diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css
index 07b4266..b1bf28f 100644
--- a/assets/webui/ofdgo.css
+++ b/assets/webui/ofdgo.css
@@ -103,7 +103,6 @@ a:focus-visible {
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
- -ms-overflow-style: none;
}
.toolbar::-webkit-scrollbar {
@@ -132,16 +131,6 @@ a:focus-visible {
white-space: nowrap;
}
-.button.primary {
- border-color: var(--accent);
- background: var(--accent);
- color: #ffffff;
-}
-
-.button.primary:hover {
- background: var(--accent-strong);
-}
-
button:not(:disabled):hover {
border-color: var(--accent);
background: #f2faf7;
@@ -172,11 +161,6 @@ button:not(:disabled):hover {
min-width: 0;
}
-.tool-group + .tool-group {
- margin-left: 0;
- padding-left: 0;
-}
-
.tool-group + .tool-group::before {
flex: 0 0 1px;
width: 1px;
@@ -332,6 +316,30 @@ body[data-hide-meta] .meta-panel {
white-space: nowrap;
}
+.app-panel {
+ margin-bottom: 12px;
+ padding-bottom: 12px;
+ border-bottom: 1px solid var(--line);
+}
+
+.app-panel .panel-row {
+ margin-bottom: 0;
+ flex-wrap: nowrap;
+}
+
+.app-status {
+ color: var(--text-muted);
+ font-size: 12px;
+}
+
+.app-refresh {
+ width: 26px;
+ padding: 0;
+ color: var(--accent-strong);
+ font-size: 18px;
+ line-height: 1;
+}
+
.page-list {
display: grid;
gap: 8px;
@@ -760,7 +768,8 @@ body[data-hide-meta] .meta-panel {
}
.font-badge.embedded,
-.font-badge.matched {
+.font-badge.matched,
+.font-badge.valid {
background: #e4f3ef;
color: var(--accent-strong);
}
@@ -776,11 +785,6 @@ body[data-hide-meta] .meta-panel {
color: #803f38;
}
-.font-badge.valid {
- background: #e4f3ef;
- color: var(--accent-strong);
-}
-
.signature-line {
display: grid;
grid-template-columns: 30px minmax(0, 1fr);
@@ -932,9 +936,6 @@ body[data-hide-meta] .meta-panel {
flex: 0 0 var(--sidebar-width);
min-width: 0;
overflow: hidden;
- font-weight: 400;
- color: var(--accent-strong);
- text-decoration: none;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -977,8 +978,6 @@ body[data-hide-meta] .meta-panel {
.toolbar {
--tool-gap: 6px;
- flex-wrap: nowrap;
- overflow-x: auto;
}
.toolbar > * {
@@ -989,19 +988,11 @@ body[data-hide-meta] .meta-panel {
display: none;
}
- .app-footer {
- flex-wrap: nowrap;
- }
-
.footer-brand,
.footer-links {
flex: 0 1 auto;
}
- .status-text {
- text-align: center;
- }
-
.workspace {
position: relative;
grid-template-columns: minmax(0, 1fr);
@@ -1059,20 +1050,6 @@ body[data-hide-meta] .meta-panel {
grid-template-columns: 1fr;
}
- .toolbar {
- height: var(--bar-height);
- min-height: var(--bar-height);
- flex-wrap: nowrap;
- align-content: center;
- overflow-x: auto;
- overflow-y: hidden;
- row-gap: 0;
- }
-
- .toolbar-space {
- display: none;
- }
-
.tool-group + .tool-group::before {
display: none;
}
@@ -1092,10 +1069,6 @@ body[data-hide-meta] .meta-panel {
font-weight: 500;
}
- .button.primary[data-short] {
- min-width: 42px;
- }
-
.icon-button {
font-size: 13px;
}
@@ -1118,8 +1091,4 @@ body[data-hide-meta] .meta-panel {
.dpi-select {
width: 48px;
}
-
- .footer-links {
- justify-content: flex-end;
- }
}
diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js
index 4bf15bb..bb125af 100644
--- a/assets/webui/ofdgo.js
+++ b/assets/webui/ofdgo.js
@@ -115,6 +115,9 @@ const el = {
pageListPanel: document.querySelector(".page-list-panel"),
pageList: document.querySelector("#pageList"),
metaPanel: document.querySelector(".meta-panel"),
+ appPanel: document.querySelector("#appPanel"),
+ offlineStatus: document.querySelector("#offlineStatus"),
+ refreshAppButton: document.querySelector("#refreshAppButton"),
metaTitle: document.querySelector("#metaTitle"),
metaAuthor: document.querySelector("#metaAuthor"),
metaVersion: document.querySelector("#metaVersion"),
@@ -152,6 +155,7 @@ el.annotationButton.addEventListener("click", toggleAnnotations);
el.pageExportFormat.addEventListener("change", () => updateDPIControl());
el.exportPageButton.addEventListener("click", exportCurrentPage);
el.exportButton.addEventListener("click", exportPDF);
+el.refreshAppButton.addEventListener("click", refreshApplication);
el.pageInput.addEventListener("change", () => {
const page = Number.parseInt(el.pageInput.value, 10);
if (Number.isFinite(page)) {
@@ -228,6 +232,10 @@ function resizeViewer() {
async function boot() {
setBusy(true, "正在准备引擎", 8, STATUS.engine);
try {
+ if (!await prepareOffline()) {
+ return;
+ }
+ setProgress("正在准备引擎", 8, STATUS.engine);
await ensureWASM();
setProgress("引擎准备完成", 70);
loadExportFormats();
@@ -247,6 +255,120 @@ async function boot() {
}
}
+async function registerOffline() {
+ const scope = new URL("./", location.href).href;
+ let registration = await navigator.serviceWorker.getRegistration(scope);
+ if (!registration || registration.scope !== scope) {
+ registration = await navigator.serviceWorker.register("./ofdgo.sw.js", { updateViaCache: "none" });
+ }
+ const worker = registration.active || registration.installing || registration.waiting;
+ await waitForWorker(worker, "activated");
+ return registration;
+}
+
+async function prepareOffline() {
+ if (!window.isSecureContext || !("serviceWorker" in navigator) || !("caches" in window) || !("locks" in navigator)) {
+ return true;
+ }
+ el.appPanel.hidden = false;
+ setProgress("正在准备离线", null, "正在准备离线");
+ try {
+ const registration = await registerOffline();
+ const result = await requestOffline(registration.active, "prepare");
+ if (result.reload) {
+ location.reload();
+ return false;
+ }
+ el.offlineStatus.textContent = "可离线";
+ } catch {
+ el.offlineStatus.textContent = "未就绪";
+ }
+ el.refreshAppButton.disabled = false;
+ return true;
+}
+
+function waitForWorker(worker, target) {
+ return new Promise((resolve, reject) => {
+ function changed() {
+ if (worker.state === target || worker.state === "activated") {
+ worker.removeEventListener("statechange", changed);
+ resolve();
+ } else if (worker.state === "redundant") {
+ worker.removeEventListener("statechange", changed);
+ reject(new Error("离线服务启动失败"));
+ }
+ }
+ worker.addEventListener("statechange", changed);
+ changed();
+ });
+}
+
+function requestOffline(worker, type) {
+ return new Promise((resolve, reject) => {
+ const channel = new MessageChannel();
+ function close() {
+ channel.port1.close();
+ worker.removeEventListener("statechange", changed);
+ }
+ function changed() {
+ if (worker.state === "redundant") {
+ close();
+ reject(new Error("离线服务已更新"));
+ }
+ }
+ channel.port1.onmessage = ({ data }) => {
+ close();
+ if (data.ok) {
+ resolve(data);
+ } else {
+ reject(new Error(data.error));
+ }
+ };
+ worker.addEventListener("statechange", changed);
+ worker.postMessage({ type }, [channel.port2]);
+ changed();
+ });
+}
+
+async function refreshApplication() {
+ if (document.body.hasAttribute("aria-busy")) {
+ return;
+ }
+ if ((state.ofdBytes || state.userFonts.length) && !window.confirm("刷新后需重新打开文件和添加字体,是否继续?")) {
+ return;
+ }
+ setBusy(true, "正在刷新应用", null, "正在刷新应用");
+ el.refreshAppButton.disabled = true;
+ const panels = document.querySelectorAll(".toolbar, .workspace");
+ panels.forEach((panel) => { panel.inert = true; });
+ try {
+ if (navigator.storage?.persist) {
+ await navigator.storage.persist().catch(() => false);
+ }
+ const scope = new URL("./", location.href).href;
+ await navigator.locks.request(`ofdgo:${scope}:refresh`, { ifAvailable: true }, async (lock) => {
+ if (!lock) {
+ throw new Error("应用正在刷新");
+ }
+ const registration = await registerOffline();
+ await registration.update();
+ if (registration.installing) {
+ await waitForWorker(registration.installing, "installed");
+ }
+ const worker = registration.waiting || registration.active;
+ await requestOffline(worker, "refresh");
+ await waitForWorker(worker, "activated");
+ location.reload();
+ });
+ } catch (err) {
+ setStatus(err.message === "应用正在刷新" ? err.message : "应用刷新失败");
+ } finally {
+ panels.forEach((panel) => { panel.inert = false; });
+ el.refreshAppButton.disabled = false;
+ setBusy(false);
+ }
+}
+
function loadExportFormats() {
const formats = callWASM("ofdgoExportFormats") || [];
state.exportFormats = formats;
@@ -532,7 +654,7 @@ async function loadDocumentLocalFonts(available, openSeq = state.openSeq) {
renderFontList();
return false;
}
- if (docFonts.length && !docNames.length) {
+ if (!docNames.length) {
state.localFonts = [];
setStatus("OFD 字体均为内嵌");
updateFontSummary();
@@ -540,7 +662,7 @@ async function loadDocumentLocalFonts(available, openSeq = state.openSeq) {
return false;
}
const docWanted = fontNameKeys(docNames);
- const docLoadLimit = docNames.length ? Math.min(LOCAL_FONT_LOAD_LIMIT, Math.max(4, docNames.length * 3)) : 6;
+ const docLoadLimit = Math.min(LOCAL_FONT_LOAD_LIMIT, Math.max(4, docNames.length * 3));
let selected = selectLocalFonts(available, docWanted, docLoadLimit);
if (!selected.length) {
const fallbackWanted = fontNameKeys(COMMON_FONT_NAMES);
@@ -549,7 +671,7 @@ async function loadDocumentLocalFonts(available, openSeq = state.openSeq) {
const emptyStatus = available.length === 0 ? "未读取到系统字体" : "未匹配到文档所需字体";
const fonts = [];
for (let i = 0; i < selected.length; i += 1) {
- setProgress(`正在读取字体 ${i + 1}/${selected.length}`, 20 + Math.round(i / Math.max(1, selected.length) * 60));
+ setProgress(`正在读取字体 ${i + 1}/${selected.length}`, 20 + Math.round(i / selected.length * 60));
const item = selected[i];
const blob = await item.blob();
if (openSeq !== state.openSeq) {
@@ -563,7 +685,7 @@ async function loadDocumentLocalFonts(available, openSeq = state.openSeq) {
if (openSeq !== state.openSeq) {
return false;
}
- state.localFonts = mergeFontRecords([], fonts);
+ state.localFonts = fonts;
setStatus(fonts.length ? `已加载 ${fonts.length} 个系统字体` : emptyStatus);
updateFontSummary();
renderFontList();
@@ -624,8 +746,9 @@ function fontData(fonts) {
}
function updateFontSummary() {
- const total = fontRecords().length;
- const enabled = fontRecords().filter((font) => font.enabled).length;
+ const fonts = fontRecords();
+ const total = fonts.length;
+ const enabled = fonts.filter((font) => font.enabled).length;
el.availableFontSummary.textContent = total ? `${enabled}/${total}` : "0";
}
@@ -640,20 +763,6 @@ function createFontRecord(name, data, source) {
};
}
-function mergeFontRecords(oldFonts, newFonts) {
- const seen = new Set(oldFonts.map((font) => normalizeFontName(font.name)));
- const merged = [...oldFonts];
- for (const font of newFonts) {
- const key = normalizeFontName(font.name);
- if (!key || seen.has(key)) {
- continue;
- }
- seen.add(key);
- merged.push(font);
- }
- return merged;
-}
-
function fontRecords() {
return [...state.localFonts, ...state.userFonts];
}
@@ -2347,10 +2456,7 @@ function setBusy(busy, text = "", percent = 0, status = "") {
return;
}
el.progressPanel.hidden = false;
- if (status) {
- setStatus(status);
- }
- setProgress(text, percent);
+ setProgress(text, percent, status);
}
function setProgress(text = "", percent = 0, status = "") {
@@ -2360,6 +2466,10 @@ function setProgress(text = "", percent = 0, status = "") {
if (status) {
setStatus(status);
}
+ el.progressBar.parentElement.hidden = percent === null;
+ if (percent === null) {
+ return;
+ }
const value = Math.max(0, Math.min(100, percent));
el.progressBar.style.width = `${value}%`;
el.progressBar.setAttribute("aria-valuenow", String(value));
diff --git a/assets/webui/ofdgo.sw.js b/assets/webui/ofdgo.sw.js
new file mode 100644
index 0000000..b3c17c7
--- /dev/null
+++ b/assets/webui/ofdgo.sw.js
@@ -0,0 +1,147 @@
+const CACHE_PREFIX = `ofdgo:${self.registration.scope}:app:`;
+const META_CACHE = `${CACHE_PREFIX}meta`;
+const ASSETS = ["./", "ofdgo.css", "ofdgo.js", "wasm_exec.js", "ofdgo.wasm", "ofdgo.sw.js"]
+ .map((path) => new URL(path, self.registration.scope).href);
+const CLIENT_PREFIX = new URL("__client/", self.registration.scope).href;
+
+self.addEventListener("install", (event) => {
+ event.waitUntil(caches.open(META_CACHE));
+});
+
+self.addEventListener("fetch", (event) => {
+ const url = resourceURL(event.request.url);
+ const navigation = event.request.mode === "navigate";
+ if (event.request.method !== "GET" || !url.startsWith(self.registration.scope) || (navigation && url !== self.registration.scope)) {
+ return;
+ }
+ event.respondWith(navigator.locks.request(CACHE_PREFIX, { mode: navigation ? "exclusive" : "shared" }, () => readResource(event, navigation)));
+});
+
+self.addEventListener("message", (event) => {
+ const type = event.data?.type;
+ const port = event.ports[0];
+ if (!port || !event.source || (type !== "prepare" && type !== "refresh")) {
+ return;
+ }
+ event.waitUntil((async () => {
+ try {
+ const reload = await navigator.locks.request(CACHE_PREFIX, async () => {
+ const meta = await caches.open(META_CACHE);
+ await cleanBundles(meta);
+ if (type === "refresh") {
+ await updateBundle(meta);
+ return true;
+ }
+ const bundle = await readBundle(meta, CLIENT_PREFIX + event.source.id);
+ if (await isBundleComplete(bundle)) {
+ return false;
+ }
+ await currentBundle(meta);
+ return true;
+ });
+ if (type === "refresh") {
+ await self.skipWaiting();
+ }
+ port.postMessage({ ok: true, reload });
+ } catch (err) {
+ port.postMessage({ error: err.message });
+ } finally {
+ port.close();
+ }
+ })());
+});
+
+function resourceURL(value) {
+ const url = new URL(value);
+ url.search = "";
+ url.hash = "";
+ if (url.href === new URL("index.html", self.registration.scope).href) {
+ return self.registration.scope;
+ }
+ return url.href;
+}
+
+async function readBundle(meta, key) {
+ const response = await meta.match(key);
+ return response ? response.json() : null;
+}
+
+async function isBundleComplete(bundle) {
+ if (!bundle || !await caches.has(bundle.name)) {
+ return false;
+ }
+ const cache = await caches.open(bundle.name);
+ const keys = await cache.keys();
+ return bundle.assets.every((url) => keys.some((key) => key.url === url));
+}
+
+async function currentBundle(meta) {
+ const bundle = await readBundle(meta, self.registration.scope);
+ return await isBundleComplete(bundle) ? bundle : updateBundle(meta);
+}
+
+async function readResource(event, navigation) {
+ const meta = await caches.open(META_CACHE);
+ let bundle;
+ if (navigation) {
+ bundle = await currentBundle(meta);
+ await meta.put(CLIENT_PREFIX + event.resultingClientId, Response.json(bundle));
+ } else {
+ bundle = await readBundle(meta, CLIENT_PREFIX + event.clientId);
+ }
+ if (!bundle || !await caches.has(bundle.name)) {
+ return Response.error();
+ }
+ const url = resourceURL(event.request.url);
+ if (!bundle.assets.includes(url)) {
+ return fetch(event.request);
+ }
+ const cache = await caches.open(bundle.name);
+ return await cache.match(url) || Response.error();
+}
+
+async function updateBundle(meta) {
+ const resources = await Promise.all(ASSETS.map(async (url) => {
+ const response = await fetch(new Request(url, { cache: "no-store" }));
+ if (!response.ok || response.redirected) {
+ throw new Error("应用资源下载失败");
+ }
+ return { url, response };
+ }));
+ if (resources.some(({ response }) => response.headers.get("X-OFDGo-Checksum") !== BUNDLE_CHECKSUM)) {
+ throw new Error("应用资源版本不一致");
+ }
+ const bundle = { name: CACHE_PREFIX + crypto.randomUUID(), assets: ASSETS };
+ try {
+ const cache = await caches.open(bundle.name);
+ for (const { url, response } of resources) {
+ await cache.put(url, response);
+ }
+ await meta.put(self.registration.scope, Response.json(bundle));
+ } catch (err) {
+ await caches.delete(bundle.name);
+ throw err;
+ }
+ return bundle;
+}
+
+async function cleanBundles(meta) {
+ const current = await readBundle(meta, self.registration.scope);
+ const keep = new Set([META_CACHE, current?.name]);
+ for (const key of await meta.keys()) {
+ if (!key.url.startsWith(CLIENT_PREFIX)) {
+ continue;
+ }
+ const client = await self.clients.get(key.url.slice(CLIENT_PREFIX.length));
+ if (client) {
+ keep.add((await readBundle(meta, key)).name);
+ } else {
+ await meta.delete(key);
+ }
+ }
+ for (const name of await caches.keys()) {
+ if (name.startsWith(CACHE_PREFIX) && !keep.has(name)) {
+ await caches.delete(name);
+ }
+ }
+}
diff --git a/cmd/webui/webui.go b/cmd/webui/webui.go
index 9d2b543..abb02e9 100644
--- a/cmd/webui/webui.go
+++ b/cmd/webui/webui.go
@@ -17,12 +17,14 @@
package main
import (
+ "bytes"
"flag"
+ "fmt"
"net/http"
"os"
- "strings"
+ "time"
- webuiassets "github.com/xiaoqidun/ofdgo/assets/webui"
+ "github.com/xiaoqidun/ofdgo/assets/webui"
)
func main() {
@@ -36,10 +38,16 @@ func main() {
// serveWebUI 创建WebUI静态文件处理器
// 返回: http.Handler HTTP处理器
func serveWebUI() http.Handler {
- files := http.FileServer(http.FS(webuiassets.FS))
+ files := http.FileServerFS(webuiassets.FS)
+ checksum := webuiassets.Checksum()
+ serviceWorker, _ := webuiassets.FS.ReadFile("ofdgo.sw.js")
+ serviceWorker = append([]byte(fmt.Sprintf("const BUNDLE_CHECKSUM = %q;\n", checksum)), serviceWorker...)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if strings.HasSuffix(r.URL.Path, ".wasm") {
- w.Header().Set("Content-Type", "application/wasm")
+ w.Header().Set("Cache-Control", "no-store")
+ w.Header().Set("X-OFDGo-Checksum", checksum)
+ if r.URL.Path == "/ofdgo.sw.js" {
+ http.ServeContent(w, r, "ofdgo.sw.js", time.Time{}, bytes.NewReader(serviceWorker))
+ return
}
files.ServeHTTP(w, r)
})
diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go
index fb1bc04..68d5c7b 100644
--- a/internal/webui/wasm.go
+++ b/internal/webui/wasm.go
@@ -27,9 +27,6 @@ import (
// currentSession 当前WebUI文档会话
var currentSession *Session
-// callbacks 浏览器回调函数引用
-var callbacks []js.Func
-
// apiResult 浏览器接口返回结果
type apiResult struct {
OK bool `json:"ok"`
@@ -64,7 +61,6 @@ func registerCallback(name string, fn func([]js.Value) (any, error)) {
return encodeResult(apiResult{OK: true, Data: data})
})
js.Global().Set(name, cb)
- callbacks = append(callbacks, cb)
}
// safeCall 调用浏览器回调并转换异常