diff --git a/assets/webui/index.html b/assets/webui/index.html index d087e94..e79b153 100644 --- a/assets/webui/index.html +++ b/assets/webui/index.html @@ -12,9 +12,14 @@
+
+
+ +
+
- +
diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css index 33c2b4e..822f71d 100644 --- a/assets/webui/ofdgo.css +++ b/assets/webui/ofdgo.css @@ -14,6 +14,10 @@ --focus: #0f7b68; --sidebar-width: clamp(220px, 20vw, 292px); --bar-bg: #fbfcfd; + --titlebar-background: #e8edf0; + --titlebar-foreground: #1f2428; + --titlebar-inactive-background: #eef1f4; + --titlebar-inactive-foreground: #66737f; --bar-height: 40px; --bar-pad: 5px 10px; --control-height: 28px; @@ -26,6 +30,91 @@ 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 { width: 100%; height: 100%; @@ -81,14 +170,15 @@ a:focus-visible { .app { 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%; height: 100dvh; overflow: 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 { @@ -455,12 +545,14 @@ a:focus-visible { display: flex; min-width: 0; background: var(--bar-bg); + border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); } .toolbar-frame > .toolbar { flex: 1 1 auto; border-bottom: 0; + box-shadow: none; } .app-update { @@ -754,6 +846,7 @@ button:not(:disabled):hover { } .form-dialog::backdrop { + top: var(--titlebar-height, 36px); background: rgb(31 36 40 / 18%); } diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index a2665d3..112f8b1 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -29,14 +29,13 @@ const batchElements = Object.fromEntries(["Button", "Panel", "Form", "Input", "A const fontBinding = { font: null, choices: [], sequence: 0 }; const fontBindingElements = Object.fromEntries(["Panel", "Form", "Title", "Name", "SourceRow", "Source", "File", "Hint", "Status", "Cancel", "Submit"].map(name => [name, document.querySelector(`#fontBinding${name}`)])); const resourceElements = Object.fromEntries(["ManageButton", "Panel", "Form", "Tabs", "List", "Empty", "Reference", "ReferenceGroup", "Actions", "Add", "Preview", "Export", "Rename", "Replace", "Delete", "Locate", "Close", "Binding", "BindingActions", "Page", "Object", "Pick", "Bind", "Unbind", "Viewer", "Status", "File"].map(name => [name, document.querySelector(`#resource${name}`)])); -const resourceManager = { kind: "Font", scope: "", views: new Map(), items: [], filteredItems: [], start: -1, end: -1, selected: null, references: [], referenceOffset: 0, targets: [], pick: null, busy: false, sequence: 0, url: null, fileAction: null }; +let resourceManager = createResourceManager(); const settingsElements = Object.fromEntries(["Button", "Panel", "Form", "Title", "Tabs", "Context", "Content", "Actions", "Status"].map(name => [name, document.querySelector(`#settings${name}`)])); -const settings = { kind: "bookmarks", locked: false, views: new Map(), sequence: 0, busy: false, page: 0, scope: "document", object: "", path: [], selected: -1, data: null, actions: [], drafts: new Map(), view: "", baseline: {}, suspended: false, invoice: false }; -const invoicePanel = { tab: "basic", views: new Map(), entries: [], formats: [], drafts: new Map(), key: "", result: null }; +let settings = createSettings(); +let invoicePanel = createInvoicePanel(); const invoiceDocuments = new WeakMap(); const invoiceReferenceStandard = "http://www.ofdspec.org/2016"; -let wasmPromise = null; let remoteDownload = null; let wasmWorker = null; let wasmRequestID = 0; @@ -46,128 +45,150 @@ let credentialRequest = null; let documentRequest = null; let cancelPageTouch = null; -const state = { - conversionWarnings: [], - composite: null, - annotationEdit: null, - annotationCreate: null, - ready: false, - wasmExited: false, - wasmSeq: 0, - wasmRecovering: false, - wasmRecoveries: 0, - exporting: false, - saving: false, - saveRequestID: 0, - signing: false, - signCanceled: false, - exportRequestID: 0, - editing: false, - selectObjects: true, - dirty: false, - editorInfo: null, - editorViews: new Map(), - savedRevision: null, - saveHandle: null, - saveStamp: null, - packageDirty: false, - encryptionDirty: false, - insertObject: null, - importPageCount: 0, - importDocumentCount: 0, - importDocumentMode: false, - importEncrypted: false, - importing: false, - imageImporting: false, - createInsert: false, - createPackage: false, - createDocumentAt: null, - documentManagement: null, - versionManagement: null, - infoDocumentIndex: null, - pageSelection: new Set(), - pageSelectionAnchor: null, - pageMultiSelect: false, - pageTouchPointer: null, - outlineSelection: null, - outlineSeq: 0, - openActionEnabled: false, - openActionDocument: false, - openActionPage: null, - openActionTask: null, - openActionFrame: 0, - outlineAction: "add", - styleOriginal: null, - objectClipboard: null, - styleClipboard: null, - outlineExpanded: new Map(), - textFonts: [], - textFontID: null, - textDefaults: { type: "TextObject", size: 16 * 25.4 / 72, color: "#000000", wrap: true, align: "left", paragraphHeight: 0, letterSpacing: 0 }, - ofdBytes: null, - fileName: "ofdgo.ofd", - openSeq: 0, - pageSeq: 0, - searchSeq: 0, - searchQuery: "", - searchMatches: [], - searchIndex: -1, - navigationScroll: new Map(), - fontSyncPending: false, - fontCatalogLoading: false, - fontFacesLoading: false, - fontRenderPending: false, - fontSyncing: false, - doc: null, - pageIndex: 0, - pageNavigation: null, - scale: 1, - rotation: 0, - panMode: false, - pan: null, - fitMode: "width", - continuous: false, - pageLayoutPreference: 1, - get pageLayout() { - return this.doc?.pageCount > 1 ? this.pageLayoutPreference : 1; - }, - renderAnnotations: true, - renderBackend: "canvas", - renderMode: "svg", - renderDPI: DEFAULT_IMAGE_DPI, - pageCache: new Map(), - mediaPlayers: new Map(), - svgFonts: new Map(), - svgFontLoads: new Map(), - svgImages: new Map(), - selectedPages: new Set(), - documentSelection: null, - pageInFlight: new Map(), - pageRenderQueue: [], - pagePrefetchIndex: null, - pagePrefetchDirection: 1, - pageRenderRunning: false, - pageRenderWorkers: 0, - pageObserver: null, - visiblePages: new Set(), - scrollFrame: 0, - pageWindow: null, - thumbnailWindow: null, - thumbnailFrame: 0, - pageDragIndex: null, - pageListCurrent: null, - thumbnailInFlight: new Set(), - thumbnailObserver: null, - visibleThumbnails: new Set(), - exportFormats: [], - exportPages: null, - exportBackdrop: false, - encryptionChanged: false, - showPages: !COMPACT_LAYOUT.matches, - showMeta: !COMPACT_LAYOUT.matches, -}; +const workspace = { tabs: [], active: null, sequence: 0, nextID: 0, pending: null, switching: false, closing: false }; + +function createResourceManager() { + return { kind: "Font", scope: "", views: new Map(), items: [], filteredItems: [], start: -1, end: -1, selected: null, references: [], referenceOffset: 0, targets: [], pick: null, busy: false, sequence: 0, url: null, fileAction: null }; +} + +function createSettings() { + return { kind: "bookmarks", locked: false, views: new Map(), sequence: 0, busy: false, page: 0, scope: "document", object: "", path: [], selected: -1, data: null, actions: [], drafts: new Map(), view: "", baseline: {}, suspended: false, invoice: false }; +} + +function createInvoicePanel() { + return { tab: "basic", views: new Map(), entries: [], formats: [], drafts: new Map(), key: "", result: null }; +} + +function createDocumentState() { + return { + conversionWarnings: [], + composite: null, + annotationEdit: null, + annotationCreate: null, + ready: false, + wasmExited: false, + wasmSeq: 0, + wasmRecovering: false, + wasmRecoveries: 0, + exporting: false, + saving: false, + saveRequestID: 0, + signing: false, + signCanceled: false, + exportRequestID: 0, + editing: false, + selectObjects: true, + dirty: false, + editorInfo: null, + editorViews: new Map(), + savedRevision: null, + saveHandle: null, + saveStamp: null, + packageDirty: false, + encryptionDirty: false, + insertObject: null, + importPageCount: 0, + importDocumentCount: 0, + importDocumentMode: false, + importEncrypted: false, + importing: false, + imageImporting: false, + createInsert: false, + createPackage: false, + createDocumentAt: null, + documentManagement: null, + versionManagement: null, + infoDocumentIndex: null, + pageSelection: new Set(), + pageSelectionAnchor: null, + pageMultiSelect: false, + pageTouchPointer: null, + outlineSelection: null, + outlineSeq: 0, + openActionEnabled: false, + openActionDocument: false, + openActionPage: null, + openActionTask: null, + openActionFrame: 0, + outlineAction: "add", + styleOriginal: null, + objectClipboard: null, + styleClipboard: null, + outlineExpanded: new Map(), + textFonts: [], + textFontID: null, + textDefaults: { type: "TextObject", size: 16 * 25.4 / 72, color: "#000000", wrap: true, align: "left", paragraphHeight: 0, letterSpacing: 0 }, + ofdBytes: null, + fileName: "ofdgo.ofd", + openSeq: 0, + pageSeq: 0, + searchSeq: 0, + searchQuery: "", + searchMatches: [], + searchIndex: -1, + searchRunning: false, + navigationScroll: new Map(), + fontSyncPending: false, + fontCatalogLoading: false, + fontFacesLoading: false, + fontRenderPending: false, + fontSyncing: false, + doc: null, + pageIndex: 0, + pageNavigation: null, + scale: 1, + rotation: 0, + panMode: false, + pan: null, + fitMode: "width", + continuous: false, + pageLayoutPreference: 1, + get pageLayout() { + return this.doc?.pageCount > 1 ? this.pageLayoutPreference : 1; + }, + renderAnnotations: true, + renderBackend: "canvas", + renderMode: "svg", + renderDPI: DEFAULT_IMAGE_DPI, + pageCache: new Map(), + mediaPlayers: new Map(), + svgFonts: new Map(), + svgFontLoads: new Map(), + svgImages: new Map(), + selectedPages: new Set(), + documentSelection: null, + pageInFlight: new Map(), + pageRenderQueue: [], + pagePrefetchIndex: null, + pagePrefetchDirection: 1, + pageRenderRunning: false, + pageRenderWorkers: 0, + pageObserver: null, + visiblePages: new Set(), + scrollFrame: 0, + pageWindow: null, + thumbnailWindow: null, + thumbnailFrame: 0, + pageDragIndex: null, + pageListCurrent: null, + thumbnailInFlight: new Set(), + thumbnailObserver: null, + visibleThumbnails: new Set(), + exportFormats: [], + exportPages: null, + exportBackdrop: false, + encryptionChanged: false, + showPages: !COMPACT_LAYOUT.matches, + showMeta: !COMPACT_LAYOUT.matches, + }; +} + +let state = createDocumentState(); const el = { + documentTitleBar: document.querySelector("#documentTitleBar"), + documentTabs: document.querySelector("#documentTabs"), + documentNewTab: document.querySelector("#documentNewTab"), signButton: document.querySelector("#signButton"), signPanel: document.querySelector("#signPanel"), signForm: document.querySelector("#signForm"), @@ -246,6 +267,8 @@ const el = { ofdButton: document.querySelector("#ofdButton"), closeButton: document.querySelector("#closeButton"), closePanel: document.querySelector("#closePanel"), + closeTitle: document.querySelector("#closePanel .form-title"), + closeMessage: document.querySelector("#closePanel .confirm-message"), newButton: document.querySelector("#newButton"), editButton: document.querySelector("#editButton"), editNotice: document.querySelector("#editNotice"), @@ -1625,7 +1648,7 @@ document.addEventListener("pointerdown", () => el.viewerPanel.classList.remove(" el.fontAddButton.addEventListener("click", () => openFontFile(el.fontInput)); el.fontDirectoryButton.addEventListener("click", () => openFontFile(el.fontDirectoryInput)); el.localFontButton.addEventListener("click", loadLocalFonts); -el.ofdInput.addEventListener("change", () => openOFD(el.ofdInput.files[0])); +el.ofdInput.addEventListener("change", async () => { for (const file of Array.from(el.ofdInput.files)) await openOFD(file); }); el.fontInput.addEventListener("change", openSelectedFonts); el.fontDirectoryInput.addEventListener("change", openSelectedFonts); el.prevButton.addEventListener("click", () => renderPage(pageGroupStart() - state.pageLayout)); @@ -1765,6 +1788,9 @@ document.addEventListener("visibilitychange", () => { el.fontDirectoryButton.disabled = !("webkitdirectory" in el.fontDirectoryInput); nativeHost?.installFileInputs(error => showError(error, false)); if (nativeHost) document.title = "OFDGo - OFD编辑器"; +documentTab(); +renderDocumentTabs(); +el.documentNewTab.addEventListener("click", openEmptyDocumentTab); updateSidebarState(); boot(); @@ -1778,6 +1804,16 @@ function handleKeyDown(event) { } const key = event.key; const target = event.target; + if (nativeHost && (event.ctrlKey || event.metaKey) && (key === "Tab" || key.toLowerCase() === "w")) { + event.preventDefault(); + if (document.body.hasAttribute("data-busy") || formDialogOpen()) return; + if (key.toLowerCase() === "w") closeFile(); + else { + const count = workspace.tabs.length, index = workspace.tabs.indexOf(documentTab()); + activateDocumentTab(workspace.tabs[(index + (event.shiftKey ? -1 : 1) + count) % count]); + } + return; + } if ((event.ctrlKey || event.metaKey) && key.toLowerCase() === "s" && state.doc && (state.editorInfo || state.ofdBytes)) { event.preventDefault(); @@ -2336,6 +2372,386 @@ function updatePageDirection() { el.pagePortrait.checked = complete && !landscape; } +function documentTab() { + if (!workspace.active) { + const tab = createDocumentTab(state); + tab.resources = resourceManager; + tab.settings = settings; + tab.invoice = invoicePanel; + workspace.tabs.push(tab); + workspace.active = tab; + } + return workspace.active; +} + +function isForegroundDocument(tab) { + return !tab.closed && (workspace.pending || workspace.active) === tab; +} + +function applyTitleBarInfo(info) { + if (!info || !info.height) return; + workspace.titleBarReady = true; + document.body.style.setProperty("--titlebar-height", `${info.height}px`); + el.documentTitleBar.style.setProperty("--titlebar-left", `${info.left}px`); + el.documentTitleBar.style.setProperty("--titlebar-right", `${info.right}px`); + for (const [name, value] of Object.entries(info.colors || {})) { + if (value) el.documentTitleBar.style.setProperty(`--titlebar-${name.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`)}`, value); + } + el.documentTitleBar.dataset.active = String(info.active); + el.documentTitleBar.dataset.maximized = String(info.maximized); + updateTitleBarRegions(); +} + +function updateTitleBarRegions() { + if (!nativeHost || !workspace.titleBarReady) return; + const bounds = el.documentTitleBar.querySelector(".document-title-drag").getBoundingClientRect(); + const regions = bounds.width > 0 && bounds.height > 0 ? [{x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height}] : []; + workspace.dragRegions = regions; + if (workspace.dragUpdating) return; + workspace.dragUpdating = true; + Promise.resolve().then(async () => { + while (workspace.dragRegions) { + const regions = workspace.dragRegions; + workspace.dragRegions = null; + await nativeHost.call("window.drag", {regions}); + } + }).catch(error => showError(error, false)).finally(() => { + workspace.dragUpdating = false; + if (workspace.dragRegions) updateTitleBarRegions(); + }); +} + +async function closeWorkspace() { + if (workspace.closing) return; + workspace.closing = true; + try { + if (workspace.tabs.some(tab => tab.state.saving)) { setStatus("文档正在保存,请稍后关闭"); return; } + if (batch.running && !window.confirm("批量转换尚未完成,是否退出?")) return; + const dirty = workspace.tabs.some(tab => tab.state.dirty) || canvasEditor.textChanged() || canvasEditor.cropChanged() || canvasEditor.nudgeChanged(); + if (dirty && (workspace.switching || documentRequest || document.body.hasAttribute("data-busy") || formDialogOpen())) { + setStatus("操作正在进行,请完成或取消后关闭"); + return; + } + if (!dirty) { + await closeApplicationWindow(); + return; + } + const count = workspace.tabs.filter(tab => tab.state.dirty || tab === workspace.active && (canvasEditor.textChanged() || canvasEditor.cropChanged() || canvasEditor.nudgeChanged())).length; + const action = await requestCloseAction("退出程序", `有${count}个文档尚未保存`, "保存"); + if (action === "cancel") return; + if (action === "save") { + if (!await commitDocumentView()) return; + for (const tab of workspace.tabs) { + if (!tab.state.dirty) continue; + if (!await activateDocumentTab(tab, true)) return; + if (!await exportFile(true, null, "ofd", false) || tab.state.dirty) return; + } + } + await closeApplicationWindow(); + } finally { + workspace.closing = false; + } +} + +async function closeApplicationWindow() { + window.removeEventListener("beforeunload", warnUnsaved); + window.removeEventListener("beforeunload", warnBatch); + await nativeHost.call("window.close", {}); +} + +function createDocumentTab(value = createDocumentState()) { + return { id: ++workspace.nextID, state: value, resources: createResourceManager(), settings: createSettings(), + invoice: createInvoicePanel(), worker: null, promise: null, closed: false, localFonts: [], view: null, status: STATUS.ready }; +} + +function advanceDocumentSequence() { + state.openSeq = workspace.sequence = Math.max(workspace.sequence, state.openSeq) + 1; + return state.openSeq; +} + +function copyApplicationPreferences(target) { + for (const key of ["pageLayoutPreference", "renderAnnotations", "renderBackend", "renderMode", "renderDPI", "showPages", "showMeta", "exportFormats"]) target[key] = state[key]; + const { fontChoice, ...defaults } = state.textDefaults; + target.textDefaults = { ...defaults, ...(target.textDefaults.fontChoice ? {fontChoice: target.textDefaults.fontChoice} : {}) }; +} + +function renderDocumentTabs() { + documentTab(); + el.documentTabs.replaceChildren(); + for (const tab of workspace.tabs) { + const item = document.createElement("div"); + item.className = "document-tab"; + item.dataset.id = String(tab.id); + item.dataset.selected = String(tab === workspace.active); + item.setAttribute("role", "tab"); + item.setAttribute("aria-selected", String(tab === workspace.active)); + item.tabIndex = tab === workspace.active ? 0 : -1; + item.draggable = true; + const name = document.createElement("span"); + name.className = "document-tab-name"; + const title = tab.state.doc ? tab.state.fileName : "OFDGo"; + name.textContent = `${tab.state.dirty ? "● " : ""}${title}`; + item.title = `${title}${tab.state.saving ? " · 正在保存" : ""}`; + const close = document.createElement("button"); + close.type = "button"; + close.className = "document-tab-close"; + close.title = "关闭"; + close.setAttribute("aria-label", `关闭 ${title}`); + const icon = document.createElement("span"); + icon.className = "close-icon"; + close.append(icon); + close.disabled = tab.state.saving || !tab.state.doc && workspace.tabs.length === 1; + close.addEventListener("click", async event => { + event.stopPropagation(); + if (await activateDocumentTab(tab)) await closeFile(); + }); + item.append(name, close); + item.addEventListener("click", () => activateDocumentTab(tab)); + item.addEventListener("auxclick", async event => { + if (event.button !== 1) return; + event.preventDefault(); + if (await activateDocumentTab(tab)) await closeFile(); + }); + item.addEventListener("dragstart", event => { + if (event.target.closest("button")) { event.preventDefault(); return; } + event.dataTransfer.setData("application/x-ofdgo-tab", String(tab.id)); + event.dataTransfer.effectAllowed = "move"; + }); + item.addEventListener("dragover", event => { + if (Array.from(event.dataTransfer.types).includes("application/x-ofdgo-tab")) { event.preventDefault(); event.dataTransfer.dropEffect = "move"; } + }); + item.addEventListener("drop", event => { + const index = workspace.tabs.findIndex(value => String(value.id) === event.dataTransfer.getData("application/x-ofdgo-tab")); + if (index < 0) return; + event.preventDefault(); + event.stopPropagation(); + const bounds = item.getBoundingClientRect(); + const after = event.clientX > bounds.left + bounds.width / 2; + const [moved] = workspace.tabs.splice(index, 1); + if (moved === tab) workspace.tabs.splice(index, 0, moved); + else workspace.tabs.splice(workspace.tabs.indexOf(tab) + Number(after), 0, moved); + renderDocumentTabs(); + }); + item.addEventListener("keydown", async event => { + if (!["ArrowLeft", "ArrowRight", "Home", "End", "Delete"].includes(event.key) || event.target !== item) return; + event.preventDefault(); + if (event.key === "Delete") { if (await activateDocumentTab(tab)) await closeFile(); return; } + const index = workspace.tabs.indexOf(tab), count = workspace.tabs.length; + const next = event.key === "Home" ? 0 : event.key === "End" ? count - 1 : (index + (event.key === "ArrowRight" ? 1 : -1) + count) % count; + if (await activateDocumentTab(workspace.tabs[next])) el.documentTabs.querySelector('[data-selected="true"]')?.focus(); + }); + el.documentTabs.append(item); + } + el.documentTabs.querySelector('[data-selected="true"]')?.scrollIntoView({block: "nearest", inline: "nearest"}); + updateTitleBarRegions(); +} + +async function commitDocumentView() { + return await canvasEditor.commitNudge() && await canvasEditor.commitText() && await canvasEditor.commitCrop(); +} + +function detachDocumentView() { + const tab = documentTab(); + window.clearTimeout(wasmRecoveryTimer); + wasmRecoveryTimer = 0; + const selection = canvasEditor.selected; + tab.view = { anchor: state.doc ? scaleAnchor() : null, navigation: el.navigationTabs.querySelector('[data-selected="true"]'), + navigationTop: el.navigationContent.scrollTop, metaTop: el.metaPanel.scrollTop, tool: canvasEditor.tool, + selection: selection ? {index: selection.index, ids: (selection.items || [selection]).map(item => item.id)} : canvasEditor.pendingSelection, + richClipboard: canvasEditor.richClipboard, search: el.searchInput.value }; + tab.view.searchStatus = state.searchRunning ? "搜索已暂停" : el.searchStatus.textContent; + if (state.searchRunning) { + state.searchQuery = ""; + state.searchSeq++; + state.searchRunning = false; + } + tab.localFonts = fontManager.localFonts; + tab.status = documentRequest?.previousStatus || el.statusText.textContent; + advanceDocumentSequence(); + state.pageSeq++; + if (state.scrollFrame) cancelAnimationFrame(state.scrollFrame); + if (state.thumbnailFrame) cancelAnimationFrame(state.thumbnailFrame); + if (state.openActionFrame) cancelAnimationFrame(state.openActionFrame); + state.scrollFrame = state.thumbnailFrame = state.openActionFrame = 0; + state.openActionTask = null; + resetPageLoading(); + wasmWorker?.configurePages?.(null); + state.pageRenderRunning = state.pageRenderWorkers > 0; + state.pageCache.clear(); + state.svgFontLoads.clear(); + releasePageResources(); + state.pageWindow = null; + state.thumbnailWindow = null; + state.visiblePages.clear(); + state.selectedPages.clear(); + state.documentSelection = null; + cancelPageTouch?.(); + endPan(); + clearSecurityForms(); + if (resourceElements.Panel.open) rememberResourceView(); + workspace.resourceView = null; + releaseResourcePreview(); + resourceManager.sequence++; + settings.sequence++; + fontBinding.sequence++; + fontPicker.close(); + canvasEditor.cancelNudge(); + canvasEditor.clear(); + canvasEditor.setTool(""); + for (const panel of [resourceElements.Panel, settingsElements.Panel, el.closePanel, el.attachmentDialog, el.createPanel, + el.insertPanel, fontBindingElements.Panel, el.pagePanel, el.paragraphPanel, el.infoPanel, el.documentPanel, + el.versionPanel, el.annotationNote, el.annotationCreate, el.objectPicker, el.importPanel, el.batchPagesPanel, + el.objectStylePanel, el.objectBoundsPanel, el.outlinePanel, el.structureDialog]) panel.close(); +} + +function attachDocumentTab(tab) { + tab.configurePending ||= tab.state.renderAnnotations !== state.renderAnnotations; + copyApplicationPreferences(tab.state); + workspace.active = tab; + state = tab.state; + state.textFontID = undefined; + resourceManager = tab.resources; + settings = tab.settings; + invoicePanel = tab.invoice; + wasmWorker = tab.worker; + fontManager.localFonts = tab.localFonts; + advanceDocumentSequence(); + el.svgHost.replaceChildren(); + el.pageList.replaceChildren(); + el.outlineList.replaceChildren(); + el.annotationsList.replaceChildren(); + el.annotationsStatus.textContent = ""; + el.searchInput.value = tab.view?.search || ""; + el.searchStatus.textContent = tab.view?.searchStatus || ""; + el.searchResults.replaceChildren(); + renderSearchResults(); + el.ofdInput.value = ""; + canvasEditor.richClipboard = tab.view?.richClipboard || null; + canvasEditor.pendingSelection = tab.view?.selection || null; + renderDocumentTabs(); +} + +async function openEmptyDocumentTab() { + if (workspace.closing || workspace.switching || documentRequest || document.body.hasAttribute("data-busy") || formDialogOpen()) return; + const tab = createDocumentTab(); + workspace.tabs.push(tab); + if (!await activateDocumentTab(tab) && workspace.active !== tab) { + workspace.tabs.splice(workspace.tabs.indexOf(tab), 1); + await releaseDocumentTab(tab); + renderDocumentTabs(); + } +} + +async function activateDocumentTab(tab, closing = false) { + if (!tab || tab.closed) return false; + if (workspace.closing && !closing) return false; + if (tab === documentTab()) return true; + if (workspace.switching || documentRequest || document.body.hasAttribute("data-busy") || formDialogOpen()) return false; + const focused = el.documentTitleBar.contains(document.activeElement); + workspace.switching = true; + try { + if (!await commitDocumentView()) return false; + detachDocumentView(); + attachDocumentTab(tab); + await mountDocumentTab(tab); + if (focused) el.documentTabs.querySelector('[data-selected="true"]')?.focus({preventScroll: true}); + return true; + } catch (error) { + showError(error, !state.doc); + return false; + } finally { + workspace.switching = false; + } +} + +async function mountDocumentTab(tab) { + if (state.doc) { + const view = tab.view; + if (state.wasmExited && state.editorInfo) { + setEmpty("引擎中断,未保存内容无法恢复"); + updateControls(); + setStatus(tab.status); + setBusy(false); + return; + } + if (tab.configurePending && !state.wasmExited) { + setBusy(true, "正在同步文档", null); + state.doc = await callWASM("ofdgoConfigure", await fontManager.files(fontManager.records()), state.renderAnnotations); + tab.configurePending = false; + } + await openDocument({ doc: state.wasmExited ? undefined : state.doc, pageIndex: state.pageIndex, fitMode: state.fitMode || "free", scale: state.scale, + skipAutoFonts: true, keepSearch: true, keepCache: true, restoreTab: true, openSeq: state.openSeq }); + tab.configurePending = false; + if (workspace.active !== tab) return; + if (view?.anchor) restoreScaleAnchor(view.anchor); + showNavigation(view?.navigation || el.pagesTab); + el.navigationContent.scrollTop = view?.navigationTop || 0; + el.metaPanel.scrollTop = view?.metaTop || 0; + canvasEditor.setTool(view?.tool || ""); + } else { + renderPageList(); + renderOutlines(); + renderMeta(); + el.zoomLabel.textContent = "100%"; + setEmpty(STATUS.ready); + } + renderFontList(); + updateFontSummary(); + updateControls(); + tab.controlsPending = false; + setStatus(tab.status); + setBusy(false); +} + +async function adoptDocumentTab(tab, doc, options = {}) { + setBusy(false); + if (!await commitDocumentView()) return false; + setBusy(true); + const previous = documentTab(); + const empty = !previous.state.doc && !previous.state.dirty; + detachDocumentView(); + if (empty) workspace.tabs.splice(workspace.tabs.indexOf(previous), 1, tab); + else workspace.tabs.push(tab); + tab.attached = true; + tab.state.doc = doc; + attachDocumentTab(tab); + if (empty) await releaseDocumentTab(previous); + await openDocument({ ...options, doc, resetScroll: true, openSeq: state.openSeq }); + renderDocumentTabs(); + updateWorkspaceDirty(); + return true; +} + +async function releaseDocumentTab(tab) { + if (tab.closed) return; + tab.closed = true; + await markWASMExited(tab.state.wasmSeq, null, tab); + for (const image of tab.state.svgImages.values()) URL.revokeObjectURL(image.url); + for (const font of tab.state.svgFonts.values()) document.fonts.delete(font); + tab.state.svgImages.clear(); + tab.state.svgFonts.clear(); + tab.state.svgFontLoads.clear(); + tab.state.pageCache.clear(); + tab.state.pageInFlight.clear(); + tab.state.editorViews.clear(); + tab.resources.views.clear(); + tab.settings.views.clear(); + tab.settings.drafts.clear(); + tab.invoice.views.clear(); + tab.invoice.drafts.clear(); + if (nativeHost) await nativeHost.release(tab.state.ofdBytes); + tab.state.ofdBytes = null; + tab.state.doc = null; + tab.localFonts = []; + tab.view = null; +} + +function updateWorkspaceDirty() { + if (state.dirty || workspace.tabs.some(tab => tab.state.dirty)) window.addEventListener("beforeunload", warnUnsaved); + else window.removeEventListener("beforeunload", warnUnsaved); + renderDocumentTabs(); +} + function warnUnsaved(event) { event.preventDefault(); event.returnValue = ""; @@ -2346,188 +2762,61 @@ function setDirty(dirty) { return; } state.dirty = dirty; - if (dirty) { - window.addEventListener("beforeunload", warnUnsaved); - } else { - window.removeEventListener("beforeunload", warnUnsaved); + updateWorkspaceDirty(); +} + +async function requestCloseAction(title, message, save) { + el.closeTitle.textContent = title; + el.closeMessage.textContent = message; + el.closePanel.querySelector('[value="save"]').textContent = save; + el.closePanel.returnValue = "cancel"; + const choice = new Promise(resolve => el.closePanel.addEventListener("close", () => resolve(el.closePanel.returnValue), { once: true })); + el.closePanel.showModal(); + try { + return await choice; + } finally { + el.closeTitle.textContent = "关闭文档"; + el.closeMessage.textContent = "文档尚未保存"; } } -function discardChanges() { - if (state.saving) return false; - return !state.dirty || window.confirm("文档尚未保存,放弃更改?"); -} - -async function closeFile() { - if (state.saving) return; - if (!state.doc || state.exporting || batch.unlocking || document.body.hasAttribute("data-busy")) return; +async function confirmDocumentClose() { let openSeq = state.openSeq; if (state.dirty || canvasEditor.input || canvasEditor.nudge || canvasEditor.nudgeCommit || canvasEditor.crop) { - el.closePanel.querySelector('[value="save"]').textContent = state.saveHandle ? "保存" : "另存"; - el.closePanel.returnValue = "cancel"; - const choice = new Promise(resolve => el.closePanel.addEventListener("close", () => resolve(el.closePanel.returnValue), { once: true })); - el.closePanel.showModal(); - const action = await choice; - if (openSeq !== state.openSeq || action === "cancel") return; + const action = await requestCloseAction("关闭文档", "文档尚未保存", state.saveHandle ? "保存" : "另存"); + if (openSeq !== state.openSeq || action === "cancel") return false; if (action === "save") { - if (!await exportFile(true, null, "ofd", false)) return; - if (state.dirty) return; + if (!await exportFile(true, null, "ofd", false)) return false; + if (state.dirty) return false; openSeq = state.openSeq; } } - if (openSeq !== state.openSeq || batch.unlocking || state.saving || state.exporting || document.body.hasAttribute("data-busy")) return; - const source = state.ofdBytes; - resetDocumentState(); - markWASMExited(); - renderPageList(); - renderOutlines(); - renderMeta(); - renderFontList(); - updateFontSummary(); - updateControls(); - el.zoomLabel.textContent = "100%"; - setEmpty(STATUS.ready); - setStatus(STATUS.ready); - if (nativeHost) await nativeHost.release(source); + return openSeq === state.openSeq && !batch.unlocking && !state.saving && !state.exporting && !document.body.hasAttribute("data-busy"); } -function resetDocumentState() { - clearSecurityForms(); - state.openSeq++; - state.pageSeq++; - state.ofdBytes = null; - state.doc = null; - state.fileName = "ofdgo.ofd"; - state.saveHandle = null; - state.saveStamp = null; - state.editing = false; - state.editorInfo = null; - state.savedRevision = null; - state.packageDirty = false; - state.encryptionDirty = false; - state.composite = null; - state.annotationEdit = null; - state.annotationCreate = null; - state.annotationSummary = null; - state.openActionTask = null; - state.openActionEnabled = false; - state.openActionDocument = false; - state.openActionPage = null; - if (state.openActionFrame) cancelAnimationFrame(state.openActionFrame); - state.openActionFrame = 0; - state.insertObject = null; - state.importPageCount = 0; - state.importDocumentCount = 0; - state.importDocumentMode = false; - state.importEncrypted = false; - state.createInsert = false; - state.createPackage = false; - state.createDocumentAt = null; - state.objectClipboard = null; - state.styleClipboard = null; - state.styleOriginal = null; - state.conversionWarnings = []; - state.editorViews.clear(); - state.pageSelection.clear(); - state.pageSelectionAnchor = null; - state.pageMultiSelect = false; - cancelPageTouch?.(); - state.pageTouchPointer = null; - state.navigationScroll.clear(); - state.outlineExpanded.clear(); - state.outlineSelection = null; - state.outlineAction = "add"; - state.outlineSeq++; - state.documentManagement = null; - state.versionManagement = null; - state.infoDocumentIndex = null; - state.pageIndex = 0; - state.scale = 1; - state.rotation = 0; - state.fontRenderPending = false; - state.textFonts = []; - state.textFontID = null; - delete state.textDefaults.fontChoice; - state.exportPages = null; - canvasEditor.richClipboard = null; - canvasEditor.cancelNudge(); - canvasEditor.setTool(""); - canvasEditor.clear(); - setDirty(false); - resetSearch(); - resetPageFlow(); - releasePageResources(); - if (state.scrollFrame) cancelAnimationFrame(state.scrollFrame); - if (state.thumbnailFrame) cancelAnimationFrame(state.thumbnailFrame); - state.scrollFrame = state.thumbnailFrame = 0; - state.pageWindow = null; - state.pageDragIndex = null; - el.svgHost.replaceChildren(); - el.viewerPanel.classList.remove("single-page-fits-height"); - el.annotationsList.replaceChildren(); - el.annotationsStatus.textContent = ""; - el.ofdInput.value = ""; - releaseResourcePreview(); - resourceManager.sequence++; - resourceManager.kind = "Font"; - resourceManager.busy = false; - resourceManager.items = []; - resourceManager.filteredItems = []; - resourceManager.start = resourceManager.end = -1; - resourceManager.selected = null; - resourceManager.views.clear(); - resourceManager.scope = ""; - resourceManager.references = []; - resourceManager.referenceOffset = 0; - resourceManager.targets = []; - resourceManager.fileAction = null; - settings.sequence++; - settings.kind = "bookmarks"; - settings.locked = false; - settings.busy = false; - settings.page = 0; - settings.scope = "document"; - settings.object = ""; - settings.path = []; - settings.selected = -1; - settings.resource = null; - settings.view = ""; - settings.suspended = false; - settings.invoice = false; - settings.data = null; - settings.drafts.clear(); - settings.views.clear(); - settings.actions = []; - settings.baseline = {}; - settingsElements.Content.replaceChildren(); - settingsElements.Actions.replaceChildren(); - settingsElements.Context.replaceChildren(); - settingsElements.Form.inert = false; - settingsElements.Status.textContent = ""; - resourceElements.List.replaceChildren(); - resourceElements.List.style.height = ""; - resourceElements.List.closest(".resource-list-container").scrollTop = 0; - resourceElements.List.inert = false; - resourceElements.Panel.removeAttribute("data-busy"); - resourceElements.Reference.replaceChildren(); - resourceElements.Page.replaceChildren(); - resourceElements.Object.replaceChildren(); - resourceElements.File.value = ""; - resourceElements.Status.textContent = ""; - invoicePanel.tab = "basic"; - invoicePanel.entries = []; - invoicePanel.drafts.clear(); - invoicePanel.views.clear(); - invoicePanel.key = ""; - invoicePanel.result = null; - fontBinding.sequence++; - fontBinding.font = null; - fontBinding.choices = []; - fontManager.localFonts = []; - for (const panel of [resourceElements.Panel, settingsElements.Panel, el.closePanel, el.attachmentDialog, el.createPanel, el.insertPanel, - fontBindingElements.Panel, el.pagePanel, el.paragraphPanel, el.infoPanel, el.documentPanel, - el.versionPanel, el.annotationNote, el.annotationCreate, el.objectPicker, el.importPanel, - el.batchPagesPanel, el.objectStylePanel, el.objectBoundsPanel, el.outlinePanel, el.structureDialog]) panel.close(); +async function closeFile() { + if (workspace.closing || workspace.switching || documentRequest || state.saving) return; + if (!state.doc && workspace.tabs.length === 1 || state.exporting || batch.unlocking || document.body.hasAttribute("data-busy")) return; + const tab = documentTab(); + if (!await confirmDocumentClose() || tab !== workspace.active || workspace.closing || workspace.switching) return; + workspace.switching = true; + const focused = el.documentTitleBar.contains(document.activeElement); + setBusy(true); + try { + const index = workspace.tabs.indexOf(tab); + detachDocumentView(); + workspace.tabs.splice(index, 1); + if (!workspace.tabs.length) workspace.tabs.push(createDocumentTab()); + attachDocumentTab(workspace.tabs[Math.min(index, workspace.tabs.length - 1)]); + await releaseDocumentTab(tab); + await mountDocumentTab(documentTab()); + updateWorkspaceDirty(); + if (focused) el.documentTabs.querySelector('[data-selected="true"]')?.focus({preventScroll: true}); + return true; + } finally { + workspace.switching = false; + setBusy(false); + } } function setEditorInfo(doc) { @@ -2637,7 +2926,7 @@ async function switchReadingVersion(id) { try { const doc = await callWASM("ofdgoSelectVersion", id); if (openSeq !== state.openSeq) return; - openSeq = ++state.openSeq; + openSeq = advanceDocumentSequence(); state.objectClipboard = state.styleClipboard = null; canvasEditor.richClipboard = null; resetCompositeScope(); @@ -2816,7 +3105,7 @@ async function switchDocument(index) { try { const doc = await callWASM("ofdgoSelectDocument", index); if (openSeq !== state.openSeq) return; - openSeq = ++state.openSeq; + openSeq = advanceDocumentSequence(); const source = state.ofdBytes; state.packageDirty ||= state.dirty; state.savedRevision = doc.revision; @@ -2902,7 +3191,7 @@ async function toggleEditor() { try { const doc = await callWASM("ofdgoEditDocument", pageIndex); if (openSeq !== state.openSeq) return; - openSeq = ++state.openSeq; + openSeq = advanceDocumentSequence(); const source = state.ofdBytes; state.savedRevision = doc.revision; state.editorViews.clear(); @@ -2938,7 +3227,7 @@ function confirmTextReflow(item) { } function openCreatePanel(insert = false, packageDocument = false) { - if (state.saving && insert !== true) return; + if (workspace.closing || state.saving && packageDocument) return; if (document.body.hasAttribute("data-busy")) { return; } @@ -2996,49 +3285,63 @@ async function createDocument(event) { if (await changeDocument("ofdgoImportImages", null, ...imagePageRequest(), at, "", state.renderAnnotations)) el.createPanel.close(); return; } - if (document.body.hasAttribute("data-busy") || !discardChanges()) { + if (document.body.hasAttribute("data-busy") || documentRequest || workspace.switching || workspace.closing) { return; } const title = el.createName.value.trim() || "未命名"; - let openSeq = state.openSeq; + const openSeq = state.openSeq; const images = el.createMode.value === "images"; + const previousState = state; + const tab = createDocumentTab(); + copyApplicationPreferences(tab.state); + tab.state.openSeq = openSeq; + tab.state.imageImporting = images; + workspace.pending = tab; + const request = {committing: true, previousStatus: el.statusText.textContent}; + documentRequest = request; + let accepted = false; state.imageImporting = images; setBusy(true, "正在新建文档", null, "正在新建文档"); try { - await ensureWASM(); + await ensureTabEngine(tab); if (openSeq !== state.openSeq) { return; } const doc = images - ? await callWASM("ofdgoImportImages", ...imagePageRequest(), -1, title, state.renderAnnotations) - : await callWASM("ofdgoCreateDocument", title, Number(el.createWidth.value), Number(el.createHeight.value), state.renderAnnotations); + ? await callTabWASM(tab, "ofdgoImportImages", ...imagePageRequest(), -1, title, state.renderAnnotations) + : await callTabWASM(tab, "ofdgoCreateDocument", title, Number(el.createWidth.value), Number(el.createHeight.value), state.renderAnnotations); if (openSeq !== state.openSeq) { return; } - const source = state.ofdBytes; - resetDocumentState(); - openSeq = state.openSeq; - state.editing = true; - updateTextFonts(null, true); - state.selectObjects = true; - setPan(false); - state.fileName = `${title.replace(/[\\/:*?"<>|]+/g, "_").replace(/\.ofd$/i, "")}.ofd`; - setEditorInfo(doc); - el.createPanel.close(); - await openDocument({ doc, openSeq, skipAutoFonts: true, resetScroll: true }); - if (nativeHost) await nativeHost.release(source); + tab.state.editing = true; + tab.state.fileName = `${title.replace(/[\\/:*?"<>|]+/g, "_").replace(/\.ofd$/i, "")}.ofd`; + tab.state.editorInfo = {revision: doc.revision, canUndo: doc.canUndo, canRedo: doc.canRedo, + pageCapabilities: doc.pageCapabilities, editWarnings: doc.editWarnings || []}; + tab.state.dirty = true; + accepted = await adoptDocumentTab(tab, doc, {skipAutoFonts: true}); + if (accepted) { updateTextFonts(null, true); updateControls(); } } catch (err) { if (openSeq === state.openSeq) { el.createStatus.textContent = err.message; } } finally { - if (images) { - state.imageImporting = false; - state.exportRequestID = 0; - el.cancelExportButton.hidden = true; - el.createCancel.disabled = false; + const current = documentRequest === request; + if (current) { + documentRequest = null; + workspace.pending = null; } - if (openSeq === state.openSeq) { + if (!accepted && !tab.attached) await releaseDocumentTab(tab); + if (images) { + previousState.imageImporting = false; + tab.state.imageImporting = false; + if (accepted || current && openSeq === state.openSeq) { + state.imageImporting = false; + state.exportRequestID = 0; + el.cancelExportButton.hidden = true; + el.createCancel.disabled = false; + } + } + if (accepted || current && openSeq === state.openSeq) { setBusy(false); updateControls(); } @@ -3516,28 +3819,29 @@ function editorFonts(item) { } async function loadEditorFonts() { + const tab = documentTab(), target = tab.state; const tasks = []; if (!state.fontCatalogLoading && fontManager.canReadLocal() && !fontManager.catalogLoaded && fontManager.permission !== "denied") { - state.fontCatalogLoading = true; + target.fontCatalogLoading = true; tasks.push((async () => { try { await fontManager.queryLocal(); - refreshEditorFonts(); + if (isForegroundDocument(tab)) refreshEditorFonts(); } catch (err) { - setStatus(err?.name === "NotAllowedError" ? "系统字体未授权" : String(err.message || err)); + if (isForegroundDocument(tab)) setStatus(err?.name === "NotAllowedError" ? "系统字体未授权" : String(err.message || err)); } finally { - state.fontCatalogLoading = false; + target.fontCatalogLoading = false; } })()); } if (!state.fontFacesLoading && fontManager.userFonts.some(font => font.enabled && !font.faces)) { - state.fontFacesLoading = true; + target.fontFacesLoading = true; tasks.push((async () => { try { - await fontManager.loadFaces(data => callWASM("ofdgoFontFaces", data)); - refreshEditorFonts(); + await fontManager.loadFaces(data => callTabWASM(tab, "ofdgoFontFaces", data)); + if (isForegroundDocument(tab)) refreshEditorFonts(); } finally { - state.fontFacesLoading = false; + target.fontFacesLoading = false; } })()); } @@ -3677,7 +3981,7 @@ async function insertObject(event) { canvasEditor.pendingSelection = item ? null : { index, ids: doc.selectedIDs }; state.selectObjects = true; setPan(false); - openSeq = ++state.openSeq; + openSeq = advanceDocumentSequence(); await refreshEditorPage(doc, index, openSeq); if (openSeq === state.openSeq) { rememberEditorView(before, revision, !item && !!state.composite); @@ -3969,7 +4273,7 @@ async function changeDocument(name, item, ...args) { } state.ofdBytes = null; setEditorInfo(doc); - openSeq = ++state.openSeq; + openSeq = advanceDocumentSequence(); await openDocument({ doc, openSeq, skipAutoFonts: true, resetScroll: documentChanged, clearSelection: true, pageIndex: documentChanged ? 0 : state.pageIndex, fitMode: state.fitMode, scale: state.scale }); if (openSeq === state.openSeq) { @@ -4024,7 +4328,7 @@ async function changeDocument(name, item, ...args) { } if (scoped && name === "ofdgoOrderObjects") canvasEditor.pendingSelection = { index: item.index, ids: doc.selectedIDs }; if (textSelection) canvasEditor.pendingSelection = { index: item.index, ids: doc.selectedIDs || [] }; - openSeq = ++state.openSeq; + openSeq = advanceDocumentSequence(); if (changingFont) { await refreshFontBinding(doc, openSeq); } else if (item || name === "ofdgoPasteObjects" || inserting || editingAnnotation) { @@ -4221,16 +4525,14 @@ function releasePageResources() { } async function openOFDFile() { - if (state.saving) return; if (document.body.hasAttribute("data-busy")) { return; } if (!nativeHost && window.showOpenFilePicker) { try { - const [handle] = await window.showOpenFilePicker({types: [{description: "OFD、PDF", accept: {"application/ofd": [".ofd"], "application/pdf": [".pdf"]}}]}); - const file = await handle.getFile(); + const handles = await window.showOpenFilePicker({multiple: true, types: [{description: "OFD、PDF", accept: {"application/ofd": [".ofd"], "application/pdf": [".pdf"]}}]}); await requestLocalFontsBeforeOpen(); - await openOFD(file, null, handle); + for (const handle of handles) await openOFD(await handle.getFile(), null, handle); } catch (error) { if (error.name !== "AbortError") showError(error, !state.doc); } return; } @@ -4254,16 +4556,12 @@ async function openOFDFromDrop(event) { if (document.body.hasAttribute("data-busy")) { return; } - if (event.dataTransfer.files.length > 1) { - setStatus("一次拖入一个文件"); - return; - } - const handlePromise = event.dataTransfer.items[0]?.getAsFileSystemHandle?.(); - const file = event.dataTransfer.files[0]; - if (file && isDocumentFile(file)) { + const files = Array.from(event.dataTransfer.files); + const handles = Array.from(event.dataTransfer.items).map(item => item.getAsFileSystemHandle?.().catch(() => null)); + if (files.some(isDocumentFile)) { await requestLocalFontsBeforeOpen(); } - await openOFD(file, null, await handlePromise?.catch(() => null)); + for (let index = 0; index < files.length; index++) await openOFD(files[index], null, await handles[index]); } function openFontFile(input) { @@ -4369,11 +4667,17 @@ async function boot() { return; } opening = opening.then(async () => { - await openOFD(await files[0].getFile(), null, files[0]); + for (const file of files) await openOFD(await file.getFile(), null, file); }).catch((err) => showError(err, !state.doc)); }); } - if (nativeHost) await nativeHost.start(openNativeFiles, error => showError(error, !state.doc)); + if (nativeHost) { + nativeHost.bridge.on("ofdgo.window", applyTitleBarInfo); + nativeHost.bridge.on("ofdgo.closing", closeWorkspace); + applyTitleBarInfo(await nativeHost.call("window.info", {})); + new ResizeObserver(updateTitleBarRegions).observe(el.documentTitleBar); + await nativeHost.start(openNativeFiles, error => showError(error, !state.doc)); + } try { const remote = remoteDocumentOptions(window.location.hash); if (remote && openSeq === state.openSeq) await openOFD({ name: remote.name }, remote); @@ -4397,9 +4701,9 @@ async function openNativeFiles(files) { if (batchElements.Panel.open) { retained = files.filter(isDocumentFile); addBatchFiles(files); - } else if (files.length === 1) { - if (await openOFD(files[0])) retained = files; - } else if (files.length) setStatus("请选择一个文档"); + } else { + for (const file of files) if (await openOFD(file)) retained.push(file); + } } finally { await nativeHost.discardFiles(files.filter(file => !retained.includes(file))); } @@ -4563,19 +4867,24 @@ async function refreshApplication() { if (nativeHost || state.exporting || document.body.hasAttribute("data-busy")) { return; } + if (workspace.tabs.some(tab => tab.state.saving)) { + setStatus("文档正在保存,请稍后更新"); + return; + } if (!navigator.onLine) { setStatus("离线,联网后重试"); return; } const temporaryFonts = fontManager.userFonts.some((font) => font.source === "upload"); - const message = [state.dirty ? "文档尚未保存,刷新将丢失更改" : "刷新后需重新打开文件", temporaryFonts ? "未保存字体需重新添加" : ""].filter(Boolean).join("\n"); - if ((state.ofdBytes || state.doc || temporaryFonts) && !window.confirm(message)) { + const dirty = state.dirty || workspace.tabs.some(tab => tab.state.dirty); + const message = [dirty ? "文档尚未保存,刷新将丢失更改" : "刷新后需重新打开文件", temporaryFonts ? "未保存字体需重新添加" : ""].filter(Boolean).join("\n"); + if ((workspace.tabs.some(tab => tab.state.doc || tab.state.ofdBytes) || temporaryFonts) && !window.confirm(message)) { return; } setBusy(true, "正在更新应用", null, "正在更新应用"); el.refreshAppButton.disabled = true; el.refreshAppButton.textContent = "更新"; - const panels = document.querySelectorAll(".toolbar-frame, .workspace"); + const panels = document.querySelectorAll(".document-titlebar, .toolbar-frame, .workspace"); panels.forEach((panel) => { panel.inert = true; }); try { if (navigator.storage?.persist) { @@ -4634,52 +4943,61 @@ async function loadExportFormats() { } async function ensureWASM() { - if (state.ready && !state.wasmExited) { - return; - } - if (!wasmPromise) { - wasmPromise = loadWASM().finally(() => { - wasmPromise = null; - }); - } - await wasmPromise; + return ensureTabEngine(documentTab()); } -async function loadWASM() { +async function ensureTabEngine(tab) { + if (tab.closed) throw new Error("文档已关闭"); + if (tab.state.ready && !tab.state.wasmExited) { + return; + } + if (!tab.promise) { + tab.promise = loadWASM(tab).finally(() => { + tab.promise = null; + }); + } + await tab.promise; +} + +async function loadWASM(tab = documentTab()) { + const state = tab.state; const wasmSeq = state.wasmSeq + 1; state.wasmSeq = wasmSeq; state.ready = false; state.wasmExited = false; await new Promise((resolve, reject) => { + tab.rejectLoad = reject; const worker = createEngineWorker({pages: true}); - wasmWorker = worker; + tab.worker = worker; + if (workspace.active === tab) wasmWorker = worker; const fail = (err) => { - markWASMExited(wasmSeq, err); + markWASMExited(wasmSeq, err, tab); reject(err); }; worker.onerror = (event) => fail(new Error(event.message || "引擎加载失败")); worker.onmessageerror = () => fail(new Error("引擎通信失败")); worker.onmessage = async ({ data }) => { - if (worker !== wasmWorker) { + if (worker !== tab.worker || tab.closed) { return; } + const visible = isForegroundDocument(tab); if (data.type === "font") { try { const request = wasmRequests.get(data.id); const font = request?.localFonts?.get(data.name); - if (!font || request.openSeq !== state.openSeq) throw new Error("系统字体不可用"); + if (!font || request.tab !== tab) throw new Error("系统字体不可用"); if (!request.fontData.has(font)) request.fontData.set(font, fontManager.read(font)); const bytes = await request.fontData.get(font); - if (worker === wasmWorker) worker.postMessage({ type: "font", request: data.request, bytes }); + if (worker === tab.worker) worker.postMessage({ type: "font", request: data.request, bytes }); } catch (err) { - if (worker === wasmWorker) worker.postMessage({ type: "font", request: data.request, error: err.message }); + if (worker === tab.worker) worker.postMessage({ type: "font", request: data.request, error: err.message }); } return; } if (data.type === "progress") { - setProgress(data.text, data.percent); + if (visible) setProgress(data.text, data.percent); } else if (data.type === "conversion") { - if (wasmRequests.get(data.id)?.openSeq === state.openSeq && !el.cancelExportButton.disabled) { + if (visible && wasmRequests.get(data.id)?.openSeq === state.openSeq && !el.cancelExportButton.disabled) { if (data.phase === "open") setProgress("正在读取文档", null); else if (data.phase === "pages") setProgress(data.completed ? `正在整理页面 · ${data.completed} 页` : "正在整理页面", null); else if (data.phase === "convert") setProgress(`正在转换页面 · ${data.completed} / ${data.total} 页`, data.total ? 28 + data.completed / data.total * 22 : null); @@ -4688,10 +5006,10 @@ async function loadWASM() { } } else if (data.type === "export") { if (data.id === state.saveRequestID && state.saving && !state.exporting) { - if (data.stage === "save") el.cancelExportButton.disabled = true; + if (visible && data.stage === "save") el.cancelExportButton.disabled = true; return; } - if (wasmRequests.get(data.id)?.openSeq === state.openSeq && state.exporting) { + if (visible && wasmRequests.get(data.id)?.openSeq === state.openSeq && state.exporting) { if (data.stage === "save") { el.cancelExportButton.disabled = true; if (state.signing) el.signCancel.disabled = true; @@ -4708,14 +5026,14 @@ async function loadWASM() { } } } else if (data.type === "import") { - if (wasmRequests.get(data.id)?.openSeq === state.openSeq && state.imageImporting) { + if (visible && wasmRequests.get(data.id)?.openSeq === state.openSeq && state.imageImporting) { const text = data.phase === "commit" ? "正在完成导入" : `正在导入图片 · ${data.completed} / ${data.total} 张`; el.createStatus.textContent = text; setProgress(text, data.total ? data.completed / data.total * 100 : null); if (data.phase === "commit") el.createCancel.disabled = el.cancelExportButton.disabled = true; return; } - if (wasmRequests.get(data.id)?.openSeq === state.openSeq && state.importing && !el.importCancel.disabled) { + if (visible && wasmRequests.get(data.id)?.openSeq === state.openSeq && state.importing && !el.importCancel.disabled) { const label = { ids: "正在检查标识", documents: "正在导入文档", pages: "正在导入页面", resources: "正在读取资源", commit: "正在完成导入" }[data.phase] || "正在导入文档"; el.importStatus.textContent = data.total ? `${label} · ${data.completed} / ${data.total}${state.importDocumentMode ? "" : " 页"}` : label; setProgress(label, data.total ? data.completed / data.total * 100 : null); @@ -4723,11 +5041,13 @@ async function loadWASM() { } } else if (data.type === "ready") { state.ready = true; + tab.rejectLoad = null; resolve(); } else if (data.type === "exit") { fail(new Error(data.error)); } else { const request = wasmRequests.get(data.id); + if (!request || request.tab !== tab) return; wasmRequests.delete(data.id); if (data.ok) { request.resolve(data.data); @@ -4746,41 +5066,52 @@ async function loadWASM() { }); } -function markWASMExited(wasmSeq = state.wasmSeq, err) { +function markWASMExited(wasmSeq = state.wasmSeq, err, tab = documentTab()) { + const state = tab.state; if (wasmSeq !== state.wasmSeq) { return; } state.ready = false; - clearSecurityForms(); + tab.rejectLoad?.(err || new Error("引擎运行中断")); + tab.rejectLoad = null; + if (isForegroundDocument(tab)) clearSecurityForms(); state.wasmExited = true; - wasmWorker?.terminate(); - wasmWorker = null; - for (const request of wasmRequests.values()) { + if (err) tab.status = state.editorInfo ? "引擎中断,未保存内容无法恢复" : "引擎运行中断"; + const stopped = tab.worker?.terminate(); + tab.worker = null; + if (workspace.active === tab) wasmWorker = null; + for (const [id, request] of wasmRequests) { + if (request.tab !== tab) continue; + wasmRequests.delete(id); request.reject(err || new Error("引擎运行中断")); } - wasmRequests.clear(); + if (!isForegroundDocument(tab) || workspace.active !== tab) return stopped; if (err) { - setStatus(state.editorInfo ? "引擎中断,未保存内容无法恢复" : "引擎运行中断"); + setStatus(tab.status); } updateControls(); scheduleWASMRecovery(); + return stopped; } function scheduleWASMRecovery() { - if (!state.ofdBytes || state.wasmRecovering || state.wasmRecoveries >= 2) { + if (!state.ofdBytes || state.editorInfo || state.wasmRecovering || state.wasmRecoveries >= 2) { return; } + const tab = documentTab(); window.clearTimeout(wasmRecoveryTimer); - wasmRecoveryTimer = window.setTimeout(recoverWASM, 0); + wasmRecoveryTimer = window.setTimeout(() => { + if (workspace.active === tab && !workspace.pending && !workspace.switching) recoverWASM(); + }, 0); } async function recoverWASM() { - if (state.wasmRecovering || !state.ofdBytes) { + if (!state.wasmExited || state.editorInfo || state.wasmRecovering || !state.ofdBytes) { return; } state.wasmRecovering = true; state.wasmRecoveries += 1; - const openSeq = ++state.openSeq; + const openSeq = advanceDocumentSequence(); setBusy(true, "正在恢复引擎", 18, STATUS.recovering); try { await openDocument({ @@ -4865,7 +5196,7 @@ async function downloadRemoteDocument(options) { } async function openOFD(file, remote = null, handle = file?.handle) { - if (state.saving || state.exporting || documentRequest?.committing || !documentRequest && document.body.hasAttribute("data-busy")) return; + if (workspace.closing || state.exporting || workspace.switching || documentRequest?.committing || !documentRequest && document.body.hasAttribute("data-busy")) return; if (!file) { return; } @@ -4874,26 +5205,29 @@ async function openOFD(file, remote = null, handle = file?.handle) { showError(new Error("选择 OFD、PDF 文件"), !state.doc); return; } - if (!discardChanges()) { - return; - } remoteDownload?.abort(); - state.wasmRecoveries = 0; - let openSeq = state.openSeq; - const request = {committing: false}; + const openSeq = state.openSeq; + const tab = createDocumentTab(); + copyApplicationPreferences(tab.state); + tab.state.openSeq = openSeq; + const request = {committing: false, previousStatus: documentRequest?.previousStatus || el.statusText.textContent}; documentRequest = request; + workspace.pending = tab; const current = () => documentRequest === request && openSeq === state.openSeq; setBusy(true, "正在读取文档", 10, STATUS.opening); let accepted = false; + let originalSource = null; try { const downloaded = remote ? await downloadRemoteDocument(remote) : null; if (!current()) return; - await ensureWASM(); + await ensureTabEngine(tab); if (!current()) return; const source = downloaded ? downloaded.file : file; - const format = await callWASM("ofdgoDetectFormat", source); + const format = await callTabWASM(tab, "ofdgoDetectFormat", source); if (!current()) return; let bytes = await documentBytes(source); + originalSource = bytes; + tab.state.ofdBytes = bytes; const name = (downloaded ? downloaded.name : file.name) || `ofdgo.${format}`; let warnings = []; let convertedDoc; @@ -4903,39 +5237,33 @@ async function openOFD(file, remote = null, handle = file?.handle) { request.committing = true; if (format === "pdf") { setProgress("正在转换文档", 25); - await ensureWASM(); + await ensureTabEngine(tab); if (!current()) return; let converted; try { - converted = await openWithCredentials(await fontManager.files(fontManager.records()), openSeq, bytes, false, true); + converted = await openWithCredentials(await fontManager.files(fontManager.records()), openSeq, bytes, false, true, tab); } finally { if (current()) { - state.exportRequestID = 0; + tab.state.exportRequestID = 0; el.cancelExportButton.hidden = true; } } if (!current()) return; bytes = converted.bytes; + tab.state.ofdBytes = bytes; convertedDoc = converted.doc; warnings = JSON.parse(converted.warnings || "null") || []; } - await ensureWASM(); + await ensureTabEngine(tab); if (!current()) return; - const doc = convertedDoc || await openWithCredentials(await fontManager.files(fontManager.userFonts), openSeq, bytes); + const doc = convertedDoc || await openWithCredentials(await fontManager.files(fontManager.userFonts), openSeq, bytes, false, false, tab); if (!current()) return; - const previousSource = state.ofdBytes; - resetDocumentState(); - openSeq = state.openSeq; - state.ofdBytes = bytes; - state.saveHandle = format === "ofd" && !remote ? handle || null : null; - accepted = true; - state.saveStamp = state.saveHandle && !nativeHost ? {size: file.size, modified: file.lastModified} : null; - state.conversionWarnings = warnings; - state.fileName = (name || "ofdgo.ofd").replace(/\.pdf$/i, ".ofd"); - if (remote && !/\.ofd$/i.test(state.fileName)) state.fileName += ".ofd"; - updateControls(); - await openDocument({ doc, pageIndex: remote?.pageIndex || 0, resetScroll: true, openSeq, activateActions: true }); - if (nativeHost && previousSource !== bytes) await nativeHost.release(previousSource); + tab.state.saveHandle = format === "ofd" && !remote ? handle || null : null; + tab.state.saveStamp = tab.state.saveHandle && !nativeHost ? {size: file.size, modified: file.lastModified} : null; + tab.state.conversionWarnings = warnings; + tab.state.fileName = (name || "ofdgo.ofd").replace(/\.pdf$/i, ".ofd"); + if (remote && !/\.ofd$/i.test(tab.state.fileName)) tab.state.fileName += ".ofd"; + accepted = await adoptDocumentTab(tab, doc, {pageIndex: remote?.pageIndex || 0, activateActions: true}); } catch (err) { if (current()) { if (err.name === "AbortError") setStatus("打开已取消"); @@ -4943,7 +5271,20 @@ async function openOFD(file, remote = null, handle = file?.handle) { setBusy(false); } } finally { - if (documentRequest === request) documentRequest = null; + if (documentRequest === request) { + documentRequest = null; + workspace.pending = null; + setBusy(false); + if (documentTab().controlsPending) { + updateControls(); + documentTab().controlsPending = false; + } + if (state.wasmExited) scheduleWASMRecovery(); + } + accepted ||= Boolean(tab.attached); + const source = tab.state.ofdBytes; + if (!accepted) await releaseDocumentTab(tab); + if (nativeHost && originalSource !== source) await nativeHost.release(originalSource); } return accepted; } @@ -5136,6 +5477,7 @@ function updateFontSummary() { } async function applyFontChange() { + for (const tab of workspace.tabs) if (tab !== documentTab()) tab.configurePending = true; updateFontSummary(); renderFontList(); if (!state.doc) { @@ -5407,7 +5749,8 @@ function requestCredentials(code, openSeq, pdf = false) { }); } -async function openWithCredentials(fonts, openSeq, data = state.ofdBytes, importing = false, pdf = false) { +async function openWithCredentials(fonts, openSeq, data = state.ofdBytes, importing = false, pdf = false, tab = documentTab()) { + const callWASM = (name, ...args) => callTabWASM(tab, name, ...args); let credentials = null; try { while (openSeq === state.openSeq) { @@ -5456,7 +5799,7 @@ async function openDocument(options = {}) { if (!state.ofdBytes && !state.editorInfo) { return; } - const openSeq = options.openSeq || (state.openSeq += 1); + const openSeq = options.openSeq || advanceDocumentSequence(); const previewPosition = options.previewScroll && state.pageWindow ? pageWindowAnchor(state.pageWindow) : null; const viewport = options.keepPreview ? el.viewerPanel.getBoundingClientRect() : null; const previewIDs = viewport ? [...state.visiblePages].filter((index) => { @@ -5558,10 +5901,10 @@ async function openDocument(options = {}) { } if (options.clearSelection) canvasEditor.clear(); if (options.selection) canvasEditor.pendingSelection = options.selection; - resetPageFlow(options.keepPreview); + resetPageFlow(options.keepPreview || options.keepCache); renderPageList(); if (options.keepPreview || options.resetScroll || el.outlineList.childElementCount === 0) { - renderOutlines(!options.keepPreview); + renderOutlines(!options.keepPreview && !options.restoreTab); } renderMeta(options.keepPreview); renderPageFlow(); @@ -7500,7 +7843,7 @@ function renderResourceTabs() { } function rememberResourceView() { - if (!resourceManager.scope) return; + if (!resourceManager.scope || workspace.resourceView !== resourceManager) return; resourceManager.views.set(resourceManager.kind, {selected: resourceManager.selected?.id, scroll: resourceElements.List.closest(".resource-list-container").scrollTop, reference: Number(resourceElements.Reference.value), page: resourceElements.Page.value, object: resourceElements.Object.value}); @@ -7508,26 +7851,30 @@ function rememberResourceView() { async function resourceTask(action, delay = 0) { if (resourceManager.busy) return; - resourceManager.busy = true; + const manager = resourceManager; + const current = () => manager === resourceManager; + manager.busy = true; resourceElements.List.inert = !delay; updateResourceControls(); const showBusy = () => { - resourceElements.Status.textContent = "处理中"; + if (current()) resourceElements.Status.textContent = "处理中"; }; const timer = delay ? setTimeout(showBusy, delay) : null; if (!delay) showBusy(); resourceElements.Panel.setAttribute("data-busy", "true"); try { await action(); - if (resourceElements.Status.textContent === "处理中") resourceElements.Status.textContent = ""; + if (current() && resourceElements.Status.textContent === "处理中") resourceElements.Status.textContent = ""; } catch (err) { - resourceElements.Status.textContent = resourceError(err.message); + if (current()) resourceElements.Status.textContent = resourceError(err.message); } finally { if (timer !== null) clearTimeout(timer); - resourceManager.busy = false; - resourceElements.List.inert = false; - resourceElements.Panel.removeAttribute("data-busy"); - updateResourceControls(); + manager.busy = false; + if (current()) { + resourceElements.List.inert = false; + resourceElements.Panel.removeAttribute("data-busy"); + updateResourceControls(); + } } } @@ -7542,7 +7889,8 @@ function resourceError(message) { async function loadManagedResources() { const sequence = state.openSeq; - const scope = JSON.stringify([sequence, state.doc.documentIndex || 0, state.doc.documentVersion || ""]); + const manager = resourceManager; + const scope = JSON.stringify([documentTab().id, state.doc.documentIndex || 0, state.doc.documentVersion || "", state.editing]); if (resourceManager.scope === scope) rememberResourceView(); else { resourceManager.views.clear(); @@ -7551,8 +7899,9 @@ async function loadManagedResources() { } let items; try { items = await callWASM("ofdgoResources"); } - finally { resourceManager.requestID = 0; } + finally { manager.requestID = 0; } if (sequence !== state.openSeq || !resourceElements.Panel.open) return; + workspace.resourceView = manager; resourceManager.items = items || []; await renderResourceList(); } @@ -8575,7 +8924,7 @@ function batchCall(slot, name, args, onProgress) { function startBatchWorker() { return new Promise((resolve, reject) => { - const worker = createEngineWorker(); + const worker = createEngineWorker({batch: true}); const slot = { worker, error: null }; batch.workers.add(slot); const fail = async error => { @@ -8821,6 +9170,11 @@ function compressionOptions(value) { } async function exportFile(whole, indices = null, value = el.exportFormat.value, saveAs = true) { + const tab = documentTab(); + const state = tab.state; + const callWASM = (name, ...args) => callTabWASM(tab, name, ...args); + const active = () => isForegroundDocument(tab); + if (!active()) return; if (!state.doc || state.saving || state.exporting || document.body.hasAttribute("data-busy")) { return; } @@ -8849,6 +9203,8 @@ async function exportFile(whole, indices = null, value = el.exportFormat.value, let retrySaveAs = false; let background = false; let savedRevision = null; + let packageDirty = false; + let encryptionDirty = false; let snapshotPrepared = false; state.exporting = true; state.saving = saving && indices === null; @@ -8874,14 +9230,17 @@ async function exportFile(whole, indices = null, value = el.exportFormat.value, suggestedName: fileName, types: [{ description: label, accept: { [mime]: [`.${extension}`] } }], }) : null; - if (openSeq !== state.openSeq) { + if (!active() || openSeq !== state.openSeq) { return; } if (canvasEditor.input || canvasEditor.crop || canvasEditor.nudge || canvasEditor.nudgeCommit) { setBusy(false); if (!await canvasEditor.commitNudge()) return; + if (!active()) return; if (!await canvasEditor.commitText()) return; + if (!active()) return; if (!await canvasEditor.commitCrop()) return; + if (!active()) return; openSeq = state.openSeq; setBusy(true, "正在生成文件", null, status); } @@ -8889,19 +9248,24 @@ async function exportFile(whole, indices = null, value = el.exportFormat.value, const destination = !nativeHost && file && file === state.saveHandle && state.saveStamp ? {fileHandle: file, saveStamp: state.saveStamp} : file; if (saving && indices === null && !readingSave) { snapshotPrepared = true; - await callWASM("ofdgoPrepareSave"); savedRevision = state.editorInfo?.revision; + packageDirty = state.packageDirty; + encryptionDirty = state.encryptionDirty; + await callWASM("ofdgoPrepareSave"); + if (!active() || openSeq !== state.openSeq || wasmSeq !== state.wasmSeq) return; background = true; - state.exporting = false; - setBusy(false); - updateControls(); + if (background) { + state.exporting = false; + setBusy(false); + updateControls(); + } } const result = readingSave ? { label: "OFD", size: state.ofdBytes.size ?? state.ofdBytes.byteLength, mime, blob: state.ofdBytes } - : saving ? await callWASM(background ? "ofdgoSaveSnapshot" : "ofdgoSaveDocument", indices, ...outputOptions, destination) : whole + : saving ? await callWASM(snapshotPrepared ? "ofdgoSaveSnapshot" : "ofdgoSaveDocument", indices, ...outputOptions, destination) : whole ? await callWASM("ofdgoExportDocument", format.value, dpi, indices, state.renderBackend, ...outputOptions, file) : await callWASM("ofdgoExportPage", pageIndex, format.value, dpi, state.renderBackend, ...outputOptions, file); - if ((!background && openSeq !== state.openSeq) || wasmSeq !== state.wasmSeq) { + if (tab.closed || (!background && (!active() || openSeq !== state.openSeq)) || wasmSeq !== state.wasmSeq) { return; } if (readingSave && file) { @@ -8923,10 +9287,14 @@ async function exportFile(whole, indices = null, value = el.exportFormat.value, downloadBytes(result.blob, result.mime, fileName); } if (saving && indices === null && state.editorInfo && file) { - state.savedRevision = background ? savedRevision : state.editorInfo.revision; - state.packageDirty = false; - state.encryptionDirty = false; - updatePendingChanges(); + state.savedRevision = snapshotPrepared ? savedRevision : state.editorInfo.revision; + if (!snapshotPrepared || state.editorInfo.revision === savedRevision) { + if (!snapshotPrepared || state.packageDirty === packageDirty) state.packageDirty = false; + if (!snapshotPrepared || state.encryptionDirty === encryptionDirty) state.encryptionDirty = false; + } + state.dirty = state.packageDirty || state.encryptionDirty || state.editorInfo.revision !== state.savedRevision; + if (workspace.active === tab) updatePendingChanges(); + updateWorkspaceDirty(); } if (saving && indices === null && file) { state.saveHandle = file; @@ -8938,20 +9306,24 @@ async function exportFile(whole, indices = null, value = el.exportFormat.value, state.saveStamp = {size: current.size, modified: current.lastModified}; } catch { state.saveHandle = null; } } - document.title = `${nativeHost ? "OFDGo" : "OFDGo WebUI"} - ${state.fileName}`; - el.metaFile.textContent = state.fileName; + if (active()) { + document.title = `${nativeHost ? "OFDGo" : "OFDGo WebUI"} - ${state.fileName}`; + el.metaFile.textContent = state.fileName; + } } - setStatus(`${result.label}已${action}(${formatBytes(result.size)})`); + tab.status = `${result.label}已${action}(${formatBytes(result.size)})`; + if (active()) setStatus(tab.status); return true; } catch (err) { - retrySaveAs = saving && !saveAs && !nativeHost && !!state.saveHandle && ["NotAllowedError", "SecurityError"].includes(err.name); - if ((background || openSeq === state.openSeq) && wasmSeq === state.wasmSeq && !retrySaveAs) { + const permissionDenied = saving && !saveAs && !nativeHost && !!state.saveHandle && ["NotAllowedError", "SecurityError"].includes(err.name); + retrySaveAs = active() && permissionDenied; + if (!tab.closed && (background || openSeq === state.openSeq) && wasmSeq === state.wasmSeq) { const canceled = `${action}已取消`; - if (el.batchPagesPanel.open) el.batchPagesStatus.textContent = err.name === "AbortError" ? canceled : err.message; - if (err.name === "AbortError") { - setStatus(canceled); - } else { - showError(err, false); + tab.status = permissionDenied ? "保存权限不足,请切回文档后另存" : err.name === "AbortError" ? canceled : err.message; + if (active() && !retrySaveAs) { + if (el.batchPagesPanel.open) el.batchPagesStatus.textContent = tab.status; + if (err.name === "AbortError") setStatus(tab.status); + else showError(err, false); } } } finally { @@ -8962,14 +9334,18 @@ async function exportFile(whole, indices = null, value = el.exportFormat.value, state.exporting = false; state.exportRequestID = 0; } - el.cancelExportButton.hidden = !state.exportRequestID; - if (!document.body.hasAttribute("data-busy")) el.progressPanel.hidden = true; - if ((background || openSeq === state.openSeq) && wasmSeq === state.wasmSeq) { - if (!background) setBusy(false); - updateControls(); - } + if (active()) { + el.cancelExportButton.hidden = !state.exportRequestID; + if (!document.body.hasAttribute("data-busy")) el.progressPanel.hidden = true; + if ((background || openSeq === state.openSeq) && wasmSeq === state.wasmSeq) { + if (!background) setBusy(false); + updateControls(); + } + } else if (workspace.active === tab) tab.controlsPending = true; + updateWorkspaceDirty(); + renderDocumentTabs(); } - if (retrySaveAs && (background || openSeq === state.openSeq) && wasmSeq === state.wasmSeq) return exportFile(whole, indices, value, true); + if (retrySaveAs && active() && (background || openSeq === state.openSeq) && wasmSeq === state.wasmSeq) return exportFile(whole, indices, value, true); } function cancelExport() { @@ -8977,7 +9353,9 @@ function cancelExport() { remoteDownload.abort(); return; } - const requestID = state.exportRequestID || state.saveRequestID; + const tab = workspace.pending || documentTab(); + const target = tab.state; + const requestID = target.exportRequestID || target.saveRequestID; if (!requestID || el.cancelExportButton.disabled) { return; } @@ -8990,7 +9368,7 @@ function cancelExport() { el.importStatus.textContent = "正在取消操作"; } setProgress("正在取消操作", null); - wasmWorker.postMessage({ type: "cancel", id: requestID }); + tab.worker.postMessage({ type: "cancel", id: requestID }); } function virtualIndex(offsets, top) { @@ -9146,7 +9524,7 @@ function resetPageFlow(keepCache = false) { function resetPageLoading() { releaseMediaPlayers(); for (const [id, request] of wasmRequests) { - if (request.name === "ofdgoRenderPage") wasmWorker?.postMessage({ type: "cancel", id }); + if (request.tab === documentTab() && request.name === "ofdgoRenderPage") wasmWorker?.postMessage({ type: "cancel", id }); } for (const task of state.pageInFlight.values()) { task.resolve(null); @@ -9296,14 +9674,17 @@ function schedulePageRender() { } async function processPageRenderQueue() { + const tab = documentTab(); await nextFrame(); + if (workspace.active !== tab) return; await Promise.all(Array.from({ length: nativeHost?.parallelism || wasmWorker?.parallelism || 1 }, () => processPageRenderTasks())); } async function processPageRenderTasks() { + const tab = documentTab(), state = tab.state; state.pageRenderWorkers++; try { - while (state.pageRenderQueue.length) { + while (workspace.active === tab && state.pageRenderQueue.length) { let next = 0; for (let index = 1; index < state.pageRenderQueue.length; index++) { if (comparePageRenderTask(state.pageRenderQueue[index], state.pageRenderQueue[next]) < 0) next = index; @@ -9321,7 +9702,7 @@ async function processPageRenderTasks() { const args = [task.index, state.renderBackend, state.renderDPI, displayMode() === "raster"]; args.push([...state.svgFonts.keys()], [...state.svgImages.keys()], task.preview); const page = await callWASM("ofdgoRenderPage", ...args); - if (state.pageInFlight.get(task.key) !== task) { + if (workspace.active !== tab || state.pageInFlight.get(task.key) !== task || task.openSeq !== state.openSeq) { task.resolve(null); continue; } @@ -9367,8 +9748,8 @@ async function processPageRenderTasks() { } finally { if (--state.pageRenderWorkers === 0) { state.pageRenderRunning = false; - if (!state.pageInFlight.size) releasePageResources(); - if (state.pageRenderQueue.length) schedulePageRender(); + if (workspace.active === tab && !state.pageInFlight.size) releasePageResources(); + if (workspace.active === tab && state.pageRenderQueue.length) schedulePageRender(); } } } @@ -10586,6 +10967,7 @@ function renderOutlines(reset = true) { } function resetSearch(clearInput = true) { + state.searchRunning = false; state.searchSeq += 1; state.searchQuery = ""; state.searchMatches = []; @@ -10616,6 +10998,8 @@ async function searchDocument() { const seq = state.searchSeq; const openSeq = state.openSeq; const pageCount = state.doc.pageCount; + const target = state; + target.searchRunning = true; try { for (let page = 0; page < pageCount; page += 1) { const matches = await callWASM("ofdgoSearchPage", page, query); @@ -10641,6 +11025,8 @@ async function searchDocument() { el.searchStatus.textContent = "搜索失败"; showError(err, false); } + } finally { + if (target.searchSeq === seq) target.searchRunning = false; } } @@ -12565,7 +12951,7 @@ function updateControls() { renderSecurity(); updateStructureControls(); const hasDoc = Boolean(state.doc); - el.newButton.disabled = state.saving; + el.newButton.disabled = document.body.hasAttribute("data-busy"); el.closeButton.disabled = !hasDoc || state.exporting || state.saving || document.body.hasAttribute("data-busy"); updateDocumentControls(); updateDisplayControls(); @@ -12741,8 +13127,15 @@ function updateAnnotationButton() { } async function callWASM(name, ...args) { + return callTabWASM(documentTab(), name, ...args); +} + +async function callTabWASM(tab, name, ...args) { + const state = tab.state; + const visible = isForegroundDocument(tab); + if (tab.closed) throw new Error("文档已关闭"); if (state.wasmExited) { - scheduleWASMRecovery(); + if (workspace.active === tab) scheduleWASMRecovery(); throw new Error(state.wasmRecovering ? STATUS.recovering : "引擎运行中断"); } if (!state.ready) { @@ -12752,11 +13145,13 @@ async function callWASM(name, ...args) { const id = ++wasmRequestID; if (name === "ofdgoPrepareSave" || name === "ofdgoSaveSnapshot") { state.saveRequestID = id; - el.cancelExportButton.hidden = false; - el.cancelExportButton.disabled = false; + if (visible) { + el.cancelExportButton.hidden = false; + el.cancelExportButton.disabled = false; + } } - if (name === "ofdgoResources" && resourceElements.Panel.open) resourceManager.requestID = id; - wasmRequests.set(id, { name, resolve, reject, openSeq: state.openSeq }); + if (name === "ofdgoResources" && workspace.active === tab && resourceElements.Panel.open) resourceManager.requestID = id; + wasmRequests.set(id, { name, resolve, reject, openSeq: state.openSeq, tab }); if (name === "ofdgoExportMedia") wasmRequests.get(id).mediaID = args[0]; if (name === "ofdgoConvertPDF") { const request = wasmRequests.get(id); @@ -12766,8 +13161,10 @@ async function callWASM(name, ...args) { } if (name === "ofdgoImportDocuments" || name === "ofdgoImportImages" || name === "ofdgoExportImages" || name === "ofdgoConvertPDF" || name === "ofdgoExportPage" || name === "ofdgoExportDocument" || name === "ofdgoExportAttachment" || name === "ofdgoExportResource" || name === "ofdgoExportVersion" || name === "ofdgoSaveDocument" || name === "ofdgoSaveSigned" || name === "ofdgoImportPages") { state.exportRequestID = id; - el.cancelExportButton.hidden = false; - el.cancelExportButton.disabled = false; + if (visible) { + el.cancelExportButton.hidden = false; + el.cancelExportButton.disabled = false; + } } try { const transfer = []; @@ -12776,7 +13173,7 @@ async function callWASM(name, ...args) { transfer.push(args[0].buffer); } if (name === "ofdgoLoadImport" && ArrayBuffer.isView(args[0]) && !nativeHost) transfer.push(args[0].buffer); - wasmWorker.postMessage({ id, name, args }, transfer); + tab.worker.postMessage({ id, name, args }, transfer); } catch (err) { wasmRequests.delete(id); reject(err); @@ -12794,6 +13191,7 @@ function showError(err, empty = !state.doc) { function setBusy(busy, text = "", percent = 0, status = "") { document.body.toggleAttribute("data-busy", busy); + el.newButton.disabled = el.documentNewTab.disabled = busy; el.pageLayoutButton.disabled = busy || !(state.doc?.pageCount > 1); el.closeButton.disabled = busy || !state.doc || state.exporting || state.saving; el.refreshAppButton.disabled = busy; diff --git a/assets/webui/ofdgo_host.js b/assets/webui/ofdgo_host.js index c93af11..ee106c6 100644 --- a/assets/webui/ofdgo_host.js +++ b/assets/webui/ofdgo_host.js @@ -1,5 +1,11 @@ let engineModule = 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() { engineScheduler ||= new BrowserScheduler(); @@ -31,19 +37,20 @@ class BrowserScheduler { send(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); } catch (error) { - worker.requests.delete(data.id); - worker.onmessage?.({data:{id:data.id, ok:false, error:error.message}}); + worker.requests.delete(requestKey(worker, data)); + worker.receive({data:{engineID:data.engineID, id:data.id, ok:false, error:error.message}}); } } complete(worker, data) { if (data.memory) worker.memory = data.memory; if (data.id == null || data.type) return; - const started = worker.requests.get(data.id); - worker.requests.delete(data.id); + const key = requestKey(worker, data); + const started = worker.requests.get(key); + worker.requests.delete(key); 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); } @@ -72,7 +79,7 @@ class BrowserScheduler { const index = this.queue.findIndex(task => task.worker === worker && task.data.id === id); if (index < 0) return false; 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(); return true; } @@ -89,32 +96,63 @@ class BrowserWorker { constructor(options) { engineScheduler ||= new BrowserScheduler(); 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.requests = new Map(); this.memory = 64 << 20; this.reservation = 0; 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); this.worker.onmessage = event => { if (this.closed || event.data.type === "module") return; engineScheduler.complete(this, event.data); - if (event.data.type === "exit") this.terminate(); - try { this.onmessage?.(event); } + if (event.data.type === "exit" && (!this.shared || !event.data.engineID)) this.terminate(); + try { this.receive(event); } finally { engineScheduler.drain(); } }; - this.worker.onerror = event => { this.terminate(); this.onerror?.(event); }; - this.worker.onmessageerror = event => { this.terminate(); this.onmessageerror?.(event); }; + this.worker.onerror = event => { this.terminate(); this.fail("onerror", event); }; + this.worker.onmessageerror = event => { this.terminate(); this.fail("onmessageerror", event); }; this.worker.addEventListener("message", event => { if (event.data.type !== "module") return; event.stopImmediatePropagation(); 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 }); }); }); + 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) { if (this.closed) throw new DOMException("引擎已关闭", "AbortError"); + if (!this.shared) data = {...data, engineID:this.engineID}; if (data.type) { if (data.type === "cancel" && engineScheduler.cancel(this, data.id)) return; this.worker.postMessage(data, transfer); @@ -124,15 +162,61 @@ class BrowserWorker { terminate() { if (this.closed) return; this.closed = true; + if (engineRuntimes.get(this.url) === this) engineRuntimes.delete(this.url); + this.closingSessions.clear(); this.worker.terminate(); 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 = {}) { if (nativeHost) return nativeHost.worker(); - if (options.pages) return new BrowserEngine(); - return new BrowserWorker(options); + if (options.page || options.batch) return new BrowserWorker(options); + if (options.primary) return new BrowserSession(options); + return new BrowserEngine(options); } export async function documentBytes(file) { @@ -142,8 +226,10 @@ export async function documentBytes(file) { } class BrowserEngine { - constructor() { - this.primary = createEngineWorker({primary:true}); + constructor(options) { + this.primary = createEngineWorker({primary:true, url:options.url}); + this.engineID = this.primary.engineID; + this.workerURL = options.url; this.primary.onmessage = event => { const data = event.data; if (data.memory) this.memory = data.memory; @@ -187,7 +273,7 @@ class BrowserEngine { } 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}; worker.reservation = slot.memory; worker.reclaim = () => { if (!slot.busy) this.closeSlot(slot); }; @@ -269,7 +355,7 @@ class BrowserEngine { slot.pages.delete(slot.lastPage); slot.pages.add(slot.lastPage); 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 => { if (!this.closed && revision === this.revision && error.name !== "AbortError") { this.closeSlot(slot, error); @@ -279,7 +365,7 @@ class BrowserEngine { this.primary.postMessage(data, transfer); 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(() => { if (!fallback) this.requests.delete(data.id); slot.busy = false; @@ -436,14 +522,17 @@ class NativeHost { let closed = false; let unsubscribe; let pending = Promise.resolve(); + let termination = null; const readers = new Set(); + const inputs = new Set(); const requests = new Map(); const completions = new Map(); const emit = data => { 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}) => { + worker.engineID = engine; if (closed) return engine; host.parallelism = Math.max(1, parallelism || 1); host.cacheBytes = cacheBytes || host.cacheBytes; @@ -452,8 +541,10 @@ class NativeHost { return engine; }).catch(error => { if (!closed) worker.onerror?.({message:error.message}); throw error; }); const worker = { + engineID:null, onmessage:null, onerror:null, + configurePages() {}, postMessage(data) { if (closed) return; if (data.type === "cancel") { @@ -461,6 +552,10 @@ class NativeHost { 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(); requests.set(data.id, controller); const parallel = data.name === "ofdgoRenderPage"; @@ -502,14 +597,17 @@ class NativeHost { } else if (data.name !== "ofdgoSaveSnapshot") pending = operation; }, terminate() { - if (closed) return; + if (closed) return termination; closed = true; 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?.(); for (const resolve of completions.values()) resolve({ok:false, canceled:true, error:"会话已关闭"}); completions.clear(); + await Promise.all([...inputs].map(file => host.call("release", {file}).catch(() => {}))); + inputs.clear(); }); + return termination; }, }; ready.catch(() => {}); diff --git a/assets/webui/ofdgo_wasm.js b/assets/webui/ofdgo_wasm.js index c4229b9..025a78d 100644 --- a/assets/webui/ofdgo_wasm.js +++ b/assets/webui/ofdgo_wasm.js @@ -1,12 +1,15 @@ importScripts("./wasm_exec.js"); -let pending = Promise.resolve(); -const operations = new Map(); -const fontRequests = new Map(); +const engines = new Map(); +let resolveRuntime; +let rejectRuntime; +const runtimeReady = new Promise((resolve, reject) => { resolveRuntime = resolve; rejectRuntime = reject; }); +runtimeReady.catch(() => {}); let fontSequence = 0; 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; } const request = ++fontSequence; const cancel = () => { @@ -18,7 +21,7 @@ function requestFont(id, signal, name, done) { signal.removeEventListener("abort", cancel); 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 }) => { if (data.type === "module") { @@ -26,24 +29,71 @@ self.onmessage = ({ data }) => { else loadWASM(data.module).catch(exitWASM); 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") { - fontRequests.get(data.request)?.(data); - fontRequests.delete(data.request); + engine.fontRequests.get(data.request)?.(data); + engine.fontRequests.delete(data.request); return; } if (data.type === "cancel") { - operations.get(data.id)?.abort(); + engine.operations.get(data.id)?.abort(); return; } - if (["ofdgoResources", "ofdgoPrepareSave", "ofdgoSaveSnapshot"].includes(data.name)) operations.set(data.id, new AbortController()); - if (data.name === "ofdgoSaveSnapshot") { - pending = pending.then(() => { void handleMessage(data); }); - return; - } - 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()); - } - pending = pending.then(() => data.name === "ofdgoConvertFile" || data.name === "ofdgoPackFiles" ? handleBatchMessage(data) : handleMessage(data)); + engine.operations.set(data.id, new AbortController()); + engine.pending = engine.pending.then(() => { + const operation = (data.name === "ofdgoConvertFile" || data.name === "ofdgoPackFiles" ? handleBatchMessage(engine, data) : handleMessage(engine, data)).catch(error => { + engine.operations.delete(data.id); + self.postMessage({ engineID, id: data.id, ok: false, error: error.message }); + }); + engine.active.add(operation); + operation.finally(() => engine.active.delete(operation)); + if (data.name !== "ofdgoSaveSnapshot") return operation; + }); }; 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 channel = new MessageChannel(); const finish = (done, err) => { @@ -89,7 +141,7 @@ async function handleBatchMessage({ id, name, args }) { if (name === "ofdgoConvertFile") { const [file, options, fonts, destination] = args; 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); if (!format) throw new Error("不支持此格式"); root = destination; @@ -100,7 +152,7 @@ async function handleBatchMessage({ id, name, args }) { if (format.paged) directory = await root.getDirectoryHandle(target.name, { create: true }); else { await root.getFileHandle(target.name, { create: true }); directory = root; } created = target.name; - self.postMessage({ id, type: "output", name: created }); + postMessage({ id, type: "output", name: created }); return target; }) : { stem: base, name: format.paged ? base : `${base}.${format.extension}` }; const boundary = async (action, pageName) => { @@ -113,7 +165,7 @@ async function handleBatchMessage({ id, name, args }) { const handle = await directory.getFileHandle(fileName, { create: true }); if (!format.paged) { created = fileName; - self.postMessage({ id, type: "output", name: created }); + postMessage({ id, type: "output", name: created }); } current.handle = handle; output = await handle.createWritable(); @@ -126,19 +178,19 @@ async function handleBatchMessage({ id, name, args }) { 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) => { - 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); }, (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 { const [entries, handle, compression] = args; 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, - (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); } signal.throwIfAborted(); @@ -157,7 +209,7 @@ async function handleBatchMessage({ id, name, args }) { } result.data.size = size; operations.delete(id); - self.postMessage({ id, ...result, memory: engineMemory.buffer.byteLength }); + postMessage({ id, ...result, memory: engineMemory.buffer.byteLength }); } catch (err) { await output?.abort().catch(() => {}); let message = signal.aborted ? "转换已取消" : err.message; @@ -165,7 +217,7 @@ async function handleBatchMessage({ id, name, args }) { try { await root.removeEntry(created, { recursive: true }); } 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 { credentials?.password?.fill(0); credentials?.key?.fill(0); @@ -175,12 +227,15 @@ async function handleBatchMessage({ id, name, args }) { 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 saveFile; let saveStamp; let channel; 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 converting = name === "ofdgoConvertPDF"; const saving = name === "ofdgoSaveDocument" || name === "ofdgoSaveSnapshot" || name === "ofdgoSaveSigned"; @@ -222,7 +277,7 @@ async function handleMessage({ id, name, args }) { } const chunks = []; let size = 0; - if (signal) { + if (cancellable) { channel = new MessageChannel(); const finish = (done, err, commit = false) => { channel.port1.onmessage = () => { @@ -235,10 +290,10 @@ async function handleMessage({ id, name, args }) { args.push(done => finish(done)); } else if (importing || converting) { 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"); }); - if (converting) args.push((name, done) => requestFont(id, signal, name, done)); + if (converting) args.push((name, done) => requestFont(engine, id, signal, name, done)); } else { const destination = args.pop(); const file = destination?.fileHandle || destination; @@ -263,17 +318,17 @@ async function handleMessage({ id, name, args }) { }); if (name === "ofdgoExportDocument") { args.push((completed, total, done) => { - self.postMessage({ id, type: "export", stage: "pages", completed, total }); + postMessage({ id, type: "export", stage: "pages", completed, total }); finish(done); }); } else if (name === "ofdgoExportVersion") { 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); }, done => finish(done)); } else if (saving) { 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); }); 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)); } } - const payload = await globalThis[name](...args); + const payload = await globalThis.ofdgoEngineCall(engine.id, name, ...args); signal?.throwIfAborted(); 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; - if (name !== "ofdgoExportMedia") self.postMessage({ id, type: "export", stage: "save" }); + if (name !== "ofdgoExportMedia") postMessage({ id, type: "export", stage: "save" }); if (output) { if (saveStamp) { const current = await saveFile.getFile(); @@ -304,15 +359,15 @@ async function handleMessage({ id, name, args }) { } 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))]; - self.postMessage({ id, ...result, memory: engineMemory.buffer.byteLength }, transfer); + postMessage({ id, ...result, memory: engineMemory.buffer.byteLength }, transfer); } catch (err) { await output?.abort().catch(() => {}); const result = { id, ok: false, error: err.message, errorName: err.name }; if (signal?.aborted) { - result.error = resources ? "查询已取消" : importing ? "导入已取消" : converting ? "转换已取消" : "导出已取消"; + result.error = resources ? "查询已取消" : importing ? "导入已取消" : converting ? "转换已取消" : cancellable ? "导出已取消" : "操作已取消"; result.canceled = true; } - self.postMessage(result); + postMessage(result); } finally { password?.fill(0); key?.fill(0); @@ -324,6 +379,7 @@ async function handleMessage({ id, name, args }) { } function exitWASM(err) { + rejectRuntime(err || new Error("引擎运行中断")); self.postMessage({ type: "exit", error: err?.message || "引擎运行中断" }); self.close(); } @@ -335,9 +391,7 @@ async function loadWASM(module) { self.postMessage({ type: "progress", phase: "start", text: "正在启动引擎", percent: 58 }); go.run(instance).then(exitWASM, exitWASM); if (!go.exited) { - const backends = JSON.parse(globalThis.ofdgoRenderBackends()); - if (!backends.ok) throw new Error(backends.error); - self.postMessage({ type: "ready", backends: backends.data, memory: engineMemory.buffer.byteLength }); + resolveRuntime(); } } diff --git a/go.mod b/go.mod index e8636c2..10a49d6 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/go-text/typesetting v0.3.5 github.com/tdewolff/canvas v0.0.0-20260923214215-09804640d00c 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/jbig2 v1.1.8 github.com/xiaoqidun/pdfgo v0.0.0-20261021071541-3fdb0cab9d95 diff --git a/go.sum b/go.sum index 9b39c6d..a60cd39 100644 --- a/go.sum +++ b/go.sum @@ -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/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk= 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-20261020075840-622fd8853d72/go.mod h1:xJF8xE6Bjw+NcOZNvWJEVX/XXIkngs3ltc9F6vNcLls= +github.com/xiaoqidun/guigo v0.0.0-20261022051313-f2a6a53e3537 h1:s96G2hbG89CN3eWpoLD4jc9K1U6OSvD9ly30eQJXY5c= +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/go.mod h1:DCwAfuDzCDYqUiAlZ7BDwigTjaXw7o9L1FtEma/D1aA= github.com/xiaoqidun/jbig2 v1.1.8 h1:dERPW7F8gs18a/DOD9gXHR3fP1Vl6KhCNsLnq5l3BIo= diff --git a/internal/webui/webui_wasm.go b/internal/webui/webui_wasm.go index 6359be9..a12f184 100644 --- a/internal/webui/webui_wasm.go +++ b/internal/webui/webui_wasm.go @@ -37,42 +37,74 @@ const ( // platformValue 浏览器参数值 type platformValue = js.Value +// wasmEngines 管理共享资源的独立浏览器业务会话 +type wasmEngines struct { + resources *ofdgo.Resources + engines map[string]*businessEngine +} + // RunWASM 注册浏览器WASM接口并阻塞运行 func RunWASM() { resources, _ := ofdgo.NewResources(ofdgo.ResourceOptions{Parallelism: 1, MemoryLimit: 64 << 20, CacheBytes: 48 << 20, SnapshotBytes: 32 << 20}) - e := &businessEngine{resources: resources} - asynchronous := map[string]bool{ - "ofdgoDetectFormat": true, - "ofdgoExportVersion": true, - "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) - } - } + engines := &wasmEngines{resources: resources, engines: make(map[string]*businessEngine)} + registerCallback("ofdgoEngineOpen", engines.open) + registerAsyncCallback("ofdgoEngineCall", engines.call) + registerCallback("ofdgoEngineClose", engines.close) 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 等待浏览器处理导出检查点 // 入参: fn 浏览器回调, args 回调参数 // 返回: error 写入错误或取消原因 diff --git a/internal/webview/webview.go b/internal/webview/webview.go index c494f8f..ff6ee1e 100644 --- a/internal/webview/webview.go +++ b/internal/webview/webview.go @@ -50,6 +50,7 @@ type Host struct { files *fileStore engines map[string]*webui.NativeEngine engineStops map[string]func() bool + closingEngines map[string]*engineClose conversionPool *ofdgo.ConversionPool resources *ofdgo.Resources cancels map[string]context.CancelFunc @@ -59,6 +60,14 @@ type Host struct { fontFiles map[string]string fontError error pendingPaths []string + frontendReady bool + closeAllowed bool +} + +// engineClose 保存并发关闭共享的完成通知及结果 +type engineClose struct { + done chan struct{} + err error } // resourceReader 保存资源读取位置及关闭操作 @@ -92,7 +101,7 @@ func Run(ctx context.Context, paths []string, dataDir string) error { h.conversionPool = h.resources.ConversionPool() defer h.close() 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) } } static := http.FileServerFS(webuiassets.NativeFS) @@ -124,6 +133,18 @@ func Run(ctx context.Context, paths []string, dataDir string) error { h.mu.Unlock() } 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) { 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, 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": h.mu.Lock() paths := h.pendingPaths h.pendingPaths = nil + h.frontendReady = true h.mu.Unlock() if len(paths) != 0 { 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 { var results []any h.mu.Lock() + if closing := h.closingEngines[id]; closing != nil { + h.mu.Unlock() + <-closing.done + return closing.err + } 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) if stop := h.engineStops[id]; stop != nil { stop() @@ -785,10 +857,13 @@ func (h *Host) closeEngine(id string) error { for _, result := range results { h.discard(result) } - if engine != nil { - return engine.Close() - } - return nil + err := engine.Close() + h.mu.Lock() + closing.err = err + delete(h.closingEngines, id) + close(closing.done) + h.mu.Unlock() + return err } // close 取消任务并释放当前窗口持有的会话 @@ -797,17 +872,18 @@ func (h *Host) close() { for _, cancel := range h.cancels { 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 h.batchResults = nil - for _, stop := range h.engineStops { - stop() - } - h.engineStops = nil - h.engines = make(map[string]*webui.NativeEngine) h.mu.Unlock() - for _, engine := range engines { - _ = engine.Close() + for _, id := range engines { + _ = h.closeEngine(id) } for _, result := range results { h.discard(result) @@ -842,8 +918,15 @@ func windowOptions(dataDir string) (guigo.Options, error) { return guigo.Options{}, err } return guigo.Options{ - AppID: applicationID, - Title: "OFDGo - OFD编辑器", + AppID: applicationID, + Title: "OFDGo - OFD编辑器", + TitleBar: guigo.TitleBarContent, + TitleBarColors: guigo.TitleBarColors{ + Background: "#e8edf0", + Foreground: "#1f2428", + InactiveBackground: "#eef1f4", + InactiveForeground: "#66737f", + }, Icon: icon, Width: 1440, Height: 900, diff --git a/internal/webview/webview_files.go b/internal/webview/webview_files.go index dc7aab1..8f93db2 100644 --- a/internal/webview/webview_files.go +++ b/internal/webview/webview_files.go @@ -333,17 +333,18 @@ func (s *fileStore) resource(id string) (io.ReaderAt, int64, func() error, error return file, info.Size(), file.Close, nil } -// release 释放内存结果或临时文件并取消页面清理回调 +// release 释放读取快照、内存结果或临时文件,保留在途读取及源文件 // 入参: id 资源标识 func (s *fileStore) release(id string) { s.mu.Lock() 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) + entry.retired = true if entry.stop != nil { entry.stop() } - if entry.file != nil { + if entry.file != nil && entry.readers == 0 { entry.file.Close() } if entry.temporary {