diff --git a/assets/webui/index.html b/assets/webui/index.html index dd06374..dda6a9f 100644 --- a/assets/webui/index.html +++ b/assets/webui/index.html @@ -66,6 +66,15 @@ 插入 +
+ + + + + + + 页面 +
@@ -79,13 +88,15 @@
-
- -
mm
-
-
- -
mm
+
+
+ +
mm
+
+
+ +
mm
+
@@ -94,6 +105,32 @@
+ +
+
+ 方向 +
+ + +
+
+
+
+ +
mm
+
+
+ +
mm
+
+
+
+
+ + +
+
+
diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css index 2e39949..dcdd4d4 100644 --- a/assets/webui/ofdgo.css +++ b/assets/webui/ofdgo.css @@ -97,13 +97,21 @@ a:focus-visible { .editor-group { display: grid; - grid-auto-columns: max-content; + grid-template-columns: repeat(3, max-content); column-gap: 6px; row-gap: 4px; padding-right: 12px; border-right: 1px solid var(--line); } +.editor-group:has(#insertTextButton) { + grid-template-columns: repeat(2, max-content); +} + +.editor-group:has(#addPageButton) { + grid-template-columns: repeat(6, max-content); +} + .editor-group:last-child { border-right: 0; } @@ -351,6 +359,17 @@ button:not(:disabled):hover { margin-bottom: 12px; } +.form-pair { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.form-pair .form-unit { + grid-template-columns: minmax(0, 1fr) auto; + gap: 4px; +} + .form-options { display: flex; } @@ -1744,3 +1763,13 @@ button.outline-link:hover { width: 48px; } } + +@media (max-width: 360px) { + .editor-group:has(#addPageButton) { + grid-template-columns: repeat(3, max-content); + } + + .editor-group:has(#addPageButton) > button { + grid-row: auto; + } +} diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index ac31b0f..03415cb 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -91,6 +91,20 @@ const el = { redoButton: document.querySelector("#redoButton"), insertTextButton: document.querySelector("#insertTextButton"), insertImageButton: document.querySelector("#insertImageButton"), + addPageButton: document.querySelector("#addPageButton"), + copyPageButton: document.querySelector("#copyPageButton"), + deletePageButton: document.querySelector("#deletePageButton"), + movePagePrevButton: document.querySelector("#movePagePrevButton"), + movePageNextButton: document.querySelector("#movePageNextButton"), + pageSettingsButton: document.querySelector("#pageSettingsButton"), + pagePanel: document.querySelector("#pagePanel"), + pageForm: document.querySelector("#pageForm"), + pagePortrait: document.querySelector("#pagePortrait"), + pageLandscape: document.querySelector("#pageLandscape"), + pageWidth: document.querySelector("#pageWidth"), + pageHeight: document.querySelector("#pageHeight"), + pageStatus: document.querySelector("#pageStatus"), + pageCancel: document.querySelector("#pageCancel"), saveButton: document.querySelector("#saveButton"), createPanel: document.querySelector("#createPanel"), createForm: document.querySelector("#createForm"), @@ -235,6 +249,31 @@ el.editTextButton.addEventListener("click", () => { }); el.undoButton.addEventListener("click", () => changeDocument("ofdgoUndo")); el.redoButton.addEventListener("click", () => changeDocument("ofdgoRedo")); +el.addPageButton.addEventListener("click", () => { + const page = currentPageInfo(); + return changeDocument("ofdgoChangePage", null, "add", state.pageIndex, page.width, page.height); +}); +el.copyPageButton.addEventListener("click", () => changeDocument("ofdgoChangePage", null, "copy", state.pageIndex)); +el.deletePageButton.addEventListener("click", () => changeDocument("ofdgoChangePage", null, "delete", state.pageIndex)); +el.movePagePrevButton.addEventListener("click", () => changeDocument("ofdgoChangePage", null, "move", state.pageIndex, state.pageIndex - 1)); +el.movePageNextButton.addEventListener("click", () => changeDocument("ofdgoChangePage", null, "move", state.pageIndex, state.pageIndex + 1)); +el.pageSettingsButton.addEventListener("click", openPagePanel); +el.pageCancel.addEventListener("click", () => el.pagePanel.close()); +el.pageWidth.addEventListener("input", updatePageDirection); +el.pageHeight.addEventListener("input", updatePageDirection); +for (const radio of [el.pagePortrait, el.pageLandscape]) { + radio.addEventListener("change", () => { + [el.pageWidth.value, el.pageHeight.value] = [el.pageHeight.value, el.pageWidth.value]; + updatePageDirection(); + }); +} +el.pageForm.addEventListener("submit", async (event) => { + event.preventDefault(); + el.pageStatus.textContent = ""; + if (await changeDocument("ofdgoChangePage", null, "resize", state.pageIndex, Number(el.pageWidth.value), Number(el.pageHeight.value))) { + el.pagePanel.close(); + } +}); el.ofdButton.addEventListener("click", openOFDFile); el.newButton.addEventListener("click", openCreatePanel); el.createCancel.addEventListener("click", () => el.createPanel.close()); @@ -455,7 +494,25 @@ function handleKeyDown(event) { } function formDialogOpen() { - return el.exportPanel.open || el.createPanel.open || el.insertPanel.open; + return el.exportPanel.open || el.createPanel.open || el.insertPanel.open || el.pagePanel.open; +} + +function openPagePanel() { + if (!state.editing || document.body.hasAttribute("aria-busy")) { + return; + } + const page = currentPageInfo(); + el.pageWidth.value = String(page.width); + el.pageHeight.value = String(page.height); + el.pageStatus.textContent = ""; + updatePageDirection(); + el.pagePanel.showModal(); +} + +function updatePageDirection() { + const landscape = Number(el.pageWidth.value) > Number(el.pageHeight.value); + el.pageLandscape.checked = landscape; + el.pagePortrait.checked = !landscape; } function warnUnsaved(event) { @@ -590,12 +647,12 @@ async function insertObject(event) { let openSeq = state.openSeq; const text = !el.insertTextRow.hidden; const item = state.textObject; - const { scrollLeft, scrollTop } = el.viewerPanel; - const page = state.doc.pages[state.pageIndex]; + const index = item?.index ?? state.pageIndex; + const page = state.doc.pages[index]; const x = Math.min(20, page.width / 10); const y = Math.min(20, page.height / 10); el.insertStatus.textContent = ""; - setBusy(true, item ? "正在修改文字" : text ? "正在添加文字" : "正在添加图片", null); + setBusy(true); try { let data = null; if (text) { @@ -613,8 +670,8 @@ async function insertObject(event) { const size = item && points === Number((item.size * 72 / 25.4).toPrecision(12)) ? item.size : points * 25.4 / 72; const doc = item ? await callWASM("ofdgoUpdateText", item.index, item.id, el.insertText.value, data, size) - : text ? await callWASM("ofdgoInsertText", state.pageIndex, el.insertText.value, data, x, y, size) - : await callWASM("ofdgoInsertImage", state.pageIndex, data, x, y, page.width - x * 2); + : text ? await callWASM("ofdgoInsertText", index, el.insertText.value, data, x, y, size) + : await callWASM("ofdgoInsertImage", index, data, x, y, page.width - x * 2); if (openSeq !== state.openSeq) { return; } @@ -624,15 +681,17 @@ async function insertObject(event) { state.selectObjects = true; setPan(false); openSeq = ++state.openSeq; - await openDocument({ doc, openSeq, skipAutoFonts: true, pageIndex: item?.index ?? state.pageIndex, fitMode: state.fitMode, scale: state.scale }); - if (item && openSeq === state.openSeq) { - el.viewerPanel.scrollLeft = scrollLeft; - el.viewerPanel.scrollTop = scrollTop; + await refreshEditorPage(doc, index, openSeq); + if (openSeq === state.openSeq) { el.viewerPanel.focus({ preventScroll: true }); } } catch (err) { if (openSeq === state.openSeq) { - el.insertStatus.textContent = err.message; + if (el.insertPanel.open) { + el.insertStatus.textContent = err.message; + } else { + showError(err, false); + } } } finally { if (openSeq === state.openSeq) { @@ -646,27 +705,51 @@ async function changeDocument(name, item, ...args) { return; } let openSeq = state.openSeq; + const previous = currentPageInfo(); const { scrollLeft, scrollTop } = el.viewerPanel; - setBusy(true, "正在更新文档", null); + setBusy(true); try { - const doc = await callWASM(name, ...(item ? [item.index, item.id, ...args] : [])); + const doc = await callWASM(name, ...(item ? [item.index, item.id] : []), ...args); if (openSeq !== state.openSeq) { return; } + if (doc.revision === state.editorInfo?.revision) { + return true; + } setEditorInfo(doc); if (!item || name === "ofdgoDeleteObject") { canvasEditor.clear(); } openSeq = ++state.openSeq; - await openDocument({ doc, openSeq, skipAutoFonts: true, pageIndex: item?.index ?? state.pageIndex, fitMode: state.fitMode, scale: state.scale }); + if (item) { + await refreshEditorPage(doc, item.index, openSeq); + } else { + const samePage = doc.pages.findIndex((page) => page.id === previous.id); + const pageIndex = doc.pageIndex ?? (samePage < 0 ? Math.min(state.pageIndex, doc.pageCount - 1) : samePage); + const page = doc.pages[pageIndex]; + const keepScroll = doc.pageIndex === undefined && page.id === previous.id && pageIndex === state.pageIndex + && page.width === previous.width && page.height === previous.height; + await openDocument({ doc, openSeq, skipAutoFonts: true, pageIndex, fitMode: state.fitMode, scale: state.scale }); + if (openSeq === state.openSeq) { + if (keepScroll) { + el.viewerPanel.scrollLeft = scrollLeft; + el.viewerPanel.scrollTop = scrollTop; + } else { + scrollToPage(pageIndex); + } + } + } if (openSeq === state.openSeq) { - el.viewerPanel.scrollLeft = scrollLeft; - el.viewerPanel.scrollTop = scrollTop; el.viewerPanel.focus({ preventScroll: true }); + return true; } } catch (err) { if (openSeq === state.openSeq) { - showError(err, false); + if (el.pagePanel.open) { + el.pageStatus.textContent = err.message; + } else { + showError(err, false); + } } } finally { if (openSeq === state.openSeq) { @@ -675,6 +758,57 @@ async function changeDocument(name, item, ...args) { } } +async function refreshEditorPage(doc, index, openSeq) { + state.doc = doc; + resetSearch(); + state.documentSelection = null; + resetPageLoading(); + try { + const page = await loadPageData(index, { openSeq, priority: 0, refresh: true }); + if (openSeq !== state.openSeq) { + return; + } + mountPageSVG(index, page, openSeq); + updateThumbnail(index, openSeq); + setStatus(pageStatus(state.pageIndex, doc.pageCount)); + } catch (err) { + if (openSeq === state.openSeq) { + state.pageCache.delete(index); + canvasEditor.clear(); + const shell = pageShell(index); + shell.querySelector(".page-surface").replaceChildren(); + shell.classList.remove("rendered"); + markFlowPageError(index, err); + markThumbnailError(index); + } + throw err; + } finally { + if (openSeq === state.openSeq) { + const pages = [...state.pageCache.values()]; + for (const [name, image] of state.svgImages) { + if (!pages.some((page) => page.imageNames.includes(name))) { + URL.revokeObjectURL(image.url); + state.svgImages.delete(name); + } + } + for (const [name, font] of state.svgFonts) { + if (!pages.some((page) => page.fonts.some((item) => item.name === name))) { + document.fonts.delete(font); + state.svgFonts.delete(name); + } + } + renderMeta(); + updateControls(); + loadDocumentDetails(openSeq); + for (const page of doc.pages) { + observeFlowPage(pageShell(page.index), page.index); + const button = el.pageList.querySelector(`[data-page-index="${page.index}"]`); + observeThumbnail(button, page.index, openSeq); + } + } + } +} + async function openOFDFile() { if (document.body.hasAttribute("aria-busy")) { return; @@ -1116,6 +1250,7 @@ async function openOFD(file) { setDirty(false); el.createPanel.close(); el.insertPanel.close(); + el.pagePanel.close(); updateControls(); await openDocument({ pageIndex: 0, resetScroll: true, openSeq }); } catch (err) { @@ -1587,7 +1722,7 @@ async function loadDocumentDetails(openSeq) { } async function renderPage(index, options = {}) { - if (!state.doc) { + if (!state.doc || (state.editing && document.body.hasAttribute("aria-busy") && !options.keepBusy)) { return null; } const openSeq = options.openSeq || state.openSeq; @@ -1835,6 +1970,10 @@ function resetPageFlow() { state.selectedPages.clear(); state.documentSelection = null; state.visiblePages.clear(); + resetPageLoading(); +} + +function resetPageLoading() { for (const task of state.pageInFlight.values()) { task.resolve(null); } @@ -1936,12 +2075,6 @@ function loadPageData(index, options = {}) { if (openSeq !== state.openSeq) { return Promise.resolve(null); } - if (state.pageCache.has(index)) { - const page = state.pageCache.get(index); - state.pageCache.delete(index); - state.pageCache.set(index, page); - return Promise.resolve(page); - } const key = `${openSeq}:${index}`; const priority = options.priority ?? 3; const current = state.pageInFlight.get(key); @@ -1949,13 +2082,19 @@ function loadPageData(index, options = {}) { current.priority = Math.min(current.priority, priority); return current.promise; } + if (!options.refresh && state.pageCache.has(index)) { + const page = state.pageCache.get(index); + state.pageCache.delete(index); + state.pageCache.set(index, page); + return Promise.resolve(page); + } let resolve; let reject; const promise = new Promise((done, fail) => { resolve = done; reject = fail; }); - const task = { key, index, openSeq, priority, resolve, reject, promise }; + const task = { key, index, openSeq, priority, refresh: options.refresh, resolve, reject, promise }; state.pageInFlight.set(key, task); state.pageRenderQueue.push(task); schedulePageRender(); @@ -1981,7 +2120,7 @@ async function processPageRenderQueue() { task.resolve(null); continue; } - if (state.pageCache.has(task.index)) { + if (!task.refresh && state.pageCache.has(task.index)) { task.resolve(state.pageCache.get(task.index)); continue; } @@ -2892,6 +3031,7 @@ function updateThumbnail(index, openSeq = state.openSeq) { function markThumbnailError(index) { const thumb = el.pageList.querySelector(`[data-page-index="${index}"] .thumb-paper`); if (thumb) { + delete thumb.dataset.renderKey; thumb.classList.add("error"); thumb.textContent = String(index + 1); } @@ -3617,6 +3757,11 @@ function updateControls() { function updateEditorTools() { el.editorTools.hidden = !state.editing; el.insertTextButton.disabled = el.insertImageButton.disabled = el.saveButton.disabled = !state.ready || state.exporting; + const pagesDisabled = !state.editing || !state.ready || state.exporting; + el.addPageButton.disabled = el.copyPageButton.disabled = el.pageSettingsButton.disabled = pagesDisabled; + el.deletePageButton.disabled = pagesDisabled || state.doc?.pageCount <= 1; + el.movePagePrevButton.disabled = pagesDisabled || state.pageIndex === 0; + el.movePageNextButton.disabled = pagesDisabled || state.pageIndex === state.doc?.pageCount - 1; const enabled = state.editing && state.selectObjects && !state.panMode; canvasEditor.setEnabled(enabled); el.selectObjectButton.setAttribute("aria-pressed", String(enabled)); @@ -3678,6 +3823,7 @@ function setBusy(busy, text = "", percent = 0, status = "") { document.body.toggleAttribute("aria-busy", busy); el.createForm.inert = busy; el.insertForm.inert = busy; + el.pageForm.inert = busy; el.editorTools.inert = busy; el.fontList.inert = busy; if (!busy) { @@ -3689,7 +3835,7 @@ function setBusy(busy, text = "", percent = 0, status = "") { } el.exportPanel.close(); endPan(); - el.progressPanel.hidden = false; + el.progressPanel.hidden = !text; setProgress(text, percent, status); } diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index 7247cdd..84f47a9 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -96,6 +96,7 @@ func RunWASM() { registerExportCallback("ofdgoExportAttachment", exportAttachment) registerCallback("ofdgoMatchFontFiles", matchFontFiles) registerCallback("ofdgoCreateDocument", createDocument) + registerCallback("ofdgoChangePage", changePage) registerCallback("ofdgoInsertText", insertText) registerCallback("ofdgoUpdateText", updateText) registerCallback("ofdgoInsertImage", insertImage) @@ -521,6 +522,12 @@ type editorInfo struct { CanRedo bool `json:"canRedo"` } +// editorPageInfo 页面操作结果与目标页面 +type editorPageInfo struct { + editorInfo + PageIndex int `json:"pageIndex"` +} + // editorSummary 获取当前创作文档状态 // 返回: editorInfo 文档信息 func editorSummary() editorInfo { @@ -660,6 +667,49 @@ func deleteObject(args []js.Value) (any, error) { return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) } +// changePage 管理创作文档的页面 +// 入参: args 操作、页面索引及尺寸或目标索引 +// 返回: any 文档信息和目标页面, error 错误信息 +func changePage(args []js.Value) (any, error) { + if currentEditor == nil { + return nil, fmt.Errorf("no document is being created") + } + index := args[1].Int() + revision := currentEditor.Revision() + var err error + switch action := args[0].String(); action { + case "add": + index, err = currentEditor.AddPage(args[2].Float(), args[3].Float()) + case "copy": + index, err = currentEditor.CopyPage(index) + case "delete": + if currentEditor.PageCount() == 1 { + return nil, fmt.Errorf("the document must contain at least one page") + } + err = currentEditor.DeletePage(index) + index = min(index, currentEditor.PageCount()-1) + case "move": + target := args[2].Int() + err = currentEditor.MovePage(index, target) + index = target + case "resize": + err = currentEditor.ResizePage(index, args[2].Float(), args[3].Float()) + default: + return nil, fmt.Errorf("unsupported page action %q", action) + } + if err != nil { + return nil, err + } + if currentEditor.Revision() == revision { + return editorPageInfo{editorSummary(), index}, nil + } + info, err := previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) + if err != nil { + return nil, err + } + return editorPageInfo{info, index}, nil +} + // createDocument 新建单页文档 // 入参: args 标题、纸张宽高和注解设置 // 返回: any 文档信息, error 错误信息 @@ -675,15 +725,15 @@ func createDocument(args []js.Value) (any, error) { // previewEditor 通过内存快照更新预览,不生成中间压缩包 // 入参: editor 新建文档, annotations 是否显示注解 -// 返回: any 文档信息, error 错误信息 -func previewEditor(editor *ofdgo.Editor, annotations bool) (any, error) { +// 返回: editorInfo 文档信息, error 错误信息 +func previewEditor(editor *ofdgo.Editor, annotations bool) (editorInfo, error) { reader, err := editor.Reader() if err != nil { - return nil, err + return editorInfo{}, err } session, err := newSession(reader, OpenOptions{RenderAnnotations: annotations}) if err != nil { - return nil, err + return editorInfo{}, err } if currentSession != nil { _ = currentSession.Close()