mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-30 01:27:26 +08:00
This commit is contained in:
1 parent
7ee1052c8b
commit
080985caec
7 files changed
+348
-93
No files matched your search
+18
-2
@@ -14,9 +14,25 @@
|
|||||||
|
|
||||||
package webuiassets
|
package webuiassets
|
||||||
|
|
||||||
import "embed"
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"embed"
|
||||||
|
"encoding/hex"
|
||||||
|
)
|
||||||
|
|
||||||
// FS WebUI嵌入静态文件
|
// FS WebUI嵌入静态文件
|
||||||
//
|
//
|
||||||
//go:embed index.html ofdgo.js ofdgo.css wasm_exec.js ofdgo.wasm
|
//go:embed index.html ofdgo.css ofdgo.js ofdgo.sw.js ofdgo.wasm wasm_exec.js
|
||||||
var FS embed.FS
|
var FS embed.FS
|
||||||
|
|
||||||
|
// Checksum 计算WebUI嵌入资源校验值
|
||||||
|
// 返回: string SHA-256十六进制校验值
|
||||||
|
func Checksum() string {
|
||||||
|
hash := sha256.New()
|
||||||
|
entries, _ := FS.ReadDir(".")
|
||||||
|
for _, entry := range entries {
|
||||||
|
data, _ := FS.ReadFile(entry.Name())
|
||||||
|
hash.Write(data)
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(hash.Sum(nil))
|
||||||
|
}
|
||||||
+10
-1
@@ -13,7 +13,7 @@
|
|||||||
<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">
|
||||||
<button id="ofdButton" class="button primary" type="button" data-short="打开" aria-label="打开">打开</button>
|
<button id="ofdButton" class="icon-button" type="button" title="打开" aria-label="打开">🗁</button>
|
||||||
<button id="togglePagesButton" class="icon-button" type="button" title="页面栏" aria-label="页面栏" aria-pressed="true">☰</button>
|
<button id="togglePagesButton" class="icon-button" type="button" title="页面栏" aria-label="页面栏" aria-pressed="true">☰</button>
|
||||||
<button id="toggleMetaButton" class="icon-button" type="button" title="信息栏" aria-label="信息栏" aria-pressed="true">ⓘ</button>
|
<button id="toggleMetaButton" class="icon-button" type="button" title="信息栏" aria-label="信息栏" aria-pressed="true">ⓘ</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -70,6 +70,15 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<aside class="meta-panel">
|
<aside class="meta-panel">
|
||||||
|
<section id="appPanel" class="app-panel" aria-labelledby="appPanelTitle" hidden>
|
||||||
|
<div class="panel-row">
|
||||||
|
<div id="appPanelTitle" class="panel-title compact">应用</div>
|
||||||
|
<div class="panel-actions">
|
||||||
|
<span id="offlineStatus" class="app-status" role="status">准备中</span>
|
||||||
|
<button id="refreshAppButton" class="small-button app-refresh" type="button" title="刷新应用" aria-label="刷新应用" disabled>↻</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<div class="panel-title">文档</div>
|
<div class="panel-title">文档</div>
|
||||||
<dl class="meta-list">
|
<dl class="meta-list">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
+26
-57
@@ -103,7 +103,6 @@ a:focus-visible {
|
|||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
-ms-overflow-style: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar::-webkit-scrollbar {
|
.toolbar::-webkit-scrollbar {
|
||||||
@@ -132,16 +131,6 @@ a:focus-visible {
|
|||||||
white-space: nowrap;
|
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 {
|
button:not(:disabled):hover {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
background: #f2faf7;
|
background: #f2faf7;
|
||||||
@@ -172,11 +161,6 @@ button:not(:disabled):hover {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-group + .tool-group {
|
|
||||||
margin-left: 0;
|
|
||||||
padding-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-group + .tool-group::before {
|
.tool-group + .tool-group::before {
|
||||||
flex: 0 0 1px;
|
flex: 0 0 1px;
|
||||||
width: 1px;
|
width: 1px;
|
||||||
@@ -332,6 +316,30 @@ body[data-hide-meta] .meta-panel {
|
|||||||
white-space: nowrap;
|
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 {
|
.page-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -760,7 +768,8 @@ body[data-hide-meta] .meta-panel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.font-badge.embedded,
|
.font-badge.embedded,
|
||||||
.font-badge.matched {
|
.font-badge.matched,
|
||||||
|
.font-badge.valid {
|
||||||
background: #e4f3ef;
|
background: #e4f3ef;
|
||||||
color: var(--accent-strong);
|
color: var(--accent-strong);
|
||||||
}
|
}
|
||||||
@@ -776,11 +785,6 @@ body[data-hide-meta] .meta-panel {
|
|||||||
color: #803f38;
|
color: #803f38;
|
||||||
}
|
}
|
||||||
|
|
||||||
.font-badge.valid {
|
|
||||||
background: #e4f3ef;
|
|
||||||
color: var(--accent-strong);
|
|
||||||
}
|
|
||||||
|
|
||||||
.signature-line {
|
.signature-line {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 30px minmax(0, 1fr);
|
grid-template-columns: 30px minmax(0, 1fr);
|
||||||
@@ -932,9 +936,6 @@ body[data-hide-meta] .meta-panel {
|
|||||||
flex: 0 0 var(--sidebar-width);
|
flex: 0 0 var(--sidebar-width);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-weight: 400;
|
|
||||||
color: var(--accent-strong);
|
|
||||||
text-decoration: none;
|
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@@ -977,8 +978,6 @@ body[data-hide-meta] .meta-panel {
|
|||||||
|
|
||||||
.toolbar {
|
.toolbar {
|
||||||
--tool-gap: 6px;
|
--tool-gap: 6px;
|
||||||
flex-wrap: nowrap;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar > * {
|
.toolbar > * {
|
||||||
@@ -989,19 +988,11 @@ body[data-hide-meta] .meta-panel {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-footer {
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-brand,
|
.footer-brand,
|
||||||
.footer-links {
|
.footer-links {
|
||||||
flex: 0 1 auto;
|
flex: 0 1 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-text {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspace {
|
.workspace {
|
||||||
position: relative;
|
position: relative;
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
@@ -1059,20 +1050,6 @@ body[data-hide-meta] .meta-panel {
|
|||||||
grid-template-columns: 1fr;
|
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 {
|
.tool-group + .tool-group::before {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -1092,10 +1069,6 @@ body[data-hide-meta] .meta-panel {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button.primary[data-short] {
|
|
||||||
min-width: 42px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-button {
|
.icon-button {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
@@ -1118,8 +1091,4 @@ body[data-hide-meta] .meta-panel {
|
|||||||
.dpi-select {
|
.dpi-select {
|
||||||
width: 48px;
|
width: 48px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-links {
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
+134
-24
@@ -115,6 +115,9 @@ const el = {
|
|||||||
pageListPanel: document.querySelector(".page-list-panel"),
|
pageListPanel: document.querySelector(".page-list-panel"),
|
||||||
pageList: document.querySelector("#pageList"),
|
pageList: document.querySelector("#pageList"),
|
||||||
metaPanel: document.querySelector(".meta-panel"),
|
metaPanel: document.querySelector(".meta-panel"),
|
||||||
|
appPanel: document.querySelector("#appPanel"),
|
||||||
|
offlineStatus: document.querySelector("#offlineStatus"),
|
||||||
|
refreshAppButton: document.querySelector("#refreshAppButton"),
|
||||||
metaTitle: document.querySelector("#metaTitle"),
|
metaTitle: document.querySelector("#metaTitle"),
|
||||||
metaAuthor: document.querySelector("#metaAuthor"),
|
metaAuthor: document.querySelector("#metaAuthor"),
|
||||||
metaVersion: document.querySelector("#metaVersion"),
|
metaVersion: document.querySelector("#metaVersion"),
|
||||||
@@ -152,6 +155,7 @@ el.annotationButton.addEventListener("click", toggleAnnotations);
|
|||||||
el.pageExportFormat.addEventListener("change", () => updateDPIControl());
|
el.pageExportFormat.addEventListener("change", () => updateDPIControl());
|
||||||
el.exportPageButton.addEventListener("click", exportCurrentPage);
|
el.exportPageButton.addEventListener("click", exportCurrentPage);
|
||||||
el.exportButton.addEventListener("click", exportPDF);
|
el.exportButton.addEventListener("click", exportPDF);
|
||||||
|
el.refreshAppButton.addEventListener("click", refreshApplication);
|
||||||
el.pageInput.addEventListener("change", () => {
|
el.pageInput.addEventListener("change", () => {
|
||||||
const page = Number.parseInt(el.pageInput.value, 10);
|
const page = Number.parseInt(el.pageInput.value, 10);
|
||||||
if (Number.isFinite(page)) {
|
if (Number.isFinite(page)) {
|
||||||
@@ -228,6 +232,10 @@ function resizeViewer() {
|
|||||||
async function boot() {
|
async function boot() {
|
||||||
setBusy(true, "正在准备引擎", 8, STATUS.engine);
|
setBusy(true, "正在准备引擎", 8, STATUS.engine);
|
||||||
try {
|
try {
|
||||||
|
if (!await prepareOffline()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setProgress("正在准备引擎", 8, STATUS.engine);
|
||||||
await ensureWASM();
|
await ensureWASM();
|
||||||
setProgress("引擎准备完成", 70);
|
setProgress("引擎准备完成", 70);
|
||||||
loadExportFormats();
|
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() {
|
function loadExportFormats() {
|
||||||
const formats = callWASM("ofdgoExportFormats") || [];
|
const formats = callWASM("ofdgoExportFormats") || [];
|
||||||
state.exportFormats = formats;
|
state.exportFormats = formats;
|
||||||
@@ -532,7 +654,7 @@ async function loadDocumentLocalFonts(available, openSeq = state.openSeq) {
|
|||||||
renderFontList();
|
renderFontList();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (docFonts.length && !docNames.length) {
|
if (!docNames.length) {
|
||||||
state.localFonts = [];
|
state.localFonts = [];
|
||||||
setStatus("OFD 字体均为内嵌");
|
setStatus("OFD 字体均为内嵌");
|
||||||
updateFontSummary();
|
updateFontSummary();
|
||||||
@@ -540,7 +662,7 @@ async function loadDocumentLocalFonts(available, openSeq = state.openSeq) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const docWanted = fontNameKeys(docNames);
|
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);
|
let selected = selectLocalFonts(available, docWanted, docLoadLimit);
|
||||||
if (!selected.length) {
|
if (!selected.length) {
|
||||||
const fallbackWanted = fontNameKeys(COMMON_FONT_NAMES);
|
const fallbackWanted = fontNameKeys(COMMON_FONT_NAMES);
|
||||||
@@ -549,7 +671,7 @@ async function loadDocumentLocalFonts(available, openSeq = state.openSeq) {
|
|||||||
const emptyStatus = available.length === 0 ? "未读取到系统字体" : "未匹配到文档所需字体";
|
const emptyStatus = available.length === 0 ? "未读取到系统字体" : "未匹配到文档所需字体";
|
||||||
const fonts = [];
|
const fonts = [];
|
||||||
for (let i = 0; i < selected.length; i += 1) {
|
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 item = selected[i];
|
||||||
const blob = await item.blob();
|
const blob = await item.blob();
|
||||||
if (openSeq !== state.openSeq) {
|
if (openSeq !== state.openSeq) {
|
||||||
@@ -563,7 +685,7 @@ async function loadDocumentLocalFonts(available, openSeq = state.openSeq) {
|
|||||||
if (openSeq !== state.openSeq) {
|
if (openSeq !== state.openSeq) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
state.localFonts = mergeFontRecords([], fonts);
|
state.localFonts = fonts;
|
||||||
setStatus(fonts.length ? `已加载 ${fonts.length} 个系统字体` : emptyStatus);
|
setStatus(fonts.length ? `已加载 ${fonts.length} 个系统字体` : emptyStatus);
|
||||||
updateFontSummary();
|
updateFontSummary();
|
||||||
renderFontList();
|
renderFontList();
|
||||||
@@ -624,8 +746,9 @@ function fontData(fonts) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateFontSummary() {
|
function updateFontSummary() {
|
||||||
const total = fontRecords().length;
|
const fonts = fontRecords();
|
||||||
const enabled = fontRecords().filter((font) => font.enabled).length;
|
const total = fonts.length;
|
||||||
|
const enabled = fonts.filter((font) => font.enabled).length;
|
||||||
el.availableFontSummary.textContent = total ? `${enabled}/${total}` : "0";
|
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() {
|
function fontRecords() {
|
||||||
return [...state.localFonts, ...state.userFonts];
|
return [...state.localFonts, ...state.userFonts];
|
||||||
}
|
}
|
||||||
@@ -2347,10 +2456,7 @@ function setBusy(busy, text = "", percent = 0, status = "") {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
el.progressPanel.hidden = false;
|
el.progressPanel.hidden = false;
|
||||||
if (status) {
|
setProgress(text, percent, status);
|
||||||
setStatus(status);
|
|
||||||
}
|
|
||||||
setProgress(text, percent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setProgress(text = "", percent = 0, status = "") {
|
function setProgress(text = "", percent = 0, status = "") {
|
||||||
@@ -2360,6 +2466,10 @@ function setProgress(text = "", percent = 0, status = "") {
|
|||||||
if (status) {
|
if (status) {
|
||||||
setStatus(status);
|
setStatus(status);
|
||||||
}
|
}
|
||||||
|
el.progressBar.parentElement.hidden = percent === null;
|
||||||
|
if (percent === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const value = Math.max(0, Math.min(100, percent));
|
const value = Math.max(0, Math.min(100, percent));
|
||||||
el.progressBar.style.width = `${value}%`;
|
el.progressBar.style.width = `${value}%`;
|
||||||
el.progressBar.setAttribute("aria-valuenow", String(value));
|
el.progressBar.setAttribute("aria-valuenow", String(value));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
-5
@@ -17,12 +17,14 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"flag"
|
"flag"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"time"
|
||||||
|
|
||||||
webuiassets "github.com/xiaoqidun/ofdgo/assets/webui"
|
"github.com/xiaoqidun/ofdgo/assets/webui"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -36,10 +38,16 @@ func main() {
|
|||||||
// serveWebUI 创建WebUI静态文件处理器
|
// serveWebUI 创建WebUI静态文件处理器
|
||||||
// 返回: http.Handler HTTP处理器
|
// 返回: http.Handler HTTP处理器
|
||||||
func serveWebUI() http.Handler {
|
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) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if strings.HasSuffix(r.URL.Path, ".wasm") {
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
w.Header().Set("Content-Type", "application/wasm")
|
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)
|
files.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -27,9 +27,6 @@ import (
|
|||||||
// currentSession 当前WebUI文档会话
|
// currentSession 当前WebUI文档会话
|
||||||
var currentSession *Session
|
var currentSession *Session
|
||||||
|
|
||||||
// callbacks 浏览器回调函数引用
|
|
||||||
var callbacks []js.Func
|
|
||||||
|
|
||||||
// apiResult 浏览器接口返回结果
|
// apiResult 浏览器接口返回结果
|
||||||
type apiResult struct {
|
type apiResult struct {
|
||||||
OK bool `json:"ok"`
|
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})
|
return encodeResult(apiResult{OK: true, Data: data})
|
||||||
})
|
})
|
||||||
js.Global().Set(name, cb)
|
js.Global().Set(name, cb)
|
||||||
callbacks = append(callbacks, cb)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// safeCall 调用浏览器回调并转换异常
|
// safeCall 调用浏览器回调并转换异常
|
||||||
|
|||||||
Reference in new issue
Block a user