feat(功能特性): 新增多文件标签编辑
build.yaml / build (push) Successful in 44s

This commit is contained in:
xiaoqidun committed 2026-10-22 13:15:00 +08:00
1 parent 5183ba2ab0
commit 50da1d9144
10 files changed
+1358 -594

No files matched your search

+6 -1
View File
@@ -12,9 +12,14 @@
</head> </head>
<body> <body>
<div class="app"> <div class="app">
<div id="documentTitleBar" class="document-titlebar">
<div id="documentTabs" class="document-tabs" role="tablist" aria-label="文件"></div>
<button id="documentNewTab" class="document-new-tab" type="button" title="新建标签"><span class="new-icon"></span></button>
<div class="document-title-drag"></div>
</div>
<header class="toolbar-frame"> <header class="toolbar-frame">
<div class="toolbar app-bar"> <div class="toolbar app-bar">
<input id="ofdInput" class="file-input" type="file" accept=".ofd,.pdf"> <input id="ofdInput" class="file-input" type="file" accept=".ofd,.pdf" multiple>
<input id="fontInput" class="file-input" type="file" accept=".ttf,.otf,.ttc,font/*" multiple> <input id="fontInput" class="file-input" type="file" accept=".ttf,.otf,.ttc,font/*" multiple>
<input id="fontDirectoryInput" class="file-input" type="file" webkitdirectory multiple> <input id="fontDirectoryInput" class="file-input" type="file" webkitdirectory multiple>
<div class="tool-group"> <div class="tool-group">
+95 -2
View File
@@ -14,6 +14,10 @@
--focus: #0f7b68; --focus: #0f7b68;
--sidebar-width: clamp(220px, 20vw, 292px); --sidebar-width: clamp(220px, 20vw, 292px);
--bar-bg: #fbfcfd; --bar-bg: #fbfcfd;
--titlebar-background: #e8edf0;
--titlebar-foreground: #1f2428;
--titlebar-inactive-background: #eef1f4;
--titlebar-inactive-foreground: #66737f;
--bar-height: 40px; --bar-height: 40px;
--bar-pad: 5px 10px; --bar-pad: 5px 10px;
--control-height: 28px; --control-height: 28px;
@@ -26,6 +30,91 @@
box-sizing: border-box; box-sizing: border-box;
} }
.document-titlebar {
display: flex;
min-width: 0;
align-items: stretch;
height: var(--titlebar-height, 36px);
min-height: 0;
flex: 0 0 auto;
padding-left: var(--titlebar-left, 8px);
padding-right: var(--titlebar-right, 8px);
background: var(--titlebar-background);
color: var(--titlebar-foreground);
user-select: none;
}
.document-tabs {
display: flex;
min-width: 0;
overflow-x: auto;
scrollbar-width: none;
align-items: stretch;
}
.document-tab {
display: flex;
align-items: center;
flex: 0 0 210px;
width: 210px;
min-width: 100px;
max-width: 240px;
gap: 8px;
padding: 0 8px 0 12px;
border: 0;
border-right: 1px solid var(--line);
background: transparent;
color: var(--text-muted);
font-size: 13px;
}
.document-tab[data-selected="true"] {
background: var(--bar-bg);
color: var(--titlebar-foreground);
box-shadow: inset 0 2px var(--accent);
}
.document-tab-name {
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.document-titlebar[data-active="false"] {
background: var(--titlebar-inactive-background);
color: var(--titlebar-inactive-foreground);
}
.document-titlebar[data-active="false"] .document-tab {
color: var(--titlebar-inactive-foreground);
}
.document-tab-close,
.document-new-tab {
display: grid;
place-items: center;
flex: 0 0 28px;
width: 28px;
min-width: 28px;
align-self: center;
height: min(26px, calc(var(--titlebar-height, 36px) - 6px));
border: 0;
padding: 0;
background: transparent;
}
.document-tab-close:hover,
.document-new-tab:hover {
background: var(--accent-soft);
}
.document-title-drag {
flex: 1;
min-width: 32px;
}
html { html {
width: 100%; width: 100%;
height: 100%; height: 100%;
@@ -81,14 +170,15 @@ a:focus-visible {
.app { .app {
display: grid; display: grid;
grid-template-rows: auto minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto auto minmax(0, 1fr) auto;
width: 100%; width: 100%;
height: 100dvh; height: 100dvh;
overflow: hidden; overflow: hidden;
} }
.app:has(.editor-toolbar:not([hidden])) { .app:has(.editor-toolbar:not([hidden])) {
grid-template-rows: auto auto minmax(0, 1fr) auto; grid-template-rows: auto auto auto minmax(0, 1fr) auto;
} }
.editor-toolbar { .editor-toolbar {
@@ -455,12 +545,14 @@ a:focus-visible {
display: flex; display: flex;
min-width: 0; min-width: 0;
background: var(--bar-bg); background: var(--bar-bg);
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
} }
.toolbar-frame > .toolbar { .toolbar-frame > .toolbar {
flex: 1 1 auto; flex: 1 1 auto;
border-bottom: 0; border-bottom: 0;
box-shadow: none;
} }
.app-update { .app-update {
@@ -754,6 +846,7 @@ button:not(:disabled):hover {
} }
.form-dialog::backdrop { .form-dialog::backdrop {
top: var(--titlebar-height, 36px);
background: rgb(31 36 40 / 18%); background: rgb(31 36 40 / 18%);
} }
+873 -475
View File
File diff suppressed because it is too large. Load diff
+119 -21
View File
@@ -1,5 +1,11 @@
let engineModule = null; let engineModule = null;
let engineScheduler = null; let engineScheduler = null;
let engineSequence = 0;
const engineRuntimes = new Map();
function requestKey(worker, data) {
return worker.shared ? JSON.stringify([data.engineID, data.id]) : data.id;
}
export function engineLimits() { export function engineLimits() {
engineScheduler ||= new BrowserScheduler(); engineScheduler ||= new BrowserScheduler();
@@ -31,19 +37,20 @@ class BrowserScheduler {
send(task) { send(task) {
const {worker, data, transfer} = task; const {worker, data, transfer} = task;
worker.requests.set(data.id, performance.now()); worker.requests.set(requestKey(worker, data), performance.now());
try { worker.worker.postMessage(data, transfer); } try { worker.worker.postMessage(data, transfer); }
catch (error) { catch (error) {
worker.requests.delete(data.id); worker.requests.delete(requestKey(worker, data));
worker.onmessage?.({data:{id:data.id, ok:false, error:error.message}}); worker.receive({data:{engineID:data.engineID, id:data.id, ok:false, error:error.message}});
} }
} }
complete(worker, data) { complete(worker, data) {
if (data.memory) worker.memory = data.memory; if (data.memory) worker.memory = data.memory;
if (data.id == null || data.type) return; if (data.id == null || data.type) return;
const started = worker.requests.get(data.id); const key = requestKey(worker, data);
worker.requests.delete(data.id); const started = worker.requests.get(key);
worker.requests.delete(key);
if (this.memory > this.budget) this.limit = Math.max(1, this.limit - 1); if (this.memory > this.budget) this.limit = Math.max(1, this.limit - 1);
else if (started !== undefined && data.ok && performance.now() - started > 100 && this.memory + Math.max(128 << 20, worker.memory) < this.budget) this.limit = Math.min(this.capacity, this.limit + 1); else if (started !== undefined && data.ok && performance.now() - started > 100 && this.memory + Math.max(128 << 20, worker.memory) < this.budget) this.limit = Math.min(this.capacity, this.limit + 1);
} }
@@ -72,7 +79,7 @@ class BrowserScheduler {
const index = this.queue.findIndex(task => task.worker === worker && task.data.id === id); const index = this.queue.findIndex(task => task.worker === worker && task.data.id === id);
if (index < 0) return false; if (index < 0) return false;
this.queue.splice(index, 1); this.queue.splice(index, 1);
worker.onmessage?.({data:{id, ok:false, canceled:true, error:"操作已取消"}}); worker.receive({data:{engineID:worker.engineID, id, ok:false, canceled:true, error:"操作已取消"}});
this.drain(); this.drain();
return true; return true;
} }
@@ -89,32 +96,63 @@ class BrowserWorker {
constructor(options) { constructor(options) {
engineScheduler ||= new BrowserScheduler(); engineScheduler ||= new BrowserScheduler();
this.primary = !!options.primary; this.primary = !!options.primary;
this.shared = !!options.shared;
this.engineID = this.shared ? null : `engine-${++engineSequence}`;
this.sessions = new Map();
this.closingSessions = new Set();
this.batch = !options.primary && !options.page; this.batch = !options.primary && !options.page;
this.requests = new Map(); this.requests = new Map();
this.memory = 64 << 20; this.memory = 64 << 20;
this.reservation = 0; this.reservation = 0;
this.closed = false; this.closed = false;
this.worker = new Worker("./ofdgo_wasm.js"); this.url = String(options.url || "./ofdgo_wasm.js");
this.worker = new Worker(this.url);
engineScheduler.workers.add(this); engineScheduler.workers.add(this);
this.worker.onmessage = event => { this.worker.onmessage = event => {
if (this.closed || event.data.type === "module") return; if (this.closed || event.data.type === "module") return;
engineScheduler.complete(this, event.data); engineScheduler.complete(this, event.data);
if (event.data.type === "exit") this.terminate(); if (event.data.type === "exit" && (!this.shared || !event.data.engineID)) this.terminate();
try { this.onmessage?.(event); } try { this.receive(event); }
finally { engineScheduler.drain(); } finally { engineScheduler.drain(); }
}; };
this.worker.onerror = event => { this.terminate(); this.onerror?.(event); }; this.worker.onerror = event => { this.terminate(); this.fail("onerror", event); };
this.worker.onmessageerror = event => { this.terminate(); this.onmessageerror?.(event); }; this.worker.onmessageerror = event => { this.terminate(); this.fail("onmessageerror", event); };
this.worker.addEventListener("message", event => { this.worker.addEventListener("message", event => {
if (event.data.type !== "module") return; if (event.data.type !== "module") return;
event.stopImmediatePropagation(); event.stopImmediatePropagation();
engineModule ||= WebAssembly.compileStreaming(fetch("./ofdgo.wasm")).catch(error => { engineModule = null; throw error; }); engineModule ||= WebAssembly.compileStreaming(fetch("./ofdgo.wasm")).catch(error => { engineModule = null; throw error; });
engineModule.then(module => { if (!this.closed) this.worker.postMessage({ type:"module", module }); }, error => { if (!this.closed) this.worker.postMessage({ type:"module", error:error.message }); }); engineModule.then(module => { if (!this.closed) this.worker.postMessage({ type:"module", module }); }, error => { if (!this.closed) this.worker.postMessage({ type:"module", error:error.message }); });
}); });
if (!this.shared) this.worker.postMessage({type:"open", engineID:this.engineID});
}
receive(event) {
if (!event.data.engineID && !["progress", "exit"].includes(event.data.type)) return;
if (!this.shared) {
if (event.data.engineID && event.data.engineID !== this.engineID) return;
this.onmessage?.(event);
return;
}
if (event.data.type === "closed") {
if (!this.closingSessions.delete(event.data.engineID)) return;
if (!this.sessions.size && !this.closingSessions.size) this.terminate();
return;
}
const targets = event.data.engineID ? [this.sessions.get(event.data.engineID)] : [...this.sessions.values()];
for (const session of targets) session?.receive(event);
}
fail(name, event) {
if (!this.shared) { this[name]?.(event); return; }
for (const session of [...this.sessions.values()]) {
session.terminate();
session[name]?.(event);
}
} }
postMessage(data, transfer) { postMessage(data, transfer) {
if (this.closed) throw new DOMException("引擎已关闭", "AbortError"); if (this.closed) throw new DOMException("引擎已关闭", "AbortError");
if (!this.shared) data = {...data, engineID:this.engineID};
if (data.type) { if (data.type) {
if (data.type === "cancel" && engineScheduler.cancel(this, data.id)) return; if (data.type === "cancel" && engineScheduler.cancel(this, data.id)) return;
this.worker.postMessage(data, transfer); this.worker.postMessage(data, transfer);
@@ -124,15 +162,61 @@ class BrowserWorker {
terminate() { terminate() {
if (this.closed) return; if (this.closed) return;
this.closed = true; this.closed = true;
if (engineRuntimes.get(this.url) === this) engineRuntimes.delete(this.url);
this.closingSessions.clear();
this.worker.terminate(); this.worker.terminate();
engineScheduler.remove(this); engineScheduler.remove(this);
} }
} }
class BrowserSession {
constructor(options) {
const url = String(options.url || "./ofdgo_wasm.js");
let runtime = engineRuntimes.get(url);
if (!runtime || runtime.closed) {
runtime = new BrowserWorker({primary:true, shared:true, url});
engineRuntimes.set(url, runtime);
}
this.runtime = runtime;
this.engineID = `engine-${++engineSequence}`;
this.requests = new Set();
this.closed = false;
runtime.sessions.set(this.engineID, this);
runtime.postMessage({type:"open", engineID:this.engineID});
}
receive(event) {
if (this.closed) return;
if (event.data.id != null && !event.data.type) this.requests.delete(event.data.id);
if (event.data.type === "exit") this.terminate();
this.onmessage?.(event);
}
postMessage(data, transfer) {
if (this.closed) throw new DOMException("引擎已关闭", "AbortError");
if (!data.type) this.requests.add(data.id);
this.runtime.postMessage({...data, engineID:this.engineID}, transfer);
}
terminate() {
if (this.closed) return;
this.closed = true;
this.runtime.sessions.delete(this.engineID);
for (const id of this.requests) this.runtime.requests.delete(requestKey(this.runtime, {engineID:this.engineID, id}));
this.requests.clear();
if (!this.runtime.closed) {
this.runtime.closingSessions.add(this.engineID);
this.runtime.postMessage({type:"close", engineID:this.engineID});
}
engineScheduler.drain();
}
}
export function createEngineWorker(options = {}) { export function createEngineWorker(options = {}) {
if (nativeHost) return nativeHost.worker(); if (nativeHost) return nativeHost.worker();
if (options.pages) return new BrowserEngine(); if (options.page || options.batch) return new BrowserWorker(options);
return new BrowserWorker(options); if (options.primary) return new BrowserSession(options);
return new BrowserEngine(options);
} }
export async function documentBytes(file) { export async function documentBytes(file) {
@@ -142,8 +226,10 @@ export async function documentBytes(file) {
} }
class BrowserEngine { class BrowserEngine {
constructor() { constructor(options) {
this.primary = createEngineWorker({primary:true}); this.primary = createEngineWorker({primary:true, url:options.url});
this.engineID = this.primary.engineID;
this.workerURL = options.url;
this.primary.onmessage = event => { this.primary.onmessage = event => {
const data = event.data; const data = event.data;
if (data.memory) this.memory = data.memory; if (data.memory) this.memory = data.memory;
@@ -187,7 +273,7 @@ class BrowserEngine {
} }
createSlot(source) { createSlot(source) {
const worker = createEngineWorker({page:true}); const worker = createEngineWorker({page:true, url:this.workerURL});
const slot = {worker, pending: new Map(), busy: false, memory: Math.max(128 << 20, this.memory), pages:new Set(), lastPage:0}; const slot = {worker, pending: new Map(), busy: false, memory: Math.max(128 << 20, this.memory), pages:new Set(), lastPage:0};
worker.reservation = slot.memory; worker.reservation = slot.memory;
worker.reclaim = () => { if (!slot.busy) this.closeSlot(slot); }; worker.reclaim = () => { if (!slot.busy) this.closeSlot(slot); };
@@ -269,7 +355,7 @@ class BrowserEngine {
slot.pages.delete(slot.lastPage); slot.pages.delete(slot.lastPage);
slot.pages.add(slot.lastPage); slot.pages.add(slot.lastPage);
if (slot.pages.size > 16) slot.pages.delete(slot.pages.values().next().value); if (slot.pages.size > 16) slot.pages.delete(slot.pages.values().next().value);
if (!this.closed) this.onmessage?.({data: {...result, id: data.id}}); if (!this.closed) this.onmessage?.({data: {...result, engineID:this.engineID, id: data.id}});
}).catch(error => { }).catch(error => {
if (!this.closed && revision === this.revision && error.name !== "AbortError") { if (!this.closed && revision === this.revision && error.name !== "AbortError") {
this.closeSlot(slot, error); this.closeSlot(slot, error);
@@ -279,7 +365,7 @@ class BrowserEngine {
this.primary.postMessage(data, transfer); this.primary.postMessage(data, transfer);
return; return;
} }
if (!this.closed) this.onmessage?.({data: {id: data.id, ok: false, error: error.message, canceled: error.name === "AbortError"}}); if (!this.closed) this.onmessage?.({data: {engineID:this.engineID, id: data.id, ok: false, error: error.message, canceled: error.name === "AbortError"}});
}).finally(() => { }).finally(() => {
if (!fallback) this.requests.delete(data.id); if (!fallback) this.requests.delete(data.id);
slot.busy = false; slot.busy = false;
@@ -436,14 +522,17 @@ class NativeHost {
let closed = false; let closed = false;
let unsubscribe; let unsubscribe;
let pending = Promise.resolve(); let pending = Promise.resolve();
let termination = null;
const readers = new Set(); const readers = new Set();
const inputs = new Set();
const requests = new Map(); const requests = new Map();
const completions = new Map(); const completions = new Map();
const emit = data => { const emit = data => {
if (data.type === "result") { completions.get(data.id)?.(data.result); return; } if (data.type === "result") { completions.get(data.id)?.(data.result); return; }
if (!closed) worker.onmessage?.({data}); if (!closed) worker.onmessage?.({data:{...data, engineID:worker.engineID}});
}; };
const ready = host.call("engine.open", {}).then(({engine, parallelism, cacheBytes}) => { const ready = host.call("engine.open", {}).then(({engine, parallelism, cacheBytes}) => {
worker.engineID = engine;
if (closed) return engine; if (closed) return engine;
host.parallelism = Math.max(1, parallelism || 1); host.parallelism = Math.max(1, parallelism || 1);
host.cacheBytes = cacheBytes || host.cacheBytes; host.cacheBytes = cacheBytes || host.cacheBytes;
@@ -452,8 +541,10 @@ class NativeHost {
return engine; return engine;
}).catch(error => { if (!closed) worker.onerror?.({message:error.message}); throw error; }); }).catch(error => { if (!closed) worker.onerror?.({message:error.message}); throw error; });
const worker = { const worker = {
engineID:null,
onmessage:null, onmessage:null,
onerror:null, onerror:null,
configurePages() {},
postMessage(data) { postMessage(data) {
if (closed) return; if (closed) return;
if (data.type === "cancel") { if (data.type === "cancel") {
@@ -461,6 +552,10 @@ class NativeHost {
return; return;
} }
if (data.type === "font") return; if (data.type === "font") return;
if (["ofdgoDetectFormat", "ofdgoOpen", "ofdgoConvertPDF"].includes(data.name)) {
const input = data.args?.[0];
if (input?.nativeFile && !input.temporary) inputs.add(input.nativeFile);
}
const controller = new AbortController(); const controller = new AbortController();
requests.set(data.id, controller); requests.set(data.id, controller);
const parallel = data.name === "ofdgoRenderPage"; const parallel = data.name === "ofdgoRenderPage";
@@ -502,14 +597,17 @@ class NativeHost {
} else if (data.name !== "ofdgoSaveSnapshot") pending = operation; } else if (data.name !== "ofdgoSaveSnapshot") pending = operation;
}, },
terminate() { terminate() {
if (closed) return; if (closed) return termination;
closed = true; closed = true;
for (const controller of requests.values()) controller.abort(); for (const controller of requests.values()) controller.abort();
ready.then(engine => host.call("engine.close", {engine})).catch(() => {}).finally(() => { termination = ready.then(engine => host.call("engine.close", {engine})).catch(() => {}).finally(async () => {
unsubscribe?.(); unsubscribe?.();
for (const resolve of completions.values()) resolve({ok:false, canceled:true, error:"会话已关闭"}); for (const resolve of completions.values()) resolve({ok:false, canceled:true, error:"会话已关闭"});
completions.clear(); completions.clear();
await Promise.all([...inputs].map(file => host.call("release", {file}).catch(() => {})));
inputs.clear();
}); });
return termination;
}, },
}; };
ready.catch(() => {}); ready.catch(() => {});
+98 -44
View File
@@ -1,12 +1,15 @@
importScripts("./wasm_exec.js"); importScripts("./wasm_exec.js");
let pending = Promise.resolve(); const engines = new Map();
const operations = new Map(); let resolveRuntime;
const fontRequests = new Map(); let rejectRuntime;
const runtimeReady = new Promise((resolve, reject) => { resolveRuntime = resolve; rejectRuntime = reject; });
runtimeReady.catch(() => {});
let fontSequence = 0; let fontSequence = 0;
let engineMemory = null; let engineMemory = null;
function requestFont(id, signal, name, done) { function requestFont(engine, id, signal, name, done) {
const fontRequests = engine.fontRequests;
if (signal.aborted) { done(null, "", true); return; } if (signal.aborted) { done(null, "", true); return; }
const request = ++fontSequence; const request = ++fontSequence;
const cancel = () => { const cancel = () => {
@@ -18,7 +21,7 @@ function requestFont(id, signal, name, done) {
signal.removeEventListener("abort", cancel); signal.removeEventListener("abort", cancel);
done(result.bytes, result.error || "", signal.aborted); done(result.bytes, result.error || "", signal.aborted);
}); });
self.postMessage({ id, type: "font", request, name }); self.postMessage({ engineID: engine.id, id, type: "font", request, name });
} }
self.onmessage = ({ data }) => { self.onmessage = ({ data }) => {
if (data.type === "module") { if (data.type === "module") {
@@ -26,24 +29,71 @@ self.onmessage = ({ data }) => {
else loadWASM(data.module).catch(exitWASM); else loadWASM(data.module).catch(exitWASM);
return; return;
} }
const engineID = data.engineID;
if (typeof engineID !== "string" || !engineID) {
self.postMessage({ engineID, id: data.id, ok: false, error: "missing engine ID" });
return;
}
if (data.type === "open") {
if (engines.has(engineID)) {
self.postMessage({ engineID, type: "exit", error: "engine already exists" });
return;
}
const engine = { id: engineID, operations: new Map(), fontRequests: new Map(), active: new Set(), closed: false };
engines.set(engineID, engine);
engine.pending = runtimeReady.then(async () => {
if (engine.closed) return;
const opened = JSON.parse(globalThis.ofdgoEngineOpen(engineID));
if (!opened.ok) throw new Error(opened.error);
const backends = JSON.parse(await globalThis.ofdgoEngineCall(engineID, "ofdgoRenderBackends"));
if (!backends.ok) throw new Error(backends.error);
if (!engine.closed) self.postMessage({ engineID, type: "ready", backends: backends.data, memory: engineMemory.buffer.byteLength });
}).catch(error => {
engine.closed = true;
self.postMessage({ engineID, type: "exit", error: error.message });
});
return;
}
const engine = engines.get(engineID);
if (!engine && data.type === "close") {
self.postMessage({ engineID, type: "closed" });
return;
}
if (!engine || engine.closed && data.type !== "close") {
self.postMessage({ engineID, id: data.id, ok: false, error: "unknown or closed engine" });
return;
}
if (data.type === "close") {
engine.closed = true;
for (const operation of engine.operations.values()) operation.abort();
engine.pending.then(async () => {
await Promise.all([...engine.active]);
await runtimeReady;
globalThis.ofdgoEngineClose(engineID);
engines.delete(engineID);
self.postMessage({ engineID, type: "closed" });
}).catch(() => engines.delete(engineID));
return;
}
if (data.type === "font") { if (data.type === "font") {
fontRequests.get(data.request)?.(data); engine.fontRequests.get(data.request)?.(data);
fontRequests.delete(data.request); engine.fontRequests.delete(data.request);
return; return;
} }
if (data.type === "cancel") { if (data.type === "cancel") {
operations.get(data.id)?.abort(); engine.operations.get(data.id)?.abort();
return; return;
} }
if (["ofdgoResources", "ofdgoPrepareSave", "ofdgoSaveSnapshot"].includes(data.name)) operations.set(data.id, new AbortController()); engine.operations.set(data.id, new AbortController());
if (data.name === "ofdgoSaveSnapshot") { engine.pending = engine.pending.then(() => {
pending = pending.then(() => { void handleMessage(data); }); const operation = (data.name === "ofdgoConvertFile" || data.name === "ofdgoPackFiles" ? handleBatchMessage(engine, data) : handleMessage(engine, data)).catch(error => {
return; engine.operations.delete(data.id);
} self.postMessage({ engineID, id: data.id, ok: false, error: error.message });
if (data.name === "ofdgoImportDocuments" || data.name === "ofdgoImportImages" || data.name === "ofdgoExportImages" || data.name === "ofdgoConvertFile" || data.name === "ofdgoPackFiles" || data.name === "ofdgoConvertPDF" || data.name === "ofdgoExportPage" || data.name === "ofdgoExportDocument" || data.name === "ofdgoExportAttachment" || data.name === "ofdgoExportResource" || data.name === "ofdgoExportMedia" || data.name === "ofdgoExportVersion" || data.name === "ofdgoSaveDocument" || data.name === "ofdgoSaveSigned" || data.name === "ofdgoImportPages") { });
operations.set(data.id, new AbortController()); engine.active.add(operation);
} operation.finally(() => engine.active.delete(operation));
pending = pending.then(() => data.name === "ofdgoConvertFile" || data.name === "ofdgoPackFiles" ? handleBatchMessage(data) : handleMessage(data)); if (data.name !== "ofdgoSaveSnapshot") return operation;
});
}; };
async function availableOutput(directory, base, format) { async function availableOutput(directory, base, format) {
@@ -59,7 +109,9 @@ async function availableOutput(directory, base, format) {
} }
} }
async function handleBatchMessage({ id, name, args }) { async function handleBatchMessage(engine, { id, name, args }) {
const operations = engine.operations;
const postMessage = (data, transfer) => self.postMessage({ ...data, engineID: engine.id }, transfer);
const signal = operations.get(id).signal; const signal = operations.get(id).signal;
const channel = new MessageChannel(); const channel = new MessageChannel();
const finish = (done, err) => { const finish = (done, err) => {
@@ -89,7 +141,7 @@ async function handleBatchMessage({ id, name, args }) {
if (name === "ofdgoConvertFile") { if (name === "ofdgoConvertFile") {
const [file, options, fonts, destination] = args; const [file, options, fonts, destination] = args;
credentials = options.credentials; credentials = options.credentials;
const formats = JSON.parse(globalThis.ofdgoOutputFormats()).data; const formats = JSON.parse(await globalThis.ofdgoEngineCall(engine.id, "ofdgoOutputFormats")).data;
const format = formats.find(item => item.value === options.format); const format = formats.find(item => item.value === options.format);
if (!format) throw new Error("不支持此格式"); if (!format) throw new Error("不支持此格式");
root = destination; root = destination;
@@ -100,7 +152,7 @@ async function handleBatchMessage({ id, name, args }) {
if (format.paged) directory = await root.getDirectoryHandle(target.name, { create: true }); if (format.paged) directory = await root.getDirectoryHandle(target.name, { create: true });
else { await root.getFileHandle(target.name, { create: true }); directory = root; } else { await root.getFileHandle(target.name, { create: true }); directory = root; }
created = target.name; created = target.name;
self.postMessage({ id, type: "output", name: created }); postMessage({ id, type: "output", name: created });
return target; return target;
}) : { stem: base, name: format.paged ? base : `${base}.${format.extension}` }; }) : { stem: base, name: format.paged ? base : `${base}.${format.extension}` };
const boundary = async (action, pageName) => { const boundary = async (action, pageName) => {
@@ -113,7 +165,7 @@ async function handleBatchMessage({ id, name, args }) {
const handle = await directory.getFileHandle(fileName, { create: true }); const handle = await directory.getFileHandle(fileName, { create: true });
if (!format.paged) { if (!format.paged) {
created = fileName; created = fileName;
self.postMessage({ id, type: "output", name: created }); postMessage({ id, type: "output", name: created });
} }
current.handle = handle; current.handle = handle;
output = await handle.createWritable(); output = await handle.createWritable();
@@ -126,19 +178,19 @@ async function handleBatchMessage({ id, name, args }) {
chunks = []; chunks = [];
} }
}; };
payload = await globalThis.ofdgoConvertFile(read(file), file.size, options, write, payload = await globalThis.ofdgoEngineCall(engine.id, "ofdgoConvertFile", read(file), file.size, options, write,
(phase, completed, total, done) => { (phase, completed, total, done) => {
self.postMessage({ id, type: "conversion", phase, completed, total, memory: engineMemory.buffer.byteLength }); postMessage({ id, type: "conversion", phase, completed, total, memory: engineMemory.buffer.byteLength });
finish(done); finish(done);
}, },
(action, pageName, done) => boundary(action, pageName).then(() => finish(done), err => finish(done, err)), (action, pageName, done) => boundary(action, pageName).then(() => finish(done), err => finish(done, err)),
done => finish(done), fonts, (name, done) => requestFont(id, signal, name, done)); done => finish(done), fonts, (name, done) => requestFont(engine, id, signal, name, done));
} else { } else {
const [entries, handle, compression] = args; const [entries, handle, compression] = args;
if (handle) output = await handle.createWritable(); if (handle) output = await handle.createWritable();
payload = await globalThis.ofdgoPackFiles(entries.map(entry => ({ name: entry.name, size: entry.file.size })), payload = await globalThis.ofdgoEngineCall(engine.id, "ofdgoPackFiles", entries.map(entry => ({ name: entry.name, size: entry.file.size })),
index => read(entries[index].file), write, index => read(entries[index].file), write,
(completed, total, done) => { self.postMessage({ id, type: "conversion", phase: "pack", completed, total, memory: engineMemory.buffer.byteLength }); finish(done); }, (completed, total, done) => { postMessage({ id, type: "conversion", phase: "pack", completed, total, memory: engineMemory.buffer.byteLength }); finish(done); },
done => finish(done), compression); done => finish(done), compression);
} }
signal.throwIfAborted(); signal.throwIfAborted();
@@ -157,7 +209,7 @@ async function handleBatchMessage({ id, name, args }) {
} }
result.data.size = size; result.data.size = size;
operations.delete(id); operations.delete(id);
self.postMessage({ id, ...result, memory: engineMemory.buffer.byteLength }); postMessage({ id, ...result, memory: engineMemory.buffer.byteLength });
} catch (err) { } catch (err) {
await output?.abort().catch(() => {}); await output?.abort().catch(() => {});
let message = signal.aborted ? "转换已取消" : err.message; let message = signal.aborted ? "转换已取消" : err.message;
@@ -165,7 +217,7 @@ async function handleBatchMessage({ id, name, args }) {
try { await root.removeEntry(created, { recursive: true }); } try { await root.removeEntry(created, { recursive: true }); }
catch (cleanupError) { message += `;清理失败:${cleanupError.message}`; } catch (cleanupError) { message += `;清理失败:${cleanupError.message}`; }
} }
self.postMessage({ id, ok: false, error: message, code: err.code, canceled: signal.aborted, memory: engineMemory.buffer.byteLength }); postMessage({ id, ok: false, error: message, code: err.code, canceled: signal.aborted, memory: engineMemory.buffer.byteLength });
} finally { } finally {
credentials?.password?.fill(0); credentials?.password?.fill(0);
credentials?.key?.fill(0); credentials?.key?.fill(0);
@@ -175,12 +227,15 @@ async function handleBatchMessage({ id, name, args }) {
operations.delete(id); operations.delete(id);
} }
} }
async function handleMessage({ id, name, args }) { async function handleMessage(engine, { id, name, args }) {
const operations = engine.operations;
const postMessage = (data, transfer) => self.postMessage({ ...data, engineID: engine.id }, transfer);
let output; let output;
let saveFile; let saveFile;
let saveStamp; let saveStamp;
let channel; let channel;
const signal = operations.get(id)?.signal; const signal = operations.get(id)?.signal;
const cancellable = ["ofdgoResources", "ofdgoPrepareSave", "ofdgoSaveSnapshot", "ofdgoImportDocuments", "ofdgoImportImages", "ofdgoExportImages", "ofdgoConvertPDF", "ofdgoExportPage", "ofdgoExportDocument", "ofdgoExportAttachment", "ofdgoExportResource", "ofdgoExportMedia", "ofdgoExportVersion", "ofdgoSaveDocument", "ofdgoSaveSigned", "ofdgoImportPages"].includes(name);
const importing = name === "ofdgoImportPages" || name === "ofdgoImportImages" || name === "ofdgoImportDocuments"; const importing = name === "ofdgoImportPages" || name === "ofdgoImportImages" || name === "ofdgoImportDocuments";
const converting = name === "ofdgoConvertPDF"; const converting = name === "ofdgoConvertPDF";
const saving = name === "ofdgoSaveDocument" || name === "ofdgoSaveSnapshot" || name === "ofdgoSaveSigned"; const saving = name === "ofdgoSaveDocument" || name === "ofdgoSaveSnapshot" || name === "ofdgoSaveSigned";
@@ -222,7 +277,7 @@ async function handleMessage({ id, name, args }) {
} }
const chunks = []; const chunks = [];
let size = 0; let size = 0;
if (signal) { if (cancellable) {
channel = new MessageChannel(); channel = new MessageChannel();
const finish = (done, err, commit = false) => { const finish = (done, err, commit = false) => {
channel.port1.onmessage = () => { channel.port1.onmessage = () => {
@@ -235,10 +290,10 @@ async function handleMessage({ id, name, args }) {
args.push(done => finish(done)); args.push(done => finish(done));
} else if (importing || converting) { } else if (importing || converting) {
args.push((phase, completed, total, done) => { args.push((phase, completed, total, done) => {
self.postMessage({ id, type: importing ? "import" : "conversion", phase, completed, total }); postMessage({ id, type: importing ? "import" : "conversion", phase, completed, total });
finish(done, null, importing && phase === "commit"); finish(done, null, importing && phase === "commit");
}); });
if (converting) args.push((name, done) => requestFont(id, signal, name, done)); if (converting) args.push((name, done) => requestFont(engine, id, signal, name, done));
} else { } else {
const destination = args.pop(); const destination = args.pop();
const file = destination?.fileHandle || destination; const file = destination?.fileHandle || destination;
@@ -263,17 +318,17 @@ async function handleMessage({ id, name, args }) {
}); });
if (name === "ofdgoExportDocument") { if (name === "ofdgoExportDocument") {
args.push((completed, total, done) => { args.push((completed, total, done) => {
self.postMessage({ id, type: "export", stage: "pages", completed, total }); postMessage({ id, type: "export", stage: "pages", completed, total });
finish(done); finish(done);
}); });
} else if (name === "ofdgoExportVersion") { } else if (name === "ofdgoExportVersion") {
args.push((phase, completed, total, done) => { args.push((phase, completed, total, done) => {
self.postMessage({ id, type: "export", stage: "prepare", phase, completed, total }); postMessage({ id, type: "export", stage: "prepare", phase, completed, total });
finish(done); finish(done);
}, done => finish(done)); }, done => finish(done));
} else if (saving) { } else if (saving) {
args.push((phase, completed, total, done) => { args.push((phase, completed, total, done) => {
self.postMessage({ id, type: "export", stage: "prepare", phase, completed, total }); postMessage({ id, type: "export", stage: "prepare", phase, completed, total });
finish(done); finish(done);
}); });
if (compression || name === "ofdgoSaveSnapshot") args.push(indices, compression || {mode: 0}, done => finish(done)); if (compression || name === "ofdgoSaveSnapshot") args.push(indices, compression || {mode: 0}, done => finish(done));
@@ -282,12 +337,12 @@ async function handleMessage({ id, name, args }) {
if (!saving && args.some(value => value?.mode !== undefined)) args.push(done => finish(done)); if (!saving && args.some(value => value?.mode !== undefined)) args.push(done => finish(done));
} }
} }
const payload = await globalThis[name](...args); const payload = await globalThis.ofdgoEngineCall(engine.id, name, ...args);
signal?.throwIfAborted(); signal?.throwIfAborted();
const result = typeof payload === "string" ? JSON.parse(payload) : payload; const result = typeof payload === "string" ? JSON.parse(payload) : payload;
if (signal && !importing && !converting && !resources && result.ok) { if (cancellable && !importing && !converting && !resources && result.ok) {
result.data.size = size; result.data.size = size;
if (name !== "ofdgoExportMedia") self.postMessage({ id, type: "export", stage: "save" }); if (name !== "ofdgoExportMedia") postMessage({ id, type: "export", stage: "save" });
if (output) { if (output) {
if (saveStamp) { if (saveStamp) {
const current = await saveFile.getFile(); const current = await saveFile.getFile();
@@ -304,15 +359,15 @@ async function handleMessage({ id, name, args }) {
} }
output = null; output = null;
const transfer = [...new Set([result.data?.bytes, ...(result.data?.images || []).map(image => image.bytes), ...(result.data?.fonts || []).map(font => font.data)].filter(bytes => bytes?.byteLength).map(bytes => bytes.buffer))]; const transfer = [...new Set([result.data?.bytes, ...(result.data?.images || []).map(image => image.bytes), ...(result.data?.fonts || []).map(font => font.data)].filter(bytes => bytes?.byteLength).map(bytes => bytes.buffer))];
self.postMessage({ id, ...result, memory: engineMemory.buffer.byteLength }, transfer); postMessage({ id, ...result, memory: engineMemory.buffer.byteLength }, transfer);
} catch (err) { } catch (err) {
await output?.abort().catch(() => {}); await output?.abort().catch(() => {});
const result = { id, ok: false, error: err.message, errorName: err.name }; const result = { id, ok: false, error: err.message, errorName: err.name };
if (signal?.aborted) { if (signal?.aborted) {
result.error = resources ? "查询已取消" : importing ? "导入已取消" : converting ? "转换已取消" : "导出已取消"; result.error = resources ? "查询已取消" : importing ? "导入已取消" : converting ? "转换已取消" : cancellable ? "导出已取消" : "操作已取消";
result.canceled = true; result.canceled = true;
} }
self.postMessage(result); postMessage(result);
} finally { } finally {
password?.fill(0); password?.fill(0);
key?.fill(0); key?.fill(0);
@@ -324,6 +379,7 @@ async function handleMessage({ id, name, args }) {
} }
function exitWASM(err) { function exitWASM(err) {
rejectRuntime(err || new Error("引擎运行中断"));
self.postMessage({ type: "exit", error: err?.message || "引擎运行中断" }); self.postMessage({ type: "exit", error: err?.message || "引擎运行中断" });
self.close(); self.close();
} }
@@ -335,9 +391,7 @@ async function loadWASM(module) {
self.postMessage({ type: "progress", phase: "start", text: "正在启动引擎", percent: 58 }); self.postMessage({ type: "progress", phase: "start", text: "正在启动引擎", percent: 58 });
go.run(instance).then(exitWASM, exitWASM); go.run(instance).then(exitWASM, exitWASM);
if (!go.exited) { if (!go.exited) {
const backends = JSON.parse(globalThis.ofdgoRenderBackends()); resolveRuntime();
if (!backends.ok) throw new Error(backends.error);
self.postMessage({ type: "ready", backends: backends.data, memory: engineMemory.buffer.byteLength });
} }
} }
+1 -1
View File
@@ -8,7 +8,7 @@ require (
github.com/go-text/typesetting v0.3.5 github.com/go-text/typesetting v0.3.5
github.com/tdewolff/canvas v0.0.0-20260923214215-09804640d00c github.com/tdewolff/canvas v0.0.0-20260923214215-09804640d00c
github.com/tdewolff/font v0.0.0-20261010204837-88a7f6b56918 github.com/tdewolff/font v0.0.0-20261010204837-88a7f6b56918
github.com/xiaoqidun/guigo v0.0.0-20261020075840-622fd8853d72 github.com/xiaoqidun/guigo v0.0.0-20261022051313-f2a6a53e3537
github.com/xiaoqidun/j2kgo v1.0.2 github.com/xiaoqidun/j2kgo v1.0.2
github.com/xiaoqidun/jbig2 v1.1.8 github.com/xiaoqidun/jbig2 v1.1.8
github.com/xiaoqidun/pdfgo v0.0.0-20261021071541-3fdb0cab9d95 github.com/xiaoqidun/pdfgo v0.0.0-20261021071541-3fdb0cab9d95
+2 -2
View File
@@ -60,8 +60,8 @@ github.com/tdewolff/parse/v2 v2.8.16 h1:bLk5svUOQRkW/Y2SJ+DeENSIkZBcTIkq+Atyv5D8
github.com/tdewolff/parse/v2 v2.8.16/go.mod h1:XdsoSFThlVIRIajAuqz1evNY7bagZS8LBOPA3aVopwQ= github.com/tdewolff/parse/v2 v2.8.16/go.mod h1:XdsoSFThlVIRIajAuqz1evNY7bagZS8LBOPA3aVopwQ=
github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk= github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk=
github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8= github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
github.com/xiaoqidun/guigo v0.0.0-20261020075840-622fd8853d72 h1:rzIbnmVre37b9G4EYyznItUBy+pjw6wz5Lcg50cDPf4= github.com/xiaoqidun/guigo v0.0.0-20261022051313-f2a6a53e3537 h1:s96G2hbG89CN3eWpoLD4jc9K1U6OSvD9ly30eQJXY5c=
github.com/xiaoqidun/guigo v0.0.0-20261020075840-622fd8853d72/go.mod h1:xJF8xE6Bjw+NcOZNvWJEVX/XXIkngs3ltc9F6vNcLls= github.com/xiaoqidun/guigo v0.0.0-20261022051313-f2a6a53e3537/go.mod h1:xJF8xE6Bjw+NcOZNvWJEVX/XXIkngs3ltc9F6vNcLls=
github.com/xiaoqidun/j2kgo v1.0.2 h1:OAjjEOfaBaQzBNcj9wx+eM68UI5pewR6SzNZa1R1DIw= github.com/xiaoqidun/j2kgo v1.0.2 h1:OAjjEOfaBaQzBNcj9wx+eM68UI5pewR6SzNZa1R1DIw=
github.com/xiaoqidun/j2kgo v1.0.2/go.mod h1:DCwAfuDzCDYqUiAlZ7BDwigTjaXw7o9L1FtEma/D1aA= github.com/xiaoqidun/j2kgo v1.0.2/go.mod h1:DCwAfuDzCDYqUiAlZ7BDwigTjaXw7o9L1FtEma/D1aA=
github.com/xiaoqidun/jbig2 v1.1.8 h1:dERPW7F8gs18a/DOD9gXHR3fP1Vl6KhCNsLnq5l3BIo= github.com/xiaoqidun/jbig2 v1.1.8 h1:dERPW7F8gs18a/DOD9gXHR3fP1Vl6KhCNsLnq5l3BIo=
+62 -30
View File
@@ -37,42 +37,74 @@ const (
// platformValue 浏览器参数值 // platformValue 浏览器参数值
type platformValue = js.Value type platformValue = js.Value
// wasmEngines 管理共享资源的独立浏览器业务会话
type wasmEngines struct {
resources *ofdgo.Resources
engines map[string]*businessEngine
}
// RunWASM 注册浏览器WASM接口并阻塞运行 // RunWASM 注册浏览器WASM接口并阻塞运行
func RunWASM() { func RunWASM() {
resources, _ := ofdgo.NewResources(ofdgo.ResourceOptions{Parallelism: 1, MemoryLimit: 64 << 20, CacheBytes: 48 << 20, SnapshotBytes: 32 << 20}) resources, _ := ofdgo.NewResources(ofdgo.ResourceOptions{Parallelism: 1, MemoryLimit: 64 << 20, CacheBytes: 48 << 20, SnapshotBytes: 32 << 20})
e := &businessEngine{resources: resources} engines := &wasmEngines{resources: resources, engines: make(map[string]*businessEngine)}
asynchronous := map[string]bool{ registerCallback("ofdgoEngineOpen", engines.open)
"ofdgoDetectFormat": true, registerAsyncCallback("ofdgoEngineCall", engines.call)
"ofdgoExportVersion": true, registerCallback("ofdgoEngineClose", engines.close)
"ofdgoConvertPDF": true,
"ofdgoConvertFile": true,
"ofdgoPackFiles": true,
"ofdgoExportPage": true,
"ofdgoExportDocument": true,
"ofdgoExportAttachment": true,
"ofdgoExportMedia": true,
"ofdgoResources": true,
"ofdgoExportResource": true,
"ofdgoExportImages": true,
"ofdgoImportImages": true,
"ofdgoImportPages": true,
"ofdgoImportDocuments": true,
"ofdgoSaveDocument": true,
"ofdgoPrepareSave": true,
"ofdgoSaveSnapshot": true,
"ofdgoSetEncryption": true,
"ofdgoSaveSigned": true,
}
for name, callback := range e.businessCommands() {
if asynchronous[name] {
registerAsyncCallback(name, callback)
} else {
registerCallback(name, callback)
}
}
select {} select {}
} }
// open 创建具有稳定标识的独立业务会话
// 入参: args 会话标识
// 返回: any 空结果, error 参数或会话错误
func (w *wasmEngines) open(args []js.Value) (any, error) {
if len(args) != 1 || args[0].Type() != js.TypeString || args[0].String() == "" {
return nil, fmt.Errorf("missing engine ID")
}
id := args[0].String()
if w.engines[id] != nil {
return nil, fmt.Errorf("engine already exists: %s", id)
}
w.engines[id] = &businessEngine{resources: w.resources}
return nil, nil
}
// call 按会话标识分派业务命令,不提供默认会话
// 入参: args 会话标识、命令及业务参数
// 返回: any 业务结果, error 调用错误
func (w *wasmEngines) call(args []js.Value) (any, error) {
if len(args) < 2 || args[0].Type() != js.TypeString || args[1].Type() != js.TypeString {
return nil, fmt.Errorf("missing engine ID or operation")
}
e := w.engines[args[0].String()]
if e == nil {
return nil, fmt.Errorf("unknown engine: %s", args[0].String())
}
fn := e.businessCommands()[args[1].String()]
if fn == nil {
return nil, fmt.Errorf("unknown operation: %s", args[1].String())
}
return fn(args[2:])
}
// close 释放会话的文档、编辑器、导入及剪贴板,不关闭共享资源
// 入参: args 会话标识
// 返回: any 空结果, error 参数或关闭错误
func (w *wasmEngines) close(args []js.Value) (any, error) {
if len(args) != 1 || args[0].Type() != js.TypeString || args[0].String() == "" {
return nil, fmt.Errorf("missing engine ID")
}
id := args[0].String()
e := w.engines[id]
if e == nil {
return nil, nil
}
delete(w.engines, id)
e.clearImport()
err := e.currentSession.Close()
*e = businessEngine{}
return nil, err
}
// awaitExport 等待浏览器处理导出检查点 // awaitExport 等待浏览器处理导出检查点
// 入参: fn 浏览器回调, args 回调参数 // 入参: fn 浏览器回调, args 回调参数
// 返回: error 写入错误或取消原因 // 返回: error 写入错误或取消原因
+98 -15
View File
@@ -50,6 +50,7 @@ type Host struct {
files *fileStore files *fileStore
engines map[string]*webui.NativeEngine engines map[string]*webui.NativeEngine
engineStops map[string]func() bool engineStops map[string]func() bool
closingEngines map[string]*engineClose
conversionPool *ofdgo.ConversionPool conversionPool *ofdgo.ConversionPool
resources *ofdgo.Resources resources *ofdgo.Resources
cancels map[string]context.CancelFunc cancels map[string]context.CancelFunc
@@ -59,6 +60,14 @@ type Host struct {
fontFiles map[string]string fontFiles map[string]string
fontError error fontError error
pendingPaths []string pendingPaths []string
frontendReady bool
closeAllowed bool
}
// engineClose 保存并发关闭共享的完成通知及结果
type engineClose struct {
done chan struct{}
err error
} }
// resourceReader 保存资源读取位置及关闭操作 // resourceReader 保存资源读取位置及关闭操作
@@ -92,7 +101,7 @@ func Run(ctx context.Context, paths []string, dataDir string) error {
h.conversionPool = h.resources.ConversionPool() h.conversionPool = h.resources.ConversionPool()
defer h.close() defer h.close()
bindings := make(map[string]guigo.HandlerFunc) bindings := make(map[string]guigo.HandlerFunc)
for _, name := range []string{"engine.open", "engine.close", "engine.call", "engine.start", "engine.cancel", "engine.finish", "batch", "open", "save", "directory", "file", "release", "drop.release", "child", "remove", "write.begin", "write.data", "write.commit", "write.abort", "fonts", "ready", "title"} { for _, name := range []string{"engine.open", "engine.close", "engine.call", "engine.start", "engine.cancel", "engine.finish", "batch", "open", "save", "directory", "file", "release", "drop.release", "child", "remove", "write.begin", "write.data", "write.commit", "write.abort", "fonts", "ready", "title", "window.info", "window.drag", "window.close"} {
bindings["ofdgo.host."+name] = func(ctx context.Context, args any) (any, error) { return h.call(ctx, name, args) } bindings["ofdgo.host."+name] = func(ctx context.Context, args any) (any, error) { return h.call(ctx, name, args) }
} }
static := http.FileServerFS(webuiassets.NativeFS) static := http.FileServerFS(webuiassets.NativeFS)
@@ -124,6 +133,18 @@ func Run(ctx context.Context, paths []string, dataDir string) error {
h.mu.Unlock() h.mu.Unlock()
} }
options.OnDrop = h.openPaths options.OnDrop = h.openPaths
options.OnTitleBarChanged = func(info guigo.TitleBarInfo) {
h.emit("ofdgo.window", info)
}
options.OnClosing = func(ctx context.Context) bool {
h.mu.Lock()
allowed := h.closeAllowed || !h.frontendReady
h.mu.Unlock()
if !allowed {
h.emit("ofdgo.closing", nil)
}
return allowed
}
options.OnError = func(err error) { options.OnError = func(err error) {
h.emit("ofdgo.error", err.Error()) h.emit("ofdgo.error", err.Error())
} }
@@ -514,10 +535,47 @@ func (h *Host) call(ctx context.Context, name string, value any) (any, error) {
return nil, nil return nil, nil
} }
return nil, window.SetTitle(text("title")) return nil, window.SetTitle(text("title"))
case "window.info", "window.drag", "window.close":
h.mu.Lock()
window := h.window
h.mu.Unlock()
if window == nil {
return nil, fmt.Errorf("窗口尚未就绪")
}
if name == "window.info" {
return window.TitleBarInfo()
}
if name == "window.close" {
h.mu.Lock()
h.closeAllowed = true
h.mu.Unlock()
return nil, window.Close()
}
values, ok := args["regions"].([]any)
if !ok {
return nil, fmt.Errorf("拖动区域无效")
}
regions := make([]guigo.Rect, 0, len(values))
for _, value := range values {
item, ok := value.(map[string]any)
if !ok {
return nil, fmt.Errorf("拖动区域无效")
}
x, xOK := item["x"].(float64)
y, yOK := item["y"].(float64)
width, widthOK := item["width"].(float64)
height, heightOK := item["height"].(float64)
if !xOK || !yOK || !widthOK || !heightOK {
return nil, fmt.Errorf("拖动区域无效")
}
regions = append(regions, guigo.Rect{X: x, Y: y, Width: width, Height: height})
}
return nil, window.SetDragRegions(regions)
case "ready": case "ready":
h.mu.Lock() h.mu.Lock()
paths := h.pendingPaths paths := h.pendingPaths
h.pendingPaths = nil h.pendingPaths = nil
h.frontendReady = true
h.mu.Unlock() h.mu.Unlock()
if len(paths) != 0 { if len(paths) != 0 {
h.openPaths(paths) h.openPaths(paths)
@@ -764,7 +822,21 @@ func (h *Host) loadFont(ctx context.Context, name string) ([]byte, error) {
func (h *Host) closeEngine(id string) error { func (h *Host) closeEngine(id string) error {
var results []any var results []any
h.mu.Lock() h.mu.Lock()
if closing := h.closingEngines[id]; closing != nil {
h.mu.Unlock()
<-closing.done
return closing.err
}
engine := h.engines[id] engine := h.engines[id]
if engine == nil {
h.mu.Unlock()
return nil
}
closing := &engineClose{done: make(chan struct{})}
if h.closingEngines == nil {
h.closingEngines = make(map[string]*engineClose)
}
h.closingEngines[id] = closing
delete(h.engines, id) delete(h.engines, id)
if stop := h.engineStops[id]; stop != nil { if stop := h.engineStops[id]; stop != nil {
stop() stop()
@@ -785,10 +857,13 @@ func (h *Host) closeEngine(id string) error {
for _, result := range results { for _, result := range results {
h.discard(result) h.discard(result)
} }
if engine != nil { err := engine.Close()
return engine.Close() h.mu.Lock()
} closing.err = err
return nil delete(h.closingEngines, id)
close(closing.done)
h.mu.Unlock()
return err
} }
// close 取消任务并释放当前窗口持有的会话 // close 取消任务并释放当前窗口持有的会话
@@ -797,17 +872,18 @@ func (h *Host) close() {
for _, cancel := range h.cancels { for _, cancel := range h.cancels {
cancel() cancel()
} }
engines := h.engines engines := make([]string, 0, len(h.engines)+len(h.closingEngines))
for id := range h.engines {
engines = append(engines, id)
}
for id := range h.closingEngines {
engines = append(engines, id)
}
results := h.batchResults results := h.batchResults
h.batchResults = nil h.batchResults = nil
for _, stop := range h.engineStops {
stop()
}
h.engineStops = nil
h.engines = make(map[string]*webui.NativeEngine)
h.mu.Unlock() h.mu.Unlock()
for _, engine := range engines { for _, id := range engines {
_ = engine.Close() _ = h.closeEngine(id)
} }
for _, result := range results { for _, result := range results {
h.discard(result) h.discard(result)
@@ -842,8 +918,15 @@ func windowOptions(dataDir string) (guigo.Options, error) {
return guigo.Options{}, err return guigo.Options{}, err
} }
return guigo.Options{ return guigo.Options{
AppID: applicationID, AppID: applicationID,
Title: "OFDGo - OFD编辑器", Title: "OFDGo - OFD编辑器",
TitleBar: guigo.TitleBarContent,
TitleBarColors: guigo.TitleBarColors{
Background: "#e8edf0",
Foreground: "#1f2428",
InactiveBackground: "#eef1f4",
InactiveForeground: "#66737f",
},
Icon: icon, Icon: icon,
Width: 1440, Width: 1440,
Height: 900, Height: 900,
+4 -3
View File
@@ -333,17 +333,18 @@ func (s *fileStore) resource(id string) (io.ReaderAt, int64, func() error, error
return file, info.Size(), file.Close, nil return file, info.Size(), file.Close, nil
} }
// release 释放内存结果或临时文件并取消页面清理回调 // release 释放读取快照、内存结果或临时文件,保留在途读取及源文件
// 入参: id 资源标识 // 入参: id 资源标识
func (s *fileStore) release(id string) { func (s *fileStore) release(id string) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
if entry := s.entries[id]; entry != nil && (entry.data != nil || entry.temporary) { if entry := s.entries[id]; entry != nil && (entry.data != nil || entry.temporary || entry.file != nil) {
delete(s.entries, id) delete(s.entries, id)
entry.retired = true
if entry.stop != nil { if entry.stop != nil {
entry.stop() entry.stop()
} }
if entry.file != nil { if entry.file != nil && entry.readers == 0 {
entry.file.Close() entry.file.Close()
} }
if entry.temporary { if entry.temporary {