diff --git a/assets/webui/assets.go b/assets/webui/assets.go index d998630..a801a32 100644 --- a/assets/webui/assets.go +++ b/assets/webui/assets.go @@ -22,7 +22,7 @@ import ( // FS WebUI嵌入静态文件 // -//go:embed index.html ofdgo.css ofdgo.js ofdgo.json ofdgo.svg ofdgo.sw.js ofdgo.wasm ofdgo.wasm.js wasm_exec.js +//go:embed index.html ofdgo.css ofdgo.js ofdgo.json ofdgo.svg ofdgo.wasm ofdgo_edit.js ofdgo_font.js ofdgo_wasm.js ofdgo_work.js wasm_exec.js var FS embed.FS // Checksum 计算WebUI嵌入资源校验值 diff --git a/assets/webui/index.html b/assets/webui/index.html index 311df65..dd06374 100644 --- a/assets/webui/index.html +++ b/assets/webui/index.html @@ -17,6 +17,7 @@
+
@@ -53,6 +54,74 @@ + + +
+
+ + +
+
+ +
mm
+
+
+ +
mm
+
+
+
+ + +
+
+
+ +
+
+ + +
+
+ +
+ + +
+
+
+ +
pt
+
+ +
+
+ + +
+
+
diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css index 2a0bcca..2e39949 100644 --- a/assets/webui/ofdgo.css +++ b/assets/webui/ofdgo.css @@ -77,6 +77,48 @@ a:focus-visible { overflow: hidden; } +.app:has(.editor-toolbar:not([hidden])) { + grid-template-rows: auto auto minmax(0, 1fr) auto; +} + +.editor-toolbar { + display: flex; + flex-wrap: wrap; + gap: 12px; + padding: 8px 12px 6px; + border-bottom: 1px solid var(--line); + background: var(--panel); + font-size: 14px; +} + +.editor-toolbar[hidden] { + display: none; +} + +.editor-group { + display: grid; + grid-auto-columns: max-content; + column-gap: 6px; + row-gap: 4px; + padding-right: 12px; + border-right: 1px solid var(--line); +} + +.editor-group:last-child { + border-right: 0; +} + +.editor-group > button { + grid-row: 1; +} + +.editor-group > span { + grid-column: 1 / -1; + text-align: center; + font-size: 12px; + color: var(--text-muted); +} + .app-bar { display: flex; align-items: center; @@ -186,6 +228,29 @@ button:not(:disabled):hover { content: ""; } +.new-icon { + position: relative; + width: 12px; + height: 15px; + border: 1px solid currentColor; +} + +.new-icon::before, +.new-icon::after { + position: absolute; + top: 50%; + left: 50%; + width: 6px; + height: 1px; + background: currentColor; + content: ""; + transform: translate(-50%, -50%); +} + +.new-icon::after { + transform: translate(-50%, -50%) rotate(90deg); +} + .toolbar-space { flex: 1 1 auto; } @@ -325,7 +390,10 @@ button:not(:disabled):hover { outline-offset: 2px; } -.form-row input[type="text"] { +.form-row input[type="text"], +.form-row input[type="number"], +.form-row input[type="file"], +.form-row select { width: 100%; min-width: 0; height: var(--control-height); @@ -336,6 +404,28 @@ button:not(:disabled):hover { color: #1f2428; } +.form-row input[type="file"] { + height: auto; + padding: 4px; +} + +.form-unit { + display: grid; + grid-template-columns: minmax(0, 1fr) 2em; + align-items: center; + gap: 8px; +} + +.form-unit > span { + color: var(--text-muted); +} + +.form-font { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; +} + .form-row input[aria-invalid="true"] { border-color: #b14343; } @@ -813,6 +903,7 @@ button.outline-link:hover { .meta-list > [hidden], .navigation-tabs[hidden], .form-row[hidden], +.tool-group[hidden], .page-list[hidden], .page-frame[hidden], .progress-panel > [hidden], @@ -937,6 +1028,82 @@ button.outline-link:hover { transform-origin: 0 0; } +.edit-layer { + display: none; + position: absolute; + inset: 0; + pointer-events: none; +} + +.editing-select .edit-layer { + display: block; +} + +.editing-select .text-run > span { + pointer-events: none; +} + +.edit-object { + position: absolute; + pointer-events: auto; + touch-action: none; + user-select: none; + cursor: move; +} + +.edit-object:hover, +.edit-object.selected { + outline: calc(1px / var(--surface-scale)) solid var(--accent); +} + +.edit-object.selected { + background: rgba(15, 123, 104, 0.04); +} + +.edit-handle { + display: none; + position: absolute; + width: calc(9px / var(--surface-scale)); + height: calc(9px / var(--surface-scale)); + border: calc(1px / var(--surface-scale)) solid var(--accent); + background: #fff; + transform: translate(-50%, -50%); +} + +.selected .edit-handle { + display: block; +} + +.edit-handle::before { + position: absolute; + inset: calc(-8px / var(--surface-scale)); + content: ""; +} + +.edit-nw { + left: 0; + top: 0; + cursor: nwse-resize; +} + +.edit-ne { + left: 100%; + top: 0; + cursor: nesw-resize; +} + +.edit-sw { + left: 0; + top: 100%; + cursor: nesw-resize; +} + +.edit-se { + left: 100%; + top: 100%; + cursor: nwse-resize; +} + .text-layer { position: absolute; inset: 0; diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index 69a5d74..ac31b0f 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -1,6 +1,7 @@ +import { CanvasEditor } from "./ofdgo_edit.js"; +import { FontManager } from "./ofdgo_font.js"; + const MM_TO_PX = 96 / 25.4; -const FONT_DATABASE = "ofdgo"; -const fontChannel = typeof BroadcastChannel === "function" ? new BroadcastChannel(FONT_DATABASE) : null; const COMPACT_LAYOUT = window.matchMedia("(max-width: 900px)"); const DEFAULT_IMAGE_DPI = 300; const PAGE_CACHE_LIMIT = 16; @@ -20,7 +21,6 @@ let wasmPromise = null; let wasmWorker = null; let wasmRequestID = 0; let wasmRecoveryTimer = 0; -let fontDatabase = null; let textMeasure = null; const state = { @@ -31,23 +31,24 @@ const state = { wasmRecoveries: 0, exporting: false, exportRequestID: 0, + editing: false, + selectObjects: true, + dirty: false, + editorInfo: null, + savedRevision: null, + textObject: null, + insertFonts: [], ofdBytes: null, fileName: "ofdgo.ofd", openSeq: 0, pageSeq: 0, - fontSeq: 0, searchSeq: 0, searchQuery: "", searchMatches: [], searchIndex: -1, navigationScroll: new Map(), - localFonts: [], - userFonts: [], fontSyncPending: false, fontSyncing: false, - systemFontCatalog: [], - systemFontCatalogLoaded: false, - systemFontPermission: "prompt", doc: null, pageIndex: 0, scale: 1, @@ -81,6 +82,37 @@ const state = { const el = { ofdInput: document.querySelector("#ofdInput"), ofdButton: document.querySelector("#ofdButton"), + newButton: document.querySelector("#newButton"), + editorTools: document.querySelector("#editorTools"), + selectObjectButton: document.querySelector("#selectObjectButton"), + deleteObjectButton: document.querySelector("#deleteObjectButton"), + editTextButton: document.querySelector("#editTextButton"), + undoButton: document.querySelector("#undoButton"), + redoButton: document.querySelector("#redoButton"), + insertTextButton: document.querySelector("#insertTextButton"), + insertImageButton: document.querySelector("#insertImageButton"), + saveButton: document.querySelector("#saveButton"), + createPanel: document.querySelector("#createPanel"), + createForm: document.querySelector("#createForm"), + createName: document.querySelector("#createName"), + createWidth: document.querySelector("#createWidth"), + createHeight: document.querySelector("#createHeight"), + createStatus: document.querySelector("#createStatus"), + createCancel: document.querySelector("#createCancel"), + insertPanel: document.querySelector("#insertPanel"), + insertForm: document.querySelector("#insertForm"), + insertTextRow: document.querySelector("#insertTextRow"), + insertFontRow: document.querySelector("#insertFontRow"), + insertSizeRow: document.querySelector("#insertSizeRow"), + insertImageRow: document.querySelector("#insertImageRow"), + insertText: document.querySelector("#insertText"), + insertFont: document.querySelector("#insertFont"), + insertFontAdd: document.querySelector("#insertFontAdd"), + insertSize: document.querySelector("#insertSize"), + insertImage: document.querySelector("#insertImage"), + insertStatus: document.querySelector("#insertStatus"), + insertCancel: document.querySelector("#insertCancel"), + insertSubmit: document.querySelector("#insertSubmit"), fontInput: document.querySelector("#fontInput"), fontDirectoryInput: document.querySelector("#fontDirectoryInput"), togglePagesButton: document.querySelector("#togglePagesButton"), @@ -167,7 +199,53 @@ const el = { statusText: document.querySelector("#statusText"), }; +const fontManager = new FontManager({ + onChange: scheduleFontSync, + onPermissionChange: updateFontPermissionHint, +}); + +const canvasEditor = new CanvasEditor(el.viewerPanel, { + busy: () => document.body.hasAttribute("aria-busy"), + rotation: () => state.rotation, + onSelect: updateObjectControls, + onTransform: (item, change) => changeDocument("ofdgoTransformObject", item, change.x, change.y, change.scale), + onDelete: (item) => changeDocument("ofdgoDeleteObject", item), + onEdit: (item) => openInsertPanel(true, item), +}); + +el.selectObjectButton.addEventListener("click", () => { + if (document.body.hasAttribute("aria-busy")) { + return; + } + state.selectObjects = !state.selectObjects || state.panMode; + if (state.selectObjects && state.panMode) { + togglePan(); + } + updateEditorTools(); +}); +el.deleteObjectButton.addEventListener("click", () => { + if (canvasEditor.selected) { + changeDocument("ofdgoDeleteObject", canvasEditor.selected); + } +}); +el.editTextButton.addEventListener("click", () => { + if (canvasEditor.selected && !canvasEditor.selected.image) { + openInsertPanel(true, canvasEditor.selected); + } +}); +el.undoButton.addEventListener("click", () => changeDocument("ofdgoUndo")); +el.redoButton.addEventListener("click", () => changeDocument("ofdgoRedo")); el.ofdButton.addEventListener("click", openOFDFile); +el.newButton.addEventListener("click", openCreatePanel); +el.createCancel.addEventListener("click", () => el.createPanel.close()); +el.createForm.addEventListener("submit", createDocument); +el.insertTextButton.addEventListener("click", () => openInsertPanel(true)); +el.insertImageButton.addEventListener("click", () => openInsertPanel(false)); +el.insertCancel.addEventListener("click", () => el.insertPanel.close()); +el.insertPanel.addEventListener("close", () => { state.textObject = null; }); +el.insertForm.addEventListener("submit", insertObject); +el.insertFontAdd.addEventListener("click", () => openFontFile(el.fontInput)); +el.saveButton.addEventListener("click", () => exportFile(true, null, "ofd")); el.togglePagesButton.addEventListener("click", () => toggleSidebar("pages")); el.toggleMetaButton.addEventListener("click", () => toggleSidebar("meta")); el.pagesTab.addEventListener("click", () => showNavigation(el.pagesTab)); @@ -271,9 +349,6 @@ document.addEventListener("dragover", (event) => { } }); document.addEventListener("drop", openOFDFromDrop); -if (fontChannel) { - fontChannel.onmessage = scheduleFontSync; -} el.fontList.addEventListener("focusout", () => { if (state.fontSyncPending) { window.setTimeout(syncUserFonts, 0); @@ -298,16 +373,20 @@ function handleKeyDown(event) { if ((event.ctrlKey || event.metaKey) && !event.shiftKey && key.toLowerCase() === "a" && state.doc && el.viewerPanel.contains(target) && !target.closest("input, textarea, select, [contenteditable]")) { event.preventDefault(); - if (!document.body.hasAttribute("aria-busy") && !el.exportPanel.open) { + if (!document.body.hasAttribute("aria-busy") && !formDialogOpen()) { selectDocumentText(); } return; } - if (document.body.hasAttribute("aria-busy") || el.exportPanel.open) { + if (document.body.hasAttribute("aria-busy") || formDialogOpen()) { return; } if (event.ctrlKey || event.metaKey) { - if (!event.shiftKey && key.toLowerCase() === "o") { + if (state.editing && !target.closest("input, textarea, select, [contenteditable]") + && (key.toLowerCase() === "z" || (!event.shiftKey && key.toLowerCase() === "y"))) { + event.preventDefault(); + (key.toLowerCase() === "y" || event.shiftKey ? el.redoButton : el.undoButton).click(); + } else if (!event.shiftKey && key.toLowerCase() === "o") { event.preventDefault(); openOFDFile(); } else if (!event.shiftKey && key.toLowerCase() === "f" && state.doc) { @@ -375,6 +454,227 @@ function handleKeyDown(event) { } } +function formDialogOpen() { + return el.exportPanel.open || el.createPanel.open || el.insertPanel.open; +} + +function warnUnsaved(event) { + event.preventDefault(); + event.returnValue = ""; +} + +function setDirty(dirty) { + if (state.dirty === dirty) { + return; + } + state.dirty = dirty; + if (dirty) { + window.addEventListener("beforeunload", warnUnsaved); + } else { + window.removeEventListener("beforeunload", warnUnsaved); + } +} + +function discardChanges() { + return !state.dirty || window.confirm("文档尚未保存,放弃更改?"); +} + +function setEditorInfo(doc) { + state.editorInfo = { revision: doc.revision, canUndo: doc.canUndo, canRedo: doc.canRedo }; + setDirty(doc.revision !== state.savedRevision); +} + +function openCreatePanel() { + if (document.body.hasAttribute("aria-busy")) { + return; + } + el.createForm.reset(); + el.createStatus.textContent = ""; + el.createPanel.showModal(); +} + +async function createDocument(event) { + event.preventDefault(); + if (document.body.hasAttribute("aria-busy") || !discardChanges()) { + return; + } + const title = el.createName.value.trim() || "未命名"; + const openSeq = ++state.openSeq; + setBusy(true, "正在新建文档", null, "正在新建文档"); + try { + await ensureWASM(); + if (openSeq !== state.openSeq) { + return; + } + const doc = await callWASM("ofdgoCreateDocument", title, Number(el.createWidth.value), Number(el.createHeight.value), state.renderAnnotations); + if (openSeq !== state.openSeq) { + return; + } + state.editing = true; + canvasEditor.clear(); + state.selectObjects = true; + setPan(false); + state.ofdBytes = null; + state.fileName = `${title.replace(/[\\/:*?"<>|]+/g, "_").replace(/\.ofd$/i, "")}.ofd`; + state.savedRevision = null; + setEditorInfo(doc); + el.createPanel.close(); + await openDocument({ doc, openSeq, skipAutoFonts: true, resetScroll: true }); + } catch (err) { + if (openSeq === state.openSeq) { + el.createStatus.textContent = err.message; + } + } finally { + if (openSeq === state.openSeq) { + setBusy(false); + updateControls(); + } + } +} + +async function openInsertPanel(text, item = null) { + if (!state.editing || document.body.hasAttribute("aria-busy")) { + return; + } + const openSeq = state.openSeq; + if (text && !item) { + await requestLocalFontsBeforeOpen(); + } + if (openSeq !== state.openSeq) { + return; + } + el.insertForm.reset(); + state.textObject = item; + if (item) { + state.insertFonts = []; + el.insertText.value = item.text; + el.insertSize.value = Number((item.size * 72 / 25.4).toPrecision(12)); + } + el.insertPanel.setAttribute("aria-label", item ? "修改文字" : text ? "添加文字" : "添加图片"); + el.insertSubmit.textContent = item ? "修改" : "添加"; + for (const [row, input] of [[el.insertTextRow, el.insertText], [el.insertFontRow, el.insertFont], [el.insertSizeRow, el.insertSize]]) { + row.hidden = !text; + input.disabled = !text; + } + el.insertImageRow.hidden = text; + el.insertImage.disabled = text; + updateInsertFonts(); + el.insertPanel.showModal(); + (text ? el.insertText : el.insertImage).focus(); +} + +function updateInsertFonts() { + const selected = state.insertFonts[Number(el.insertFont.value)]; + const key = (font) => font?.id || font?.postscriptName; + state.insertFonts = [...fontManager.userFonts.filter((font) => font.enabled), ...fontManager.catalog]; + if (state.textObject) { + state.insertFonts.unshift({ id: `embedded:${state.textObject.font}`, name: state.textObject.fontName, embedded: true }); + } + el.insertFont.replaceChildren(); + state.insertFonts.forEach((font, index) => { + const option = document.createElement("option"); + option.value = String(index); + option.textContent = font.fullName || font.name; + option.selected = key(font) === key(selected); + el.insertFont.append(option); + }); + el.insertSubmit.disabled = !el.insertTextRow.hidden && !state.insertFonts.length; + el.insertStatus.textContent = el.insertSubmit.disabled ? "尚未添加字体" : ""; +} + +async function insertObject(event) { + event.preventDefault(); + if (!state.editing || document.body.hasAttribute("aria-busy") || el.insertSubmit.disabled) { + return; + } + 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 x = Math.min(20, page.width / 10); + const y = Math.min(20, page.height / 10); + el.insertStatus.textContent = ""; + setBusy(true, item ? "正在修改文字" : text ? "正在添加文字" : "正在添加图片", null); + try { + let data = null; + if (text) { + const font = state.insertFonts[Number(el.insertFont.value)]; + if (!font.embedded) { + data = await fontManager.read(font); + } + } else { + data = new Uint8Array(await el.insertImage.files[0].arrayBuffer()); + } + if (openSeq !== state.openSeq) { + return; + } + const points = Number(el.insertSize.value); + 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); + if (openSeq !== state.openSeq) { + return; + } + setEditorInfo(doc); + el.insertPanel.close(); + canvasEditor.selectLast = !item; + 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; + el.viewerPanel.focus({ preventScroll: true }); + } + } catch (err) { + if (openSeq === state.openSeq) { + el.insertStatus.textContent = err.message; + } + } finally { + if (openSeq === state.openSeq) { + setBusy(false); + } + } +} + +async function changeDocument(name, item, ...args) { + if (!state.editing || document.body.hasAttribute("aria-busy")) { + return; + } + let openSeq = state.openSeq; + const { scrollLeft, scrollTop } = el.viewerPanel; + setBusy(true, "正在更新文档", null); + try { + const doc = await callWASM(name, ...(item ? [item.index, item.id, ...args] : [])); + if (openSeq !== state.openSeq) { + return; + } + 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 (openSeq === state.openSeq) { + el.viewerPanel.scrollLeft = scrollLeft; + el.viewerPanel.scrollTop = scrollTop; + el.viewerPanel.focus({ preventScroll: true }); + } + } catch (err) { + if (openSeq === state.openSeq) { + showError(err, false); + } + } finally { + if (openSeq === state.openSeq) { + setBusy(false); + } + } +} + async function openOFDFile() { if (document.body.hasAttribute("aria-busy")) { return; @@ -475,7 +775,7 @@ async function boot() { await ensureWASM(); let fontsRestored = true; try { - await restoreUserFonts(); + await fontManager.restore(); } catch { fontsRestored = false; } @@ -486,7 +786,7 @@ async function boot() { renderFontList(); updateControls(); updateLocalFontButton(); - await refreshLocalFontPermission(); + await fontManager.refreshPermission(); setStatus(fontsRestored ? STATUS.ready : "字体读取失败"); setBusy(false); if ("launchQueue" in window) { @@ -512,7 +812,7 @@ async function registerOffline() { const scope = new URL("./", location.href).href; let registration = await navigator.serviceWorker.getRegistration(scope); if (!registration || registration.scope !== scope) { - registration = await navigator.serviceWorker.register("./ofdgo.sw.js", { updateViaCache: "none" }); + registration = await navigator.serviceWorker.register("./ofdgo_work.js", { updateViaCache: "none" }); } const worker = registration.active || registration.installing || registration.waiting; await waitForWorker(worker, "activated"); @@ -611,9 +911,9 @@ async function refreshApplication() { if (document.body.hasAttribute("aria-busy")) { return; } - const temporaryFonts = state.userFonts.some((font) => font.source === "upload"); - const message = temporaryFonts ? "刷新后需重新打开文件\n未保存字体需重新添加" : "刷新后需重新打开文件"; - if ((state.ofdBytes || temporaryFonts) && !window.confirm(message)) { + 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)) { return; } setBusy(true, "正在刷新应用", null, "正在刷新应用"); @@ -637,6 +937,7 @@ async function refreshApplication() { const worker = registration.waiting || registration.active; await requestOffline(worker, "refresh"); await waitForWorker(worker, "activated"); + window.removeEventListener("beforeunload", warnUnsaved); location.reload(); }); } catch (err) { @@ -682,7 +983,7 @@ async function loadWASM() { state.ready = false; state.wasmExited = false; await new Promise((resolve, reject) => { - const worker = new Worker("./ofdgo.wasm.js"); + const worker = new Worker("./ofdgo_wasm.js"); wasmWorker = worker; const fail = (err) => { markWASMExited(wasmSeq, err); @@ -744,8 +1045,9 @@ function markWASMExited(wasmSeq = state.wasmSeq, err) { } wasmRequests.clear(); if (err) { - setStatus("引擎运行中断"); + setStatus(state.editing ? "引擎中断,未保存内容无法恢复" : "引擎运行中断"); } + updateControls(); scheduleWASMRecovery(); } @@ -794,6 +1096,9 @@ async function openOFD(file) { showError(new Error("选择 OFD 文件"), !state.doc); return; } + if (!discardChanges()) { + return; + } state.wasmRecoveries = 0; const openSeq = ++state.openSeq; setBusy(true, "正在读取文档", 10, STATUS.opening); @@ -804,6 +1109,14 @@ async function openOFD(file) { } state.ofdBytes = bytes; state.fileName = file.name || "ofdgo.ofd"; + state.editing = false; + state.editorInfo = null; + state.savedRevision = null; + canvasEditor.clear(); + setDirty(false); + el.createPanel.close(); + el.insertPanel.close(); + updateControls(); await openDocument({ pageIndex: 0, resetScroll: true, openSeq }); } catch (err) { if (openSeq === state.openSeq) { @@ -817,97 +1130,6 @@ function isOFDFile(file) { return /\.ofd$/i.test(file.name || ""); } -function openFontDatabase() { - if (!fontDatabase) { - fontDatabase = new Promise((resolve, reject) => { - const request = indexedDB.open(FONT_DATABASE, 1); - request.onupgradeneeded = () => { - const store = request.result.createObjectStore("fonts", { keyPath: "id", autoIncrement: true }); - store.createIndex("checksum", "checksum", { unique: true }); - }; - request.onsuccess = () => { - const db = request.result; - db.onversionchange = () => { - db.close(); - fontDatabase = null; - }; - resolve(db); - }; - request.onerror = () => reject(request.error); - }).catch((err) => { - fontDatabase = null; - throw err; - }); - } - return fontDatabase; -} - -async function fontTransaction(mode, action) { - const db = await openFontDatabase(); - return new Promise((resolve, reject) => { - const tx = db.transaction("fonts", mode); - let request; - tx.oncomplete = () => { - if (mode === "readwrite") { - fontChannel?.postMessage(null); - } - resolve(request?.result); - }; - tx.onabort = () => reject(tx.error || new Error("字体保存失败")); - try { - request = action(tx.objectStore("fonts")); - } catch (err) { - tx.abort(); - reject(err); - } - }); -} - -function listStoredFonts() { - return fontTransaction("readonly", (store) => store.getAll()); -} - -async function addStoredFonts(fonts) { - const records = new Map(); - for (const font of fonts) { - const digest = await crypto.subtle.digest("SHA-256", font.data); - const checksum = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); - if (!records.has(checksum)) { - records.set(checksum, { - name: font.name, - data: new Blob([font.data]), - enabled: font.enabled, - checksum, - }); - } - } - await fontTransaction("readwrite", (store) => { - for (const font of records.values()) { - const request = store.index("checksum").getKey(font.checksum); - request.onsuccess = () => { - if (request.result === undefined) { - store.add(font); - } - }; - } - }); -} - -function updateStoredFont(id, changes) { - return fontTransaction("readwrite", (store) => { - const request = store.get(id); - request.onsuccess = () => { - if (request.result) { - store.put({ ...request.result, ...changes }); - } - }; - }); -} - -function deleteStoredFont(id) { - return fontTransaction("readwrite", (store) => store.delete(id)); -} - async function openSelectedFonts(event) { if (document.body.hasAttribute("aria-busy")) { return; @@ -927,19 +1149,9 @@ async function openSelectedFonts(event) { for (let i = 0; i < files.length; i += 1) { setProgress(`正在读取字体 ${i + 1} / ${files.length}`, 10 + Math.round(i / files.length * 60)); const file = files[i]; - fonts.push(createFontRecord(file.name, new Uint8Array(await file.arrayBuffer()), "upload")); - } - let saved = true; - try { - await addStoredFonts(fonts); - } catch { - saved = false; - state.userFonts.push(...fonts); - } - const changed = saved ? await restoreUserFonts() : true; - if (saved && navigator.storage?.persist) { - navigator.storage.persist().catch(() => false); + fonts.push(fontManager.record(file.name, new Uint8Array(await file.arrayBuffer()), "upload")); } + const { saved, changed } = await fontManager.add(fonts); if (changed) { await applyFontChange(); } @@ -955,14 +1167,14 @@ async function loadLocalFonts() { if (document.body.hasAttribute("aria-busy")) { return; } - if (!canReadLocalFonts()) { + if (!fontManager.canReadLocal()) { setStatus("无法读取系统字体"); return; } setBusy(true, state.doc ? "正在匹配字体" : "正在请求授权", 12, state.doc ? STATUS.fonts : "正在请求授权"); try { await nextFrame(); - const available = await queryLocalFonts(); + const available = await fontManager.queryLocal(); if (!state.doc) { setStatus(available.length ? `字体授权完成 ${available.length} 个` : "暂无系统字体"); return; @@ -982,12 +1194,12 @@ async function loadLocalFonts() { } async function requestLocalFontsBeforeOpen() { - if (!canReadLocalFonts() || state.systemFontCatalogLoaded || state.systemFontPermission === "denied") { + if (!fontManager.canReadLocal() || fontManager.catalogLoaded || fontManager.permission === "denied") { return; } setBusy(true, "正在请求授权", 12, "正在请求授权"); try { - const available = await queryLocalFonts(); + const available = await fontManager.queryLocal(); setStatus(available.length ? `字体授权完成 ${available.length} 个` : "暂无系统字体"); } catch (err) { if (err && err.name === "NotAllowedError") { @@ -1000,30 +1212,13 @@ async function requestLocalFontsBeforeOpen() { } } -async function queryLocalFonts() { - try { - const available = await window.queryLocalFonts(); - state.systemFontCatalog = available; - state.systemFontCatalogLoaded = true; - state.systemFontPermission = "granted"; - updateFontPermissionHint(); - return available; - } catch (err) { - if (err && err.name === "NotAllowedError") { - state.systemFontPermission = "denied"; - updateFontPermissionHint(); - } - throw err; - } -} - async function autoLoadDocumentLocalFonts(openSeq) { - if (!state.doc?.fonts?.some((font) => !font.embedded) || !canReadLocalFonts() || state.systemFontPermission === "denied") { + if (!state.doc?.fonts?.some((font) => !font.embedded) || !fontManager.canReadLocal() || fontManager.permission === "denied") { return false; } setProgress("正在匹配字体", 62, STATUS.fonts); try { - const available = state.systemFontCatalogLoaded ? state.systemFontCatalog : await queryLocalFonts(); + const available = fontManager.catalogLoaded ? fontManager.catalog : await fontManager.queryLocal(); if (openSeq !== state.openSeq) { return false; } @@ -1041,14 +1236,14 @@ async function autoLoadDocumentLocalFonts(openSeq) { async function loadDocumentLocalFonts(available, openSeq = state.openSeq) { const docFonts = state.doc?.fonts || []; if (!docFonts.length) { - state.localFonts = []; + fontManager.localFonts = []; setStatus("暂无文档字体"); updateFontSummary(); renderFontList(); return false; } if (docFonts.every((font) => font.embedded)) { - state.localFonts = []; + fontManager.localFonts = []; setStatus("字体均为内嵌"); updateFontSummary(); renderFontList(); @@ -1063,19 +1258,13 @@ async function loadDocumentLocalFonts(available, openSeq = state.openSeq) { for (let i = 0; i < selected.length; i += 1) { setProgress(`正在读取字体 ${i + 1} / ${selected.length}`, 20 + Math.round(i / selected.length * 60)); const item = selected[i]; - const blob = await item.blob(); - if (openSeq !== state.openSeq) { - return false; - } - fonts.push(createFontRecord(localFontName(item), new Uint8Array(await blob.arrayBuffer()), "browser")); + const data = await fontManager.read(item); if (openSeq !== state.openSeq) { return false; } + fonts.push(fontManager.record(fontManager.localName(item), data, "browser")); } - if (openSeq !== state.openSeq) { - return false; - } - state.localFonts = fonts; + fontManager.localFonts = fonts; setStatus(fonts.length ? `字体加载完成 ${fonts.length} 个` : emptyStatus); updateFontSummary(); renderFontList(); @@ -1086,7 +1275,7 @@ async function selectLocalFonts(fonts) { const available = new Map(); for (const font of fonts) { const names = [ - localFontName(font), + fontManager.localName(font), font.postscriptName && `${font.postscriptName}.ttf`, font.family && `${[font.family, font.style].filter(Boolean).join(" ")}.ttf`, ]; @@ -1100,39 +1289,6 @@ async function selectLocalFonts(fonts) { return [...new Set(matched.map((name) => available.get(name)))]; } -async function fontData(fonts) { - const result = []; - for (const font of fonts) { - if (!font.enabled) { - continue; - } - if (font.data instanceof Blob) { - font.data = new Uint8Array(await font.data.arrayBuffer()); - } - result.push({ name: font.name, data: font.data }); - } - return result; -} - -async function restoreUserFonts() { - const stored = await listStoredFonts(); - const previous = new Map(state.userFonts.map((font) => [font.id, font])); - const fonts = stored.map((font) => ({ - ...font, - data: previous.get(font.id)?.checksum === font.checksum ? previous.get(font.id).data : font.data, - source: "stored", - })); - fonts.push(...state.userFonts.filter((font) => font.source === "upload")); - if (fonts.length === state.userFonts.length && fonts.every((font, index) => { - const old = state.userFonts[index]; - return font.id === old.id && font.name === old.name && font.enabled === old.enabled && font.checksum === old.checksum; - })) { - return false; - } - state.userFonts = fonts; - return true; -} - function scheduleFontSync() { state.fontSyncPending = true; syncUserFonts(); @@ -1146,7 +1302,7 @@ async function syncUserFonts() { state.fontSyncing = true; setBusy(true, "正在同步字体", null); try { - if (await restoreUserFonts()) { + if (await fontManager.restore()) { await applyFontChange(); } if (!state.doc) { @@ -1161,36 +1317,16 @@ async function syncUserFonts() { } function updateFontSummary() { - const fonts = fontRecords(); + const fonts = fontManager.records(); const total = fonts.length; const enabled = fonts.filter((font) => font.enabled).length; el.availableFontSummary.textContent = total ? `${enabled}/${total}` : "0"; } -function createFontRecord(name, data, source) { - state.fontSeq += 1; - return { - id: `${source}-${state.fontSeq}`, - name: name || "font.ttf", - data, - enabled: true, - source, - }; -} - -function fontRecords() { - return [...state.localFonts, ...state.userFonts]; -} - -function localFontName(font) { - const name = font.fullName || font.family || font.postscriptName || "local-font"; - return `${name}.ttf`; -} - async function applyFontChange(options = {}) { updateFontSummary(); renderFontList(); - if (!state.ofdBytes || !state.doc) { + if (!state.doc) { return; } await openDocument({ @@ -1209,7 +1345,7 @@ async function toggleAnnotations() { } state.renderAnnotations = !state.renderAnnotations; updateAnnotationButton(); - if (!state.ofdBytes || !state.doc) { + if (!state.doc) { return; } await openDocument({ @@ -1220,29 +1356,13 @@ async function toggleAnnotations() { }); } -function removeFont(id) { - state.localFonts = state.localFonts.filter((font) => font.id !== id); - state.userFonts = state.userFonts.filter((font) => font.id !== id); -} - async function changeFont(font, changes) { if (document.body.hasAttribute("aria-busy")) { return; } setBusy(true, "正在更新字体", null, STATUS.fonts); try { - if (font.source === "stored") { - if (changes) { - await updateStoredFont(font.id, changes); - } else { - await deleteStoredFont(font.id); - } - await restoreUserFonts(); - } else if (changes) { - Object.assign(font, changes); - } else { - removeFont(font.id); - } + await fontManager.change(font, changes); await applyFontChange(); if (!state.doc) { setStatus(STATUS.ready); @@ -1256,8 +1376,11 @@ async function changeFont(font, changes) { } function renderFontList() { + if (el.insertPanel.open) { + updateInsertFonts(); + } el.fontList.replaceChildren(); - const fonts = fontRecords(); + const fonts = fontManager.records(); if (!fonts.length) { const empty = document.createElement("div"); empty.className = "font-empty"; @@ -1336,40 +1459,18 @@ function fontSourceText(source) { } function updateLocalFontButton() { - const supported = canReadLocalFonts(); + const supported = fontManager.canReadLocal(); el.localFontButton.disabled = !supported; el.localFontButton.title = supported ? "读取系统字体" : "无法读取系统字体"; updateFontPermissionHint(); } function updateFontPermissionHint() { - el.fontPermissionHint.hidden = !canReadLocalFonts() || state.systemFontPermission === "granted"; -} - -function canReadLocalFonts() { - return typeof window.queryLocalFonts === "function"; -} - -async function refreshLocalFontPermission() { - if (!canReadLocalFonts() || !navigator.permissions?.query) { - updateFontPermissionHint(); - return; - } - try { - const permission = await navigator.permissions.query({ name: "local-fonts" }); - state.systemFontPermission = permission.state; - permission.onchange = () => { - state.systemFontPermission = permission.state; - updateFontPermissionHint(); - }; - } catch { - state.systemFontPermission = "prompt"; - } - updateFontPermissionHint(); + el.fontPermissionHint.hidden = !fontManager.canReadLocal() || fontManager.permission === "granted"; } async function openDocument(options = {}) { - if (!state.ofdBytes) { + if (!state.ofdBytes && !state.editing) { return; } const openSeq = options.openSeq || (state.openSeq += 1); @@ -1382,7 +1483,7 @@ async function openDocument(options = {}) { const resetLocalFonts = !options.skipAutoFonts; resetSearch(); if (resetLocalFonts) { - state.localFonts = []; + fontManager.localFonts = []; updateFontSummary(); renderFontList(); } @@ -1393,14 +1494,14 @@ async function openDocument(options = {}) { if (openSeq !== state.openSeq) { return; } - const fonts = !options.reuseSession || options.fontsChanged - ? await fontData(resetLocalFonts ? state.userFonts : fontRecords()) : null; + const fonts = !options.doc && (!options.reuseSession || options.fontsChanged) + ? await fontManager.files(resetLocalFonts ? fontManager.userFonts : fontManager.records()) : null; if (openSeq !== state.openSeq) { return; } - const doc = options.reuseSession + const doc = options.doc || (options.reuseSession ? await callWASM("ofdgoConfigure", fonts, state.renderAnnotations) - : await callWASM("ofdgoOpen", state.ofdBytes, fonts, state.renderAnnotations); + : await callWASM("ofdgoOpen", state.ofdBytes, fonts, state.renderAnnotations)); if (openSeq !== state.openSeq) { return; } @@ -1612,15 +1713,19 @@ async function updateExportRange() { } } -async function exportFile(whole, indices = null) { +async function exportFile(whole, indices = null, value = el.exportFormat.value) { if (!state.doc || document.body.hasAttribute("aria-busy")) { return; } - const format = exportFormatInfo(el.exportFormat.value); + const saving = value === "ofd"; + if (saving && !state.editing) { + return; + } + const format = saving ? { value: "ofd", label: "OFD", extension: "ofd", mime: "application/ofd" } : exportFormatInfo(value); if (!format) { return; } - const archive = whole && format.value !== "pdf" && format.value !== "txt"; + const archive = whole && !saving && format.value !== "pdf" && format.value !== "txt"; const label = archive ? "ZIP" : format.label; const extension = archive ? "zip" : format.extension; const mime = archive ? "application/zip" : format.mime; @@ -1639,7 +1744,7 @@ async function exportFile(whole, indices = null) { if (openSeq !== state.openSeq) { return; } - const result = whole + const result = saving ? await callWASM("ofdgoSaveDocument", file) : whole ? await callWASM("ofdgoExportDocument", format.value, dpi, indices, file) : await callWASM("ofdgoExportPage", pageIndex, format.value, dpi, file); if (openSeq !== state.openSeq) { @@ -1648,7 +1753,11 @@ async function exportFile(whole, indices = null) { if (result.blob) { downloadBytes(result.blob, result.mime, fileName); } - setStatus(`${result.label} 导出完成 ${formatBytes(result.size)}`); + if (saving) { + state.savedRevision = state.editorInfo.revision; + setDirty(false); + } + setStatus(`${result.label} ${saving ? "保存" : "导出"}完成 ${formatBytes(result.size)}`); } catch (err) { if (openSeq === state.openSeq) { if (err.name === "AbortError") { @@ -1980,6 +2089,9 @@ function mountPageSVG(index, page, openSeq = state.openSeq) { svg.classList.add("ofd-svg"); surface.replaceChildren(svg); surface.append(createTextLayer(page.text)); + if (state.editing) { + canvasEditor.mount(index, page, surface); + } for (const link of page.links) { let url; try { @@ -2298,7 +2410,8 @@ function layoutPageShell(shell, page) { surface.style.height = `${height}px`; const x = state.rotation === 90 || state.rotation === 180 ? viewWidth : 0; const y = state.rotation >= 180 ? viewHeight : 0; - surface.style.transform = `scale(${scale}) translate(${x}px, ${y}px) rotate(${state.rotation}deg)`; + surface.style.transform = `scale(${scale}) translate(${x}px, ${y}px) rotate(${state.rotation}deg)`; + surface.style.setProperty("--surface-scale", String(scale)); } } @@ -3230,6 +3343,7 @@ function rotatePages() { if (document.body.hasAttribute("aria-busy")) { return; } + canvasEditor.cancel(); state.rotation = (state.rotation + 90) % 360; clearRegionHighlights(); el.viewerPanel.classList.remove("single-page-fits-height"); @@ -3253,10 +3367,15 @@ function togglePan() { if (document.body.hasAttribute("aria-busy")) { return; } + setPan(!state.panMode); +} + +function setPan(enabled) { endPan(); - state.panMode = !state.panMode; + state.panMode = enabled; el.viewerPanel.classList.toggle("pan-mode", state.panMode); el.panButton.setAttribute("aria-pressed", String(state.panMode)); + updateEditorTools(); } function startPan(event) { @@ -3472,6 +3591,7 @@ function updateFitSpace() { function updateControls() { const hasDoc = Boolean(state.doc); + updateEditorTools(); const pageCount = state.doc ? state.doc.pageCount : 0; el.prevButton.disabled = !hasDoc || state.pageIndex <= 0; el.nextButton.disabled = !hasDoc || state.pageIndex >= pageCount - 1; @@ -3494,6 +3614,22 @@ function updateControls() { updateDPIControl(); } +function updateEditorTools() { + el.editorTools.hidden = !state.editing; + el.insertTextButton.disabled = el.insertImageButton.disabled = el.saveButton.disabled = !state.ready || state.exporting; + const enabled = state.editing && state.selectObjects && !state.panMode; + canvasEditor.setEnabled(enabled); + el.selectObjectButton.setAttribute("aria-pressed", String(enabled)); + updateObjectControls(canvasEditor.selected); + el.undoButton.disabled = !state.editorInfo?.canUndo || !state.ready || state.exporting; + el.redoButton.disabled = !state.editorInfo?.canRedo || !state.ready || state.exporting; +} + +function updateObjectControls(item) { + el.deleteObjectButton.disabled = !item || !state.ready || state.exporting; + el.editTextButton.disabled = !item || item.image || !state.ready || state.exporting; +} + function updateAnnotationButton() { el.annotationButton.setAttribute("aria-pressed", String(state.renderAnnotations)); el.annotationButton.title = state.renderAnnotations ? "关闭注解" : "开启注解"; @@ -3511,7 +3647,7 @@ async function callWASM(name, ...args) { return new Promise((resolve, reject) => { const id = ++wasmRequestID; wasmRequests.set(id, { resolve, reject, openSeq: state.openSeq }); - if (name === "ofdgoExportPage" || name === "ofdgoExportDocument" || name === "ofdgoExportAttachment") { + if (name === "ofdgoExportPage" || name === "ofdgoExportDocument" || name === "ofdgoExportAttachment" || name === "ofdgoSaveDocument") { state.exportRequestID = id; el.cancelExportButton.hidden = false; el.cancelExportButton.disabled = false; @@ -3540,6 +3676,9 @@ function showError(err, empty = !state.doc) { function setBusy(busy, text = "", percent = 0, status = "") { document.body.toggleAttribute("aria-busy", busy); + el.createForm.inert = busy; + el.insertForm.inert = busy; + el.editorTools.inert = busy; el.fontList.inert = busy; if (!busy) { el.progressPanel.hidden = true; diff --git a/assets/webui/ofdgo_edit.js b/assets/webui/ofdgo_edit.js new file mode 100644 index 0000000..6d93254 --- /dev/null +++ b/assets/webui/ofdgo_edit.js @@ -0,0 +1,204 @@ +const PX_PER_MM = 96 / 25.4; + +export function pagePoint(x, y, rect, page, rotation) { + const u = (x - rect.left) / rect.width; + const v = (y - rect.top) / rect.height; + const points = [[u, v], [v, 1 - u], [1 - u, 1 - v], [1 - v, u]]; + const point = points[rotation / 90]; + return { x: point[0] * page.width, y: point[1] * page.height }; +} + +export function objectTransform(box, page, dx, dy, corner = "") { + if (!corner) { + return { + x: Math.max(-box.x, Math.min(dx, Math.max(-box.x, page.width - box.x - box.width))), + y: Math.max(-box.y, Math.min(dy, Math.max(-box.y, page.height - box.y - box.height))), + scale: 1, + }; + } + const left = corner.includes("w"); + const top = corner.includes("n"); + const x = left ? -dx : dx; + const y = top ? -dy : dy; + const limit = Math.min( + (left ? box.x + box.width : page.width - box.x) / box.width, + (top ? box.y + box.height : page.height - box.y) / box.height, + ); + const scale = Math.max(Math.min(1 / Math.min(box.width, box.height), limit), Math.min(limit, + 1 + (x * box.width + y * box.height) / (box.width ** 2 + box.height ** 2))); + return { x: left ? box.width * (1 - scale) : 0, y: top ? box.height * (1 - scale) : 0, scale }; +} + +export class CanvasEditor { + constructor(viewer, options) { + this.viewer = viewer; + this.options = options; + this.enabled = false; + this.selected = null; + this.nodes = new WeakMap(); + viewer.addEventListener("pointerdown", (event) => this.start(event)); + viewer.addEventListener("pointermove", (event) => this.move(event)); + viewer.addEventListener("pointerup", (event) => this.end(event)); + viewer.addEventListener("pointercancel", () => this.cancel()); + viewer.addEventListener("lostpointercapture", () => this.cancel()); + viewer.addEventListener("keydown", (event) => this.keyDown(event)); + viewer.addEventListener("dblclick", () => { + const item = this.selected; + if (this.enabled && item && !item.image && !this.options.busy()) { + this.options.onEdit(item); + } + }); + viewer.addEventListener("scroll", () => this.cancel()); + window.addEventListener("blur", () => this.cancel()); + window.addEventListener("resize", () => this.cancel()); + } + + setEnabled(enabled) { + this.enabled = enabled; + this.viewer.classList.toggle("editing-select", enabled); + if (!enabled) { + this.clear(); + } + } + + mount(index, page, surface) { + const layer = document.createElement("div"); + layer.className = "edit-layer"; + for (const object of page.objects) { + const node = document.createElement("div"); + node.className = "edit-object"; + node.tabIndex = 0; + node.setAttribute("role", "button"); + node.setAttribute("aria-label", object.image ? "图片对象" : "文字对象"); + const item = { ...object, index, page: { width: page.width, height: page.height }, node, surface }; + this.nodes.set(node, item); + node.addEventListener("focus", () => this.select(item)); + this.place(item); + if (object.image) { + for (const corner of ["nw", "ne", "sw", "se"]) { + const handle = document.createElement("span"); + handle.className = `edit-handle edit-${corner}`; + handle.dataset.corner = corner; + node.append(handle); + } + } + layer.append(node); + if (this.selected?.index === index && this.selected.id === object.id) { + this.select(item); + } + } + surface.append(layer); + if (this.selectLast && layer.lastElementChild) { + this.selectLast = false; + this.select(this.nodes.get(layer.lastElementChild)); + } + } + + place(item, change = { x: 0, y: 0, scale: 1 }) { + Object.assign(item.node.style, { + left: `${(item.x + change.x) * PX_PER_MM}px`, top: `${(item.y + change.y) * PX_PER_MM}px`, + width: `${item.width * change.scale * PX_PER_MM}px`, height: `${item.height * change.scale * PX_PER_MM}px`, + }); + } + + select(item) { + this.selected?.node.classList.remove("selected"); + this.selected = item; + item?.node.classList.add("selected"); + this.options.onSelect(item); + } + + clear() { + this.cancel(); + this.selectLast = false; + this.select(null); + } + + start(event) { + if (!this.enabled || this.options.busy() || event.button !== 0 || this.drag) { + return; + } + const node = event.target.closest(".edit-object"); + if (!node) { + this.select(null); + return; + } + event.preventDefault(); + this.select(this.nodes.get(node)); + this.viewer.focus({ preventScroll: true }); + const item = this.selected; + this.drag = { + item, pointerID: event.pointerId, corner: event.target.dataset.corner || "", + rect: item.surface.getBoundingClientRect(), rotation: this.options.rotation(), + clientX: event.clientX, clientY: event.clientY, + }; + this.viewer.setPointerCapture(event.pointerId); + } + + move(event) { + const drag = this.drag; + if (!drag || drag.pointerID !== event.pointerId) { + return; + } + event.preventDefault(); + if (!drag.change && Math.hypot(event.clientX - drag.clientX, event.clientY - drag.clientY) < 3) { + return; + } + const from = pagePoint(drag.clientX, drag.clientY, drag.rect, drag.item.page, drag.rotation); + const to = pagePoint(event.clientX, event.clientY, drag.rect, drag.item.page, drag.rotation); + drag.change = objectTransform(drag.item, drag.item.page, to.x - from.x, to.y - from.y, drag.corner); + this.place(drag.item, drag.change); + } + + end(event) { + const drag = this.drag; + if (!drag || drag.pointerID !== event.pointerId) { + return; + } + this.move(event); + this.cancel(); + if (drag.change && (drag.change.x || drag.change.y || drag.change.scale !== 1)) { + this.options.onTransform(drag.item, drag.change); + } + } + + cancel() { + const drag = this.drag; + this.drag = null; + if (drag) { + this.place(drag.item); + if (this.viewer.hasPointerCapture(drag.pointerID)) { + this.viewer.releasePointerCapture(drag.pointerID); + } + } + } + + keyDown(event) { + if (!this.enabled || !this.selected || this.options.busy() || event.ctrlKey || event.metaKey || event.altKey || event.isComposing) { + return; + } + const directions = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }; + const edit = (event.key === "Enter" || event.key === "F2") && !this.selected.image; + if (event.key !== "Escape" && event.key !== "Delete" && !edit && !directions[event.key]) { + return; + } + event.preventDefault(); + event.stopPropagation(); + if (event.key === "Escape") { + this.drag ? this.cancel() : this.clear(); + } else if (event.key === "Delete") { + this.options.onDelete(this.selected); + } else if (edit && !this.drag) { + this.options.onEdit(this.selected); + } else if (!this.drag) { + const [x, y] = directions[event.key]; + const [dx, dy] = [[x, y], [y, -x], [-x, -y], [-y, x]][this.options.rotation() / 90]; + const step = event.shiftKey ? 10 : 1; + const change = objectTransform(this.selected, this.selected.page, + dx * step, dy * step); + if (change.x || change.y) { + this.options.onTransform(this.selected, change); + } + } + } +} diff --git a/assets/webui/ofdgo_font.js b/assets/webui/ofdgo_font.js new file mode 100644 index 0000000..7321006 --- /dev/null +++ b/assets/webui/ofdgo_font.js @@ -0,0 +1,240 @@ +const DATABASE = "ofdgo"; + +export class FontManager { + constructor({ onChange, onPermissionChange }) { + this.userFonts = []; + this.localFonts = []; + this.catalog = []; + this.catalogLoaded = false; + this.permission = "prompt"; + this.sequence = 0; + this.database = null; + this.onPermissionChange = onPermissionChange; + this.channel = typeof BroadcastChannel === "function" ? new BroadcastChannel(DATABASE) : null; + if (this.channel) { + this.channel.onmessage = onChange; + } + } + + openDatabase() { + if (!this.database) { + this.database = new Promise((resolve, reject) => { + const request = indexedDB.open(DATABASE, 1); + request.onupgradeneeded = () => { + const store = request.result.createObjectStore("fonts", { keyPath: "id", autoIncrement: true }); + store.createIndex("checksum", "checksum", { unique: true }); + }; + request.onsuccess = () => { + const db = request.result; + db.onversionchange = () => { + db.close(); + this.database = null; + }; + resolve(db); + }; + request.onerror = () => reject(request.error); + }).catch((err) => { + this.database = null; + throw err; + }); + } + return this.database; + } + + async transaction(mode, action) { + const db = await this.openDatabase(); + return new Promise((resolve, reject) => { + const tx = db.transaction("fonts", mode); + let request; + tx.oncomplete = () => { + if (mode === "readwrite") { + this.channel?.postMessage(null); + } + resolve(request?.result); + }; + tx.onabort = () => reject(tx.error || new Error("字体保存失败")); + try { + request = action(tx.objectStore("fonts")); + } catch (err) { + tx.abort(); + reject(err); + } + }); + } + + listStored() { + return this.transaction("readonly", (store) => store.getAll()); + } + + async store(fonts) { + const records = new Map(); + for (const font of fonts) { + const digest = await crypto.subtle.digest("SHA-256", font.data); + const checksum = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); + if (!records.has(checksum)) { + records.set(checksum, { + name: font.name, + data: new Blob([font.data]), + enabled: font.enabled, + checksum, + }); + } + } + await this.transaction("readwrite", (store) => { + for (const font of records.values()) { + const request = store.index("checksum").getKey(font.checksum); + request.onsuccess = () => { + if (request.result === undefined) { + store.add(font); + } + }; + } + }); + } + + updateStored(id, changes) { + return this.transaction("readwrite", (store) => { + const request = store.get(id); + request.onsuccess = () => { + if (request.result) { + store.put({ ...request.result, ...changes }); + } + }; + }); + } + + deleteStored(id) { + return this.transaction("readwrite", (store) => store.delete(id)); + } + + async queryLocal() { + try { + const available = await window.queryLocalFonts(); + this.catalog = available; + this.catalogLoaded = true; + this.permission = "granted"; + this.onPermissionChange(); + return available; + } catch (err) { + if (err && err.name === "NotAllowedError") { + this.permission = "denied"; + this.onPermissionChange(); + } + throw err; + } + } + + async files(fonts) { + const result = []; + for (const font of fonts) { + if (!font.enabled) { + continue; + } + result.push({ name: font.name, data: await this.read(font) }); + } + return result; + } + + async read(font) { + if (font.blob) { + const blob = await font.blob(); + return new Uint8Array(await blob.arrayBuffer()); + } + if (font.data instanceof Blob) { + font.data = new Uint8Array(await font.data.arrayBuffer()); + } + return font.data; + } + + async add(fonts) { + let saved = true; + try { + await this.store(fonts); + } catch { + saved = false; + this.userFonts.push(...fonts); + } + const changed = saved ? await this.restore() : true; + if (saved && navigator.storage?.persist) { + navigator.storage.persist().catch(() => false); + } + return { saved, changed }; + } + + async change(font, changes) { + if (font.source === "stored") { + if (changes) { + await this.updateStored(font.id, changes); + } else { + await this.deleteStored(font.id); + } + await this.restore(); + } else if (changes) { + Object.assign(font, changes); + } else { + this.localFonts = this.localFonts.filter((item) => item.id !== font.id); + this.userFonts = this.userFonts.filter((item) => item.id !== font.id); + } + } + + async restore() { + const stored = await this.listStored(); + const previous = new Map(this.userFonts.map((font) => [font.id, font])); + const fonts = stored.map((font) => ({ + ...font, + data: previous.get(font.id)?.checksum === font.checksum ? previous.get(font.id).data : font.data, + source: "stored", + })); + fonts.push(...this.userFonts.filter((font) => font.source === "upload")); + if (fonts.length === this.userFonts.length && fonts.every((font, index) => { + const old = this.userFonts[index]; + return font.id === old.id && font.name === old.name && font.enabled === old.enabled && font.checksum === old.checksum; + })) { + return false; + } + this.userFonts = fonts; + return true; + } + + record(name, data, source) { + this.sequence += 1; + return { + id: `${source}-${this.sequence}`, + name: name || "font.ttf", + data, + enabled: true, + source, + }; + } + + records() { + return [...this.localFonts, ...this.userFonts]; + } + + localName(font) { + const name = font.fullName || font.family || font.postscriptName || "local-font"; + return `${name}.ttf`; + } + + canReadLocal() { + return typeof window.queryLocalFonts === "function"; + } + + async refreshPermission() { + if (!this.canReadLocal() || !navigator.permissions?.query) { + this.onPermissionChange(); + return; + } + try { + const permission = await navigator.permissions.query({ name: "local-fonts" }); + this.permission = permission.state; + permission.onchange = () => { + this.permission = permission.state; + this.onPermissionChange(); + }; + } catch { + this.permission = "prompt"; + } + this.onPermissionChange(); + } +} diff --git a/assets/webui/ofdgo.wasm.js b/assets/webui/ofdgo_wasm.js similarity index 97% rename from assets/webui/ofdgo.wasm.js rename to assets/webui/ofdgo_wasm.js index a4f2447..d16033a 100644 --- a/assets/webui/ofdgo.wasm.js +++ b/assets/webui/ofdgo_wasm.js @@ -7,7 +7,7 @@ self.onmessage = ({ data }) => { exports.get(data.id)?.abort(); return; } - if (data.name === "ofdgoExportPage" || data.name === "ofdgoExportDocument" || data.name === "ofdgoExportAttachment") { + if (data.name === "ofdgoExportPage" || data.name === "ofdgoExportDocument" || data.name === "ofdgoExportAttachment" || data.name === "ofdgoSaveDocument") { exports.set(data.id, new AbortController()); } pending = pending.then(() => handleMessage(data)); diff --git a/assets/webui/ofdgo.sw.js b/assets/webui/ofdgo_work.js similarity index 97% rename from assets/webui/ofdgo.sw.js rename to assets/webui/ofdgo_work.js index 15dd24a..054f38e 100644 --- a/assets/webui/ofdgo.sw.js +++ b/assets/webui/ofdgo_work.js @@ -1,6 +1,6 @@ const CACHE_PREFIX = `ofdgo:${self.registration.scope}:app:`; const META_CACHE = `${CACHE_PREFIX}meta`; -const ASSETS = ["./", "ofdgo.css", "ofdgo.js", "ofdgo.json", "ofdgo.svg", "ofdgo.sw.js", "ofdgo.wasm", "ofdgo.wasm.js", "wasm_exec.js"] +const ASSETS = ["./", "ofdgo.css", "ofdgo.js", "ofdgo.json", "ofdgo.svg", "ofdgo.wasm", "ofdgo_edit.js", "ofdgo_font.js", "ofdgo_wasm.js", "ofdgo_work.js", "wasm_exec.js"] .map((path) => new URL(path, self.registration.scope).href); const CLIENT_PREFIX = new URL("__client/", self.registration.scope).href; diff --git a/cmd/webui/webui.go b/cmd/webui/webui.go index abb02e9..3b3795a 100644 --- a/cmd/webui/webui.go +++ b/cmd/webui/webui.go @@ -40,13 +40,13 @@ func main() { func serveWebUI() http.Handler { files := http.FileServerFS(webuiassets.FS) checksum := webuiassets.Checksum() - serviceWorker, _ := webuiassets.FS.ReadFile("ofdgo.sw.js") + serviceWorker, _ := webuiassets.FS.ReadFile("ofdgo_work.js") serviceWorker = append([]byte(fmt.Sprintf("const BUNDLE_CHECKSUM = %q;\n", checksum)), serviceWorker...) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-store") w.Header().Set("X-OFDGo-Checksum", checksum) - if r.URL.Path == "/ofdgo.sw.js" { - http.ServeContent(w, r, "ofdgo.sw.js", time.Time{}, bytes.NewReader(serviceWorker)) + if r.URL.Path == "/ofdgo_work.js" { + http.ServeContent(w, r, "ofdgo_work.js", time.Time{}, bytes.NewReader(serviceWorker)) return } files.ServeHTTP(w, r) diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index 3e5fe27..7247cdd 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -18,10 +18,13 @@ package webui import ( "bufio" + "bytes" "context" "encoding/json" "fmt" + "image" "io" + "math" "syscall/js" "github.com/xiaoqidun/ofdgo" @@ -92,6 +95,15 @@ func RunWASM() { registerExportCallback("ofdgoExportDocument", exportDocument) registerExportCallback("ofdgoExportAttachment", exportAttachment) registerCallback("ofdgoMatchFontFiles", matchFontFiles) + registerCallback("ofdgoCreateDocument", createDocument) + registerCallback("ofdgoInsertText", insertText) + registerCallback("ofdgoUpdateText", updateText) + registerCallback("ofdgoInsertImage", insertImage) + registerCallback("ofdgoTransformObject", transformObject) + registerCallback("ofdgoDeleteObject", deleteObject) + registerCallback("ofdgoUndo", func([]js.Value) (any, error) { return restoreEditor(false) }) + registerCallback("ofdgoRedo", func([]js.Value) (any, error) { return restoreEditor(true) }) + registerExportCallback("ofdgoSaveDocument", saveDocument) select {} } @@ -163,6 +175,7 @@ func openDocument(args []js.Value) (any, error) { return nil, err } renderAnnotations := args[2].Bool() + currentEditor = nil if currentSession != nil { _ = currentSession.Close() currentSession = nil @@ -268,6 +281,10 @@ func renderPage(args []js.Value) (any, error) { if err != nil { return nil, err } + objects, err := editorObjects(args[0].Int(), text) + if err != nil { + return nil, err + } links := make([]any, len(page.Links)) for i, link := range page.Links { links[i] = map[string]any{ @@ -287,16 +304,17 @@ func renderPage(args []js.Value) (any, error) { images[i] = map[string]any{"name": image.Name, "mime": image.MIME, "bytes": bytesToJS(image.Data)} } return successResult(map[string]any{ - "index": page.Index, - "number": page.Number, - "id": page.ID, - "width": page.Width, - "height": page.Height, - "svg": page.SVG, - "links": links, - "fonts": fonts, - "images": images, - "text": string(textData), + "index": page.Index, + "number": page.Number, + "id": page.ID, + "width": page.Width, + "height": page.Height, + "svg": page.SVG, + "links": links, + "fonts": fonts, + "images": images, + "text": string(textData), + "objects": objects, }), nil } @@ -491,3 +509,306 @@ func encodeResult(result apiResult) string { } return string(data) } + +// currentEditor 当前新建文档 +var currentEditor *ofdgo.Editor + +// editorInfo 创作文档信息与操作状态 +type editorInfo struct { + DocumentInfo + Revision uint64 `json:"revision"` + CanUndo bool `json:"canUndo"` + CanRedo bool `json:"canRedo"` +} + +// editorSummary 获取当前创作文档状态 +// 返回: editorInfo 文档信息 +func editorSummary() editorInfo { + return editorInfo{currentSession.Summary(), currentEditor.Revision(), currentEditor.CanUndo(), currentEditor.CanRedo()} +} + +// restoreEditor 撤销或重做并更新预览 +// 入参: redo 是否重做 +// 返回: any 文档信息, error 错误信息 +func restoreEditor(redo bool) (any, error) { + if currentEditor == nil { + return nil, fmt.Errorf("no document is being created") + } + apply := currentEditor.Undo + if redo { + apply = currentEditor.Redo + } + if !apply() { + return editorSummary(), nil + } + return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) +} + +// editorObjects 提供创作画布的对象命中区域 +// 入参: index 页面索引, text 页面文字 +// 返回: []any 对象区域, error 错误信息 +func editorObjects(index int, text *ofdgo.PageText) ([]any, error) { + objects := []any{} + if currentEditor == nil { + return objects, nil + } + page, err := currentSession.pageContent(index) + if err != nil { + return nil, err + } + textBoxes := make(map[string]ofdgo.Box) + textValues := make(map[string]string) + for _, run := range text.Runs { + textValues[run.ID] += run.Text + for _, box := range run.Boxes { + if box.W <= 0 || box.H <= 0 { + continue + } + prev, exists := textBoxes[run.ID] + if !exists { + textBoxes[run.ID] = box + continue + } + x, y := math.Min(prev.X, box.X), math.Min(prev.Y, box.Y) + textBoxes[run.ID] = ofdgo.Box{X: x, Y: y, W: math.Max(prev.X+prev.W, box.X+box.W) - x, H: math.Max(prev.Y+prev.H, box.Y+box.H) - y} + } + } + fonts, err := currentSession.Reader.Fonts() + if err != nil { + return nil, err + } + fontNames := make(map[string]string, len(fonts)) + for _, font := range fonts { + fontNames[font.ID] = font.FontName + } + for _, layer := range page.Content.Layer { + for _, object := range layer.Objects { + var id string + var box ofdgo.Box + switch object.Type { + case "TextObject": + id = object.TextObject.ID + box = textBoxes[id] + case "ImageObject": + id = object.ImageObject.ID + box, err = ofdgo.ParseBox(object.ImageObject.Boundary) + if err != nil { + return nil, err + } + default: + continue + } + if box.W > 0 && box.H > 0 { + item := map[string]any{"id": id, "image": object.Type == "ImageObject", "x": box.X, "y": box.Y, "width": box.W, "height": box.H} + if object.Type == "TextObject" { + item["text"], item["font"], item["fontName"], item["size"] = textValues[id], object.TextObject.Font, fontNames[object.TextObject.Font], object.TextObject.Size + } + objects = append(objects, item) + } + } + } + return objects, nil +} + +// transformObject 移动文字或等比缩放图片 +// 入参: args 页码、对象标识、位移和缩放比例 +// 返回: any 文档信息, error 错误信息 +func transformObject(args []js.Value) (any, error) { + if currentEditor == nil { + return nil, fmt.Errorf("no document is being created") + } + page, id := args[0].Int(), args[1].String() + object, err := currentEditor.Object(page, id) + if err != nil { + return nil, err + } + scale := args[4].Float() + var boundary *string + switch object.Type { + case "TextObject": + if scale != 1 { + return nil, fmt.Errorf("text resizing is not supported") + } + boundary = &object.TextObject.Boundary + case "ImageObject": + boundary = &object.ImageObject.Boundary + object.ImageObject.CTM = "" + default: + return nil, fmt.Errorf("unsupported object type %q", object.Type) + } + box, err := ofdgo.ParseBox(*boundary) + if err != nil { + return nil, err + } + *boundary = fmt.Sprintf("%g %g %g %g", box.X+args[2].Float(), box.Y+args[3].Float(), box.W*scale, box.H*scale) + if err := currentEditor.UpdateObject(page, id, object); err != nil { + return nil, err + } + return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) +} + +// deleteObject 删除创作画布中选中的对象 +// 入参: args 页码和对象标识 +// 返回: any 文档信息, error 错误信息 +func deleteObject(args []js.Value) (any, error) { + if currentEditor == nil { + return nil, fmt.Errorf("no document is being created") + } + if err := currentEditor.DeleteObject(args[0].Int(), args[1].String()); err != nil { + return nil, err + } + return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) +} + +// createDocument 新建单页文档 +// 入参: args 标题、纸张宽高和注解设置 +// 返回: any 文档信息, error 错误信息 +func createDocument(args []js.Value) (any, error) { + editor := ofdgo.NewEditor() + editor.Info.Title = args[0].String() + if _, err := editor.AddPage(args[1].Float(), args[2].Float()); err != nil { + return nil, err + } + editor.SetHistoryLimit(100) + return previewEditor(editor, args[3].Bool()) +} + +// previewEditor 通过内存快照更新预览,不生成中间压缩包 +// 入参: editor 新建文档, annotations 是否显示注解 +// 返回: any 文档信息, error 错误信息 +func previewEditor(editor *ofdgo.Editor, annotations bool) (any, error) { + reader, err := editor.Reader() + if err != nil { + return nil, err + } + session, err := newSession(reader, OpenOptions{RenderAnnotations: annotations}) + if err != nil { + return nil, err + } + if currentSession != nil { + _ = currentSession.Close() + } + currentSession = session + currentEditor = editor + return editorSummary(), nil +} + +// updateText 修改文字内容、字体和字号,空字体数据沿用对象字体 +// 入参: args 页码、对象标识、内容、字体数据和字号 +// 返回: any 文档信息, error 错误信息 +func updateText(args []js.Value) (any, error) { + if currentEditor == nil { + return nil, fmt.Errorf("no document is being created") + } + page, id := args[0].Int(), args[1].String() + object, err := currentEditor.Object(page, id) + if err != nil { + return nil, err + } + fontID := object.TextObject.Font + if !args[3].IsNull() { + data, err := bytesFromJS(args[3]) + if err != nil { + return nil, err + } + fontID, err = currentEditor.AddFont(FontFile{Data: data}, 0) + if err != nil { + return nil, err + } + } + if err := currentEditor.UpdateText(page, id, args[2].String(), fontID, args[4].Float()); err != nil { + return nil, err + } + return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) +} + +// insertText 使用选定字体创建文字对象 +// 入参: args 页码、内容、字体数据、横纵坐标和字号 +// 返回: any 文档信息, error 错误信息 +func insertText(args []js.Value) (any, error) { + if currentEditor == nil { + return nil, fmt.Errorf("no document is being created") + } + data, err := bytesFromJS(args[2]) + if err != nil { + return nil, err + } + fontID, err := currentEditor.AddFont(FontFile{Data: data}, 0) + if err != nil { + return nil, err + } + page := args[0].Int() + content, err := currentSession.pageContent(page) + if err != nil { + return nil, err + } + box, err := currentSession.pageBox(page, content) + if err != nil { + return nil, err + } + x, y := args[3].Float(), args[4].Float() + box = ofdgo.Box{X: x, Y: y, W: box.W - x, H: box.H - y} + if _, err := currentEditor.AddText(page, box, args[1].String(), fontID, args[5].Float()); err != nil { + return nil, err + } + return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) +} + +// insertImage 按原始比例创建图片对象 +// 入参: args 页码、图片数据、横纵坐标和宽度 +// 返回: any 文档信息, error 错误信息 +func insertImage(args []js.Value) (any, error) { + if currentEditor == nil { + return nil, fmt.Errorf("no document is being created") + } + data, err := bytesFromJS(args[1]) + if err != nil { + return nil, err + } + resourceID, err := currentEditor.AddImage(data) + if err != nil { + return nil, err + } + config, _, err := image.DecodeConfig(bytes.NewReader(data)) + if err != nil { + return nil, err + } + width := args[4].Float() + box := ofdgo.Box{X: args[2].Float(), Y: args[3].Float(), W: width, H: width * float64(config.Height) / float64(config.Width)} + content, err := currentSession.pageContent(args[0].Int()) + if err != nil { + return nil, err + } + pageBox, err := currentSession.pageBox(args[0].Int(), content) + if err != nil { + return nil, err + } + if height := pageBox.H - box.Y*2; box.H > height { + box.W *= height / box.H + box.H = height + } + object := ofdgo.GraphicObject{Type: "ImageObject", ImageObject: ofdgo.ImageObject{ + ResourceID: resourceID, Boundary: fmt.Sprintf("%g %g %g %g", box.X, box.Y, box.W, box.H), + }} + if _, err := currentEditor.AddObject(args[0].Int(), object); err != nil { + return nil, err + } + return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) +} + +// saveDocument 分块写出新建文档 +// 入参: args 数据写出回调 +// 返回: any 保存结果, error 错误信息 +func saveDocument(args []js.Value) (any, error) { + if currentEditor == nil { + return nil, fmt.Errorf("no document is being created") + } + writer := bufio.NewWriterSize(exportWriter{write: args[0]}, 1<<20) + if _, err := currentEditor.WriteTo(writer); err != nil { + return nil, err + } + if err := writer.Flush(); err != nil { + return nil, err + } + return successResult(map[string]any{"mime": "application/ofd", "label": "OFD"}), nil +} diff --git a/internal/webui/webui.go b/internal/webui/webui.go index 1726c53..6fa4c9e 100644 --- a/internal/webui/webui.go +++ b/internal/webui/webui.go @@ -219,6 +219,13 @@ func Open(data []byte, opts OpenOptions) (*Session, error) { if err != nil { return nil, err } + return newSession(reader, opts) +} + +// newSession 从文档快照创建阅读会话 +// 入参: reader 文档读取器, opts 打开选项 +// 返回: *Session 文档会话, error 错误信息 +func newSession(reader *ofdgo.Reader, opts OpenOptions) (*Session, error) { doc, err := reader.Doc() if err != nil { reader.Close() diff --git a/ofdgo_editor.go b/ofdgo_editor.go index 36e45cc..93c6a5b 100644 --- a/ofdgo_editor.go +++ b/ofdgo_editor.go @@ -23,6 +23,7 @@ import ( "fmt" "image" "math" + "reflect" "slices" "strconv" "strings" @@ -35,13 +36,18 @@ import ( // Editor 新建OFD文档,长度单位为毫米,页面索引从0开始,实例需串行使用 // Info可修改文档元数据,通过方法管理页面、对象和资源,不修改已有OFD文件 type Editor struct { - Info DocInfo - pages []PageContent - resources []editorResource - fonts map[string]*font.SFNT - images map[string]bool - resourceID map[editorResourceKey]string - maxID int + Info DocInfo + pages []PageContent + resources []editorResource + fonts map[string]*font.SFNT + images map[string]bool + resourceID map[editorResourceKey]string + maxID int + history []editorChange + historyIndex int + historyLimit int + revision uint64 + serial uint64 } // editorResource 文档内嵌资源 @@ -124,7 +130,9 @@ func (e *Editor) AddPage(width, height float64) (int, error) { Area: PageArea{PhysicalBox: fmt.Sprintf("0 0 %s %s", ofdNumber(width), ofdNumber(height))}, Content: Content{Layer: []Layer{{ID: e.nextID(), Type: "Body"}}}, }) - return len(e.pages) - 1, nil + index := len(e.pages) - 1 + e.recordPageAddition(index) + return index, nil } // CopyPage 复制页面并追加到文档末尾,分配新标识,复用字体和图片资源 @@ -164,16 +172,24 @@ func (e *Editor) CopyPage(index int) (int, error) { } } e.pages = append(e.pages, page) - return len(e.pages) - 1, nil + index = len(e.pages) - 1 + e.recordPageAddition(index) + return index, nil } // DeletePage 删除页面,不回收资源或复用标识,删除后页面索引随之变化 // 入参: index 页面索引 // 返回: error 错误信息 func (e *Editor) DeletePage(index int) error { - if _, err := e.page(index); err != nil { + page, err := e.page(index) + if err != nil { return err } + if change := e.recordChange(); change != nil { + saved := copyEditorPage(*page) + change.undo = func(e *Editor) { e.pages = slices.Insert(e.pages, index, copyEditorPage(saved)) } + change.redo = func(e *Editor) { e.pages = slices.Delete(e.pages, index, index+1) } + } e.pages = slices.Delete(e.pages, index, index+1) return nil } @@ -188,13 +204,14 @@ func (e *Editor) MovePage(from, to int) error { if _, err := e.page(to); err != nil { return err } - page := e.pages[from] - if from < to { - copy(e.pages[from:to], e.pages[from+1:to+1]) - } else { - copy(e.pages[to+1:from+1], e.pages[to:from]) + if from == to { + return nil + } + moveEditorItem(e.pages, from, to) + if change := e.recordChange(); change != nil { + change.undo = func(e *Editor) { moveEditorItem(e.pages, to, from) } + change.redo = func(e *Editor) { moveEditorItem(e.pages, from, to) } } - e.pages[to] = page return nil } @@ -209,7 +226,15 @@ func (e *Editor) ResizePage(index int, width, height float64) error { if !finite(width) || !finite(height) || width <= 0 || height <= 0 { return fmt.Errorf("page dimensions must be finite and positive") } - page.Area.PhysicalBox = fmt.Sprintf("0 0 %s %s", ofdNumber(width), ofdNumber(height)) + before, after := page.Area.PhysicalBox, fmt.Sprintf("0 0 %s %s", ofdNumber(width), ofdNumber(height)) + if before == after { + return nil + } + page.Area.PhysicalBox = after + if change := e.recordChange(); change != nil { + change.undo = func(e *Editor) { e.pages[index].Area.PhysicalBox = before } + change.redo = func(e *Editor) { e.pages[index].Area.PhysicalBox = after } + } return nil } @@ -337,6 +362,17 @@ func (e *Editor) AddObject(page int, object GraphicObject) (string, error) { } e.maxID++ content.Content.Layer[0].Objects = append(content.Content.Layer[0].Objects, object) + if change := e.recordChange(); change != nil { + index := len(content.Content.Layer[0].Objects) - 1 + change.undo = func(e *Editor) { + layer := &e.pages[page].Content.Layer[0] + layer.Objects = slices.Delete(layer.Objects, index, index+1) + } + change.redo = func(e *Editor) { + layer := &e.pages[page].Content.Layer[0] + layer.Objects = slices.Insert(layer.Objects, index, object) + } + } return id, nil } @@ -355,7 +391,7 @@ func (e *Editor) Object(page int, id string) (GraphicObject, error) { // 入参: page 页面索引, id 对象标识, object 新内容,忽略其ID且不引用调用方的可变数据 // 返回: error 错误信息 func (e *Editor) UpdateObject(page int, id string, object GraphicObject) error { - layer, index, err := e.findObject(page, id) + _, index, err := e.findObject(page, id) if err != nil { return err } @@ -363,7 +399,7 @@ func (e *Editor) UpdateObject(page int, id string, object GraphicObject) error { if err != nil { return err } - layer.Objects[index] = object + e.replaceObject(page, index, object) return nil } @@ -375,6 +411,17 @@ func (e *Editor) DeleteObject(page int, id string) error { if err != nil { return err } + if change := e.recordChange(); change != nil { + object := layer.Objects[index] + change.undo = func(e *Editor) { + layer := &e.pages[page].Content.Layer[0] + layer.Objects = slices.Insert(layer.Objects, index, object) + } + change.redo = func(e *Editor) { + layer := &e.pages[page].Content.Layer[0] + layer.Objects = slices.Delete(layer.Objects, index, index+1) + } + } layer.Objects = slices.Delete(layer.Objects, index, index+1) return nil } @@ -390,16 +437,32 @@ func (e *Editor) MoveObject(page int, id string, to int) error { if to < 0 || to >= len(layer.Objects) { return fmt.Errorf("object index %d out of range", to) } - object := layer.Objects[from] - if from < to { - copy(layer.Objects[from:to], layer.Objects[from+1:to+1]) - } else { - copy(layer.Objects[to+1:from+1], layer.Objects[to:from]) + if from == to { + return nil + } + moveEditorItem(layer.Objects, from, to) + if change := e.recordChange(); change != nil { + change.undo = func(e *Editor) { moveEditorItem(e.pages[page].Content.Layer[0].Objects, to, from) } + change.redo = func(e *Editor) { moveEditorItem(e.pages[page].Content.Layer[0].Objects, from, to) } } - layer.Objects[to] = object return nil } +// replaceObject 提交已校验的对象并记录前后内容 +// 入参: page 页面索引, index 对象索引, object 新内容 +func (e *Editor) replaceObject(page, index int, object GraphicObject) { + layer := &e.pages[page].Content.Layer[0] + before := layer.Objects[index] + if reflect.DeepEqual(before, object) { + return + } + layer.Objects[index] = object + if change := e.recordChange(); change != nil { + change.undo = func(e *Editor) { e.pages[page].Content.Layer[0].Objects[index] = before } + change.redo = func(e *Editor) { e.pages[page].Content.Layer[0].Objects[index] = object } + } +} + // findObject 查找正文图层中的对象 // 入参: page 页面索引, id 对象标识 // 返回: *Layer 所属图层, int 对象索引, error 错误信息 @@ -585,7 +648,7 @@ func (e *Editor) UpdateText(page int, id, value, fontID string, size float64) er if err != nil { return err } - layer.Objects[index] = object + e.replaceObject(page, index, object) return nil } diff --git a/ofdgo_editor_history.go b/ofdgo_editor_history.go new file mode 100644 index 0000000..5fbdf79 --- /dev/null +++ b/ofdgo_editor_history.go @@ -0,0 +1,142 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ofdgo + +import "slices" + +// editorChange 页面或对象操作及其修订标识 +type editorChange struct { + undo func(*Editor) + redo func(*Editor) + before uint64 + after uint64 +} + +// SetHistoryLimit 设置撤销和重做记录的总上限,默认关闭,非正数关闭并释放记录 +// 缩减上限时优先保留最近的撤销记录,不影响当前文档及修订标识 +// 记录页面和对象操作,不包含Info的直接修改及字体、图片注册,资源继续共享 +// 入参: limit 最大记录数 +func (e *Editor) SetHistoryLimit(limit int) { + e.historyLimit = max(0, limit) + if e.historyLimit == 0 { + e.history = nil + e.historyIndex = 0 + return + } + if len(e.history) > e.historyLimit { + start := max(0, e.historyIndex-e.historyLimit) + end := min(len(e.history), start+e.historyLimit) + e.history = slices.Clone(e.history[start:end]) + e.historyIndex -= start + } +} + +// CanUndo 判断是否有可撤销的操作 +// 返回: bool 是否可以撤销 +func (e *Editor) CanUndo() bool { + return e.historyIndex > 0 +} + +// CanRedo 判断是否有可重做的操作 +// 返回: bool 是否可以重做 +func (e *Editor) CanRedo() bool { + return e.historyIndex < len(e.history) +} + +// Undo 撤销最近一次页面或对象操作,保留资源和已分配的标识 +// 返回: bool 是否执行了撤销 +func (e *Editor) Undo() bool { + if !e.CanUndo() { + return false + } + e.historyIndex-- + change := e.history[e.historyIndex] + change.undo(e) + e.revision = change.before + return true +} + +// Redo 重做最近一次撤销的操作,新的有效修改会清除重做记录 +// 返回: bool 是否执行了重做 +func (e *Editor) Redo() bool { + if !e.CanRedo() { + return false + } + change := e.history[e.historyIndex] + change.redo(e) + e.historyIndex++ + e.revision = change.after + return true +} + +// Revision 获取页面和对象的当前修订标识,撤销或重做时恢复对应标识 +// Info的直接修改和资源注册不计入修订,标识仅在当前Editor实例内有效 +// 返回: uint64 修订标识 +func (e *Editor) Revision() uint64 { + return e.revision +} + +// recordChange 分配修订标识并为已完成的有效修改预留记录 +// 返回: *editorChange 记录,未启用历史时为nil +func (e *Editor) recordChange() *editorChange { + e.serial++ + change := editorChange{before: e.revision, after: e.serial} + e.revision = e.serial + if e.historyLimit == 0 { + return nil + } + clear(e.history[e.historyIndex:]) + e.history = e.history[:e.historyIndex] + if len(e.history) == e.historyLimit { + copy(e.history, e.history[1:]) + e.history = e.history[:len(e.history)-1] + } + e.history = append(e.history, change) + e.historyIndex = len(e.history) + return &e.history[e.historyIndex-1] +} + +// recordPageAddition 记录新增或复制的页面 +// 入参: index 页面索引 +func (e *Editor) recordPageAddition(index int) { + if change := e.recordChange(); change != nil { + page := copyEditorPage(e.pages[index]) + change.undo = func(e *Editor) { e.pages = slices.Delete(e.pages, index, index+1) } + change.redo = func(e *Editor) { e.pages = slices.Insert(e.pages, index, copyEditorPage(page)) } + } +} + +// copyEditorPage 复制页面容器,共享经校验且按替换方式更新的对象数据 +// 入参: page 页面 +// 返回: PageContent 独立页面容器 +func copyEditorPage(page PageContent) PageContent { + page.Content.Layer = slices.Clone(page.Content.Layer) + for i := range page.Content.Layer { + page.Content.Layer[i].Objects = slices.Clone(page.Content.Layer[i].Objects) + } + return page +} + +// moveEditorItem 按目标索引移动页面或对象 +// 入参: items 页面或对象序列, from 原索引, to 目标索引 +func moveEditorItem[T any](items []T, from, to int) { + item := items[from] + if from < to { + copy(items[from:to], items[from+1:to+1]) + } else { + copy(items[to+1:from+1], items[to:from]) + } + items[to] = item +}