From af8b64e7485bb06544e9494661b074450eb0d7c6 Mon Sep 17 00:00:00 2001 From: xiaoqidun Date: Tue, 27 Oct 2026 15:08:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E9=98=85=E8=AF=BB=E7=BC=96=E8=BE=91):=20?= =?UTF-8?q?=E5=AE=8C=E5=96=84=E5=90=8E=E5=8F=B0=E5=88=B7=E6=96=B0=E3=80=81?= =?UTF-8?q?=E9=80=89=E5=8F=96=E4=B8=8E=E6=96=87=E5=AD=97=E7=BC=96=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/webui/ofdgo.js | 80 +++++++++++------- assets/webui/ofdgo_edit.js | 166 +++++++++++++++++++++++-------------- internal/webui/webui.go | 54 +++++++----- ofdgo_rich_text.go | 55 ++++++++++++ 4 files changed, 246 insertions(+), 109 deletions(-) diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index 3f64316..6647d11 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -42,7 +42,7 @@ let credentialRequest = null; let documentDialogs = null; let cancelPageTouch = null; -const workspace = { tabs: [], active: null, sequence: 0, nextID: 0, switching: false, closing: false, objectClipboard: null }; +const workspace = { tabs: [], active: null, sequence: 0, nextID: 0, switching: false, closing: false, objectClipboard: null, textClipboard: null }; function createResourceManager() { return { kind: "Font", scope: "", views: new Map(), items: [], filteredItems: [], start: -1, end: -1, selected: null, references: [], referenceOffset: 0, targets: [], pick: null, busy: false, sequence: 0, url: null, fileAction: null }; @@ -125,6 +125,8 @@ function createDocumentState() { searchMatches: [], searchIndex: -1, searchRunning: false, + searchNext: 0, + searchPaused: false, searchTruncated: false, navigationScroll: new Map(), fontSyncPending: false, @@ -703,7 +705,10 @@ el.editorTools.addEventListener("scroll", () => fontPicker.position()); const canvasEditor = new CanvasEditor(el.viewerPanel, { textControls: [el.textFont, el.textFontToggle, el.textFontAdd, el.textFontList, el.textSize, el.textColor, textBackground, ...textFormatButtons, el.undoButton, el.redoButton, el.saveButton, el.saveAsButton, el.fontInput, document.querySelector("#textReflow")], keepTextFocus: () => fontPicker.open || el.closePanel.open || canvasEditor.input?.fontDialog, - textClipboardScope: () => `${state.wasmSeq}:${state.fileName}:${state.doc?.documents?.[state.doc.documentIndex || 0]?.root}:${state.doc?.documentIndex || 0}:${state.doc?.documentVersion || ""}`, + textClipboardScope: () => `${documentTab().id}:${state.wasmSeq}:${state.fileName}:${state.doc?.documents?.[state.doc.documentIndex || 0]?.root}:${state.doc?.documentIndex || 0}:${state.doc?.documentVersion || ""}`, + getTextClipboard: () => workspace.textClipboard, + setTextClipboard: clipboard => { workspace.textClipboard = clipboard; }, + scrollBy: (x, y) => { el.viewerPanel.scrollLeft += x; pageView.scrollViewerBy(y); }, busy: () => document.body.hasAttribute("data-busy"), rotation: () => state.rotation, canInsert: (index) => pageCan("insert", index), @@ -2782,10 +2787,10 @@ function detachDocumentView() { tab.view = { anchor: state.doc ? pageView.scaleAnchor() : null, navigation: el.navigationTabs.querySelector('[data-selected="true"]'), navigationTop: el.navigationContent.scrollTop, metaTop: el.metaPanel.scrollTop, tool: canvasEditor.tool, selection: selection ? {index: selection.index, ids: (selection.items || [selection]).map(item => item.id)} : canvasEditor.pendingSelection, - richClipboard: canvasEditor.richClipboard, search: el.searchInput.value }; + search: el.searchInput.value }; tab.view.searchStatus = state.searchRunning ? "搜索已暂停" : el.searchStatus.textContent; if (state.searchRunning) { - state.searchQuery = ""; + state.searchPaused = true; state.searchSeq++; state.searchRunning = false; } @@ -2855,7 +2860,6 @@ function attachDocumentTab(tab) { el.searchResults.replaceChildren(); renderSearchResults(); el.ofdInput.value = ""; - canvasEditor.richClipboard = tab.view?.richClipboard || null; canvasEditor.pendingSelection = tab.view?.selection || null; documentDialogs?.setOwner(tab); workspace.resourceView = resourceElements.Panel.open ? resourceManager : null; @@ -2904,6 +2908,15 @@ async function activateDocumentTab(tab, closing = false) { } } +async function refreshDocumentTab(tab) { + tab.refreshPending = true; + updateWorkspaceDirty(); + if (isForegroundDocument(tab)) { + advanceDocumentSequence(); + await mountDocumentTab(tab); + } +} + async function mountDocumentTab(tab) { if (!isForegroundDocument(tab)) return; const state = tab.state; @@ -2933,11 +2946,19 @@ async function mountDocumentTab(tab) { tab.configurePending = false; } if (!isForegroundDocument(tab) || openSeq !== state.openSeq) return; + if (tab.refreshPending) { + state.objectClipboardToken = state.styleClipboard = null; + state.pageSelection.clear(); + state.pageSelectionAnchor = null; + state.documentSelection = null; + canvasEditor.pendingSelection = null; + } await openDocument({ doc: state.wasmExited ? undefined : state.doc, pageIndex: state.pageIndex, fitMode: state.fitMode || "free", scale: state.scale, - skipAutoFonts: !tab.initialLoad, keepSearch: true, keepCache: true, restoreTab: !tab.initialLoad, resetScroll: Boolean(tab.initialLoad), openSeq }); + skipAutoFonts: !tab.initialLoad, keepSearch: !tab.refreshPending, keepCache: !tab.refreshPending, restoreTab: !tab.initialLoad, resetScroll: Boolean(tab.initialLoad), openSeq }); if (!isForegroundDocument(tab) || openSeq !== state.openSeq) return; tab.configurePending = false; tab.initialLoad = false; + tab.refreshPending = false; if (view?.anchor) pageView.restoreScaleAnchor(view.anchor); showNavigation(view?.navigation || el.pagesTab); el.navigationContent.scrollTop = view?.navigationTop || 0; @@ -2957,6 +2978,7 @@ async function mountDocumentTab(tab) { setStatus(tab.status); setBusy(Boolean(tab.opening || tab.editTask || tab.state.exporting), tab.progress?.text || "", tab.progress?.percent ?? null, tab.status, tab); if (documentDialogs) await resumeDocumentPanels(tab); + if (isForegroundDocument(tab) && state.searchPaused && !tab.busy) searchDocument(); } async function releaseDocumentTab(tab) { @@ -3266,11 +3288,9 @@ async function switchReadingVersion(id) { state.pageCache.clear(); state.pageIndex = 0; tab.view = null; - tab.refreshPending = true; - updateWorkspaceDirty(); + await refreshDocumentTab(tab); return true; } - canvasEditor.richClipboard = null; canvasEditor.clear(); canvasEditor.setTool(""); updateTextFonts(null, true); @@ -3490,12 +3510,10 @@ async function switchDocument(index) { state.pageCache.clear(); state.pageIndex = 0; tab.view = null; - tab.refreshPending = true; - updateWorkspaceDirty(); + await refreshDocumentTab(tab); if (nativeHost) await nativeHost.release(source); return; } - canvasEditor.richClipboard = null; canvasEditor.clear(); canvasEditor.setTool(""); updateTextFonts(null, true); @@ -3598,7 +3616,7 @@ async function toggleEditor() { state.conversionWarnings = []; state.objectClipboardToken = state.styleClipboard = null; state.pageCache.clear(); - tab.refreshPending = true; + await refreshDocumentTab(tab); return; } openSeq = advanceDocumentSequence(); @@ -3610,7 +3628,6 @@ async function toggleEditor() { state.conversionWarnings = []; state.objectClipboardToken = null; state.styleClipboard = null; - canvasEditor.richClipboard = null; canvasEditor.clear(); pageView.setPan(false); await openDocument({ doc, openSeq, skipAutoFonts: true, pageIndex, fitMode, scale, @@ -3943,9 +3960,9 @@ async function editCanvasObject(item, point = null) { await face.load(); if (!isForegroundDocument(tab) || openSeq !== state.openSeq) return; let rich = null; - if (item.richText) rich = await loadRichText(item, face, tab); + if (item.richText) rich = await loadRichText(item, face, tab, data.bytes); else if (!item.scoped && layoutKnown) { - try { rich = await loadRichText(item, face, tab); } catch {} + try { rich = await loadRichText(item, face, tab, data.bytes); } catch {} } if (!isForegroundDocument(tab) || openSeq !== state.openSeq) return; const source = rich || layoutKnown ? null : await callWASM("ofdgoPreviewText", item.index, item.id, item.text); @@ -4112,21 +4129,25 @@ function currentTextStyle() { return canvasEditor.input?.style ? { ...text, ...canvasEditor.input.style } : text; } -async function loadRichText(item, firstFace, tab = documentTab()) { +async function loadRichText(item, firstFace, tab = documentTab(), firstData = null) { const wasmSeq = tab.state.wasmSeq; const callWASM = (name, ...args) => { if (tab.closed || wasmSeq !== tab.state.wasmSeq) throw new Error("文档已更改"); return callTabWASM(tab, name, ...args); }; const model = item.richText || await callWASM("ofdgoGetRichText", item.index, item.id); + const remaining = firstData ? { ...model, runs: model.runs.filter(run => run.format.font !== item.font) } : model; + const fontData = remaining.runs.length ? await callWASM("ofdgoRichTextFonts", JSON.stringify(remaining)) : []; + const resources = new Map(fontData.map(font => [font.key, font.data])); + if (firstData) resources.set(item.font, firstData); const faces = new Map(); for (const id of new Set(model.runs.map(run => run.format.font))) { - const data = id === item.font && firstFace ? null : (await callWASM("ofdgoEditorFont", id)).bytes; - const face = data ? await new FontFace(`ofdgo-rich-${id}`, data, fontFaceStyle(data)).load() : firstFace; + const data = id === item.font && firstFace ? null : resources.get(id); + const face = data ? await new FontFace(`ofdgo-rich-${crypto.randomUUID()}`, data, fontFaceStyle(data)).load() : firstFace; if (tab.closed || wasmSeq !== tab.state.wasmSeq) throw new Error("文档已更改"); faces.set(id, face); } - return { model, faces }; + return { model, faces, resources }; } async function ensureRichInput() { @@ -4482,8 +4503,7 @@ async function insertObject(event) { pageCapabilities: doc.pageCapabilities, editWarnings: doc.editWarnings || []}; state.dirty = state.packageDirty || state.encryptionDirty || doc.revision !== state.savedRevision; state.pageCache.clear(); - tab.refreshPending = true; - updateWorkspaceDirty(); + await refreshDocumentTab(tab); return; } el.insertPanel.close(); @@ -4701,7 +4721,6 @@ async function changeDocument(name, item, ...args) { const originalDocument = state.doc; const before = editorLocation(), revision = state.editorInfo.revision; const restoring = name === "ofdgoUndo" || name === "ofdgoRedo"; - if (restoring) canvasEditor.richClipboard = null; const { scrollLeft, scrollTop } = el.viewerPanel; const importing = name === "ofdgoImportPages" || name === "ofdgoImportImages" || name === "ofdgoImportDocuments"; const changingFont = name === "ofdgoChangeFontBinding"; @@ -4755,8 +4774,7 @@ async function changeDocument(name, item, ...args) { pageCapabilities: doc.pageCapabilities, editWarnings: doc.editWarnings || []}; tab.state.dirty = state.packageDirty || state.encryptionDirty || doc.revision !== state.savedRevision; tab.state.pageCache.clear(); - tab.refreshPending = true; - updateWorkspaceDirty(); + await refreshDocumentTab(tab); } return; } @@ -4789,7 +4807,6 @@ async function changeDocument(name, item, ...args) { state.documentManagement.key = documentEntryKey(nextDocument); } state.objectClipboardToken = state.styleClipboard = null; - canvasEditor.richClipboard = null; state.pageSelection.clear(); state.pageSelectionAnchor = null; state.pageMultiSelect = false; @@ -10813,6 +10830,8 @@ function resetSearch(clearInput = true) { for (const [id, request] of wasmRequests) if (request.tab === documentTab() && request.name === "ofdgoSearchPage") cancelOperation(id); state.searchTruncated = false; state.searchRunning = false; + state.searchNext = 0; + state.searchPaused = false; state.searchSeq += 1; state.searchQuery = ""; state.searchMatches = []; @@ -10868,11 +10887,11 @@ async function searchDocument() { return; } const query = el.searchInput.value.trim(); - if (query && query === state.searchQuery) { + if (query && query === state.searchQuery && (state.searchRunning || state.searchTruncated || state.searchNext >= state.doc.pageCount)) { await selectSearchMatch(state.searchIndex + 1); return; } - resetSearch(false); + if (query !== state.searchQuery) resetSearch(false); if (!query) { return; } @@ -10882,13 +10901,15 @@ async function searchDocument() { const pageCount = state.doc.pageCount; const target = state; target.searchRunning = true; + target.searchPaused = false; try { - for (let page = 0; page < pageCount; page += 1) { + for (let page = target.searchNext; page < pageCount; page += 1) { const matches = await callWASM("ofdgoSearchPage", page, query, SEARCH_MATCH_LIMIT - target.searchMatches.length + 1); if (target !== state || seq !== target.searchSeq || openSeq !== target.openSeq) { return; } el.searchStatus.textContent = `正在搜索文字 · ${page + 1} / ${pageCount} 页`; + target.searchNext = page + 1; if (!matches?.length) { continue; } @@ -10905,7 +10926,6 @@ async function searchDocument() { } } catch (err) { if (target === state && seq === target.searchSeq && openSeq === target.openSeq) { - state.searchQuery = ""; el.searchStatus.textContent = "搜索失败"; showError(err, false); } diff --git a/assets/webui/ofdgo_edit.js b/assets/webui/ofdgo_edit.js index eb6a7bc..a9d2cb6 100644 --- a/assets/webui/ofdgo_edit.js +++ b/assets/webui/ofdgo_edit.js @@ -72,7 +72,7 @@ function sliceRuns(runs, start, end) { } export class RichTextSession { - constructor(editor, model, faces = new Map(), imports = []) { + constructor(editor, model, faces = new Map(), imports = [], resources = new Map()) { this.editor = editor; this.editing = editor.input; this.model = structuredClone(model); @@ -81,11 +81,12 @@ export class RichTextSession { this.faces = faces; for (const face of faces.values()) document.fonts.add(face); this.imports = new Map(imports.map(font => [font.key, font.data])); + this.resources = resources; this.choices = new Map(); if (this.editing.item.fontChoice && imports.length === 1) this.choices.set(imports[0].key, this.editing.item.fontChoice); this.typing = { ...(model.runs[0]?.format || richTextModel(this.editing.item).runs[0].format) }; this.caret = [0, 0]; - this.entries = [this.snapshot()]; + this.entries = [{ typing: { ...this.typing }, caret: [...this.caret] }]; this.index = 0; this.selectionListener = () => { if (editor.input !== this.editing || this.rendering || this.editing.composing) return; @@ -122,15 +123,15 @@ export class RichTextSession { const runs = sliceRuns(this.model.runs, start, end); return Object.fromEntries(FORMAT_KEYS.map(key => [key, runs.every(run => run.format[key] === runs[0]?.format[key]) ? runs[0]?.format[key] : undefined])); } - snapshot() { return { model: structuredClone(this.model), typing: { ...this.typing }, caret: [...this.caret] }; } remember() { this.selection(); this.entries[this.index].caret = [...this.caret]; } - record() { - const entry = this.snapshot(), old = this.entries[this.index]; - const changed = JSON.stringify(entry.model) !== JSON.stringify(old.model); + record(change) { + const entry = { change, typing: { ...this.typing }, caret: [...this.caret] }, old = this.entries[this.index]; + const changed = change && JSON.stringify(change.before) !== JSON.stringify(change.after); if (!changed && JSON.stringify(entry.typing) === JSON.stringify(old.typing)) return; + if (!changed) entry.change = null; this.entries.splice(++this.index, Infinity, entry); if (changed) this.preview(); } @@ -138,8 +139,13 @@ export class RichTextSession { if (this.editing.composing || this.editing.saving) return; const entry = this.entries[this.index + direction]; if (!entry) return; + const change = (direction < 0 ? this.entries[this.index] : entry).change; + if (change) { + const removed = direction < 0 ? change.after : change.before, added = direction < 0 ? change.before : change.after; + const end = change.start + removed.reduce((length, run) => length + run.text.length, 0); + this.model.runs = normalizeRuns([...sliceRuns(this.model.runs, 0, change.start), ...added, ...sliceRuns(this.model.runs, end, this.text.length)]); + } this.index += direction; - this.model = structuredClone(entry.model); this.typing = { ...entry.typing }; this.caret = [...entry.caret]; this.render(); @@ -168,6 +174,7 @@ export class RichTextSession { if (this.editing.composing || this.editing.saving) return; this.remember(); const [start, end] = this.selection(); + const before = sliceRuns(this.model.runs, start, end); this.caret = this.caret[0] > this.caret[1] ? [end, start] : [start, end]; if (start !== end) this.model.runs = normalizeRuns([ ...sliceRuns(this.model.runs, 0, start), @@ -175,18 +182,19 @@ export class RichTextSession { ...sliceRuns(this.model.runs, end, this.text.length), ]); this.typing = { ...this.typing, ...values }; - this.record(); + this.record({ start, before, after: sliceRuns(this.model.runs, start, end) }); this.render(); this.editor.options.onTextChange(); } replace(runs) { this.remember(); const [start, end] = this.selection(); + const before = sliceRuns(this.model.runs, start, end); this.model.runs = normalizeRuns([...sliceRuns(this.model.runs, 0, start), ...runs, ...sliceRuns(this.model.runs, end, this.text.length)]); this.caret = [start + runs.reduce((length, run) => length + run.text.length, 0)]; this.caret.push(this.caret[0]); if (runs.length) this.typing = { ...runs.at(-1).format }; - this.record(); + this.record({ start, before, after: sliceRuns(this.model.runs, start, this.caret[0]) }); this.render(); this.editor.options.onTextChange(); } @@ -206,11 +214,12 @@ export class RichTextSession { while (end > start && nextEnd > start && previous[end - 1] === value[nextEnd - 1]) { end--; nextEnd--; } if (end < previous.length && /[\uDC00-\uDFFF]/.test(previous[end])) { end++; nextEnd++; } } + const removed = sliceRuns(this.model.runs, start, end); this.model.runs = normalizeRuns([...sliceRuns(this.model.runs, 0, start), { text: value.slice(start, nextEnd), format: { ...this.typing } }, ...sliceRuns(this.model.runs, end, previous.length)]); this.caret = caret; this.beforeCaret = null; - this.record(); + this.record({ start, before: removed, after: sliceRuns(this.model.runs, start, nextEnd) }); this.render(); } copy(event, cut = false) { @@ -218,25 +227,41 @@ export class RichTextSession { const [start, end] = this.selection(); if (start === end || !event.clipboardData) return; const runs = sliceRuns(this.model.runs, start, end), text = runs.map(run => run.text).join(""); - this.editor.richClipboard = { token: crypto.randomUUID(), runs, text, scope: this.editor.options.textClipboardScope?.(), - fonts: new Map(this.imports), faces: new Map(this.faces), choices: new Map(this.choices) }; + const keys = new Set(runs.map(run => run.format.font)); + const fonts = new Map([...keys].map(key => [key, this.imports.get(key) || this.resources.get(key)]).filter(([, data]) => data)); + const clipboard = { token: crypto.randomUUID(), runs, text, scope: this.editor.options.textClipboardScope?.(), + fonts, faces: new Map([...this.faces].filter(([key]) => keys.has(key))), choices: new Map([...this.choices].filter(([key]) => keys.has(key))) }; + if (this.editor.options.setTextClipboard) this.editor.options.setTextClipboard(clipboard); + else this.editor.richClipboard = clipboard; event.preventDefault(); event.clipboardData.setData("text/plain", text); - event.clipboardData.setData(RICH_CLIPBOARD, this.editor.richClipboard.token); + event.clipboardData.setData(RICH_CLIPBOARD, clipboard.token); if (cut) this.replace([]); } - paste(event) { + async paste(event) { event.preventDefault(); if (this.editing.composing || this.editing.saving) return; const text = event.clipboardData.getData("text/plain").replace(/\r\n?/g, "\n"); - const copy = this.editor.richClipboard; - const internal = copy && copy.scope === this.editor.options.textClipboardScope?.() && event.clipboardData.getData(RICH_CLIPBOARD) === copy.token && text === copy.text; + const copy = this.editor.options.getTextClipboard ? this.editor.options.getTextClipboard() : this.editor.richClipboard; + const sameScope = copy?.scope === this.editor.options.textClipboardScope?.(); + const internal = copy && (sameScope || copy.runs.every(run => copy.fonts.has(run.format.font))) + && event.clipboardData.getData(RICH_CLIPBOARD) === copy.token && text === copy.text; + let runs; if (internal) { - for (const [key, data] of copy.fonts) this.imports.set(key, data); - for (const [key, face] of copy.faces) { this.faces.set(key, face); document.fonts.add(face); } - this.choices = new Map([...copy.choices, ...(this.choices || [])]); + const keys = new Map(copy.runs.map(run => [run.format.font, sameScope ? run.format.font : `temp-font-${crypto.randomUUID()}`])); + let faces = copy.faces; + if (!sameScope) { + const original = this.model.runs, caret = this.selection(); + faces = new Map(await Promise.all([...copy.fonts].map(async ([key, data]) => + [key, await new FontFace(`ofdgo-paste-${keys.get(key)}`, data, fontFaceStyle(data)).load()]))); + if (this.editor.input !== this.editing || this.editing.composing || this.editing.saving || this.model.runs !== original || this.selection().join() !== caret.join()) return; + } + for (const [key, data] of copy.fonts) this.imports.set(keys.get(key), data); + for (const [key, face] of faces) { this.faces.set(keys.get(key), face); document.fonts.add(face); } + for (const [key, choice] of copy.choices) this.choices.set(keys.get(key), choice); + runs = copy.runs.map(run => ({ text: run.text, format: { ...run.format, font: keys.get(run.format.font) } })); } - this.replace(internal ? structuredClone(copy.runs) : [{ text, format: { ...this.typing } }]); + this.replace(runs || [{ text, format: { ...this.typing } }]); } setFont(face, data, key) { key ||= `temp-font-${crypto.randomUUID()}`; @@ -247,35 +272,49 @@ export class RichTextSession { } render() { this.rendering = true; - const input = this.editing.input, fragment = document.createDocumentFragment(), positions = []; - let paragraph = document.createElement("div"), offset = 0; - fragment.append(paragraph); + const input = this.editing.input, lines = [[]], positions = []; for (const run of this.model.runs) { - const pieces = run.text.split("\n"), format = run.format; + const pieces = run.text.split("\n"); for (let i = 0; i < pieces.length; i++) { - if (i) { paragraph = document.createElement("div"); fragment.append(paragraph); offset++; } - const span = document.createElement("span"), node = document.createTextNode(pieces[i]); - const face = this.faces.get(format.font) || this.editing.face; - Object.assign(span.style, { fontFamily: `"${face.family}"`, fontSize: `${format.size * PX_PER_MM}px`, color: format.color, - fontWeight: format.bold ? "700" : face.weight || "400", fontStyle: format.italic ? "italic" : face.style || "normal", - textDecoration: [format.underline && "underline", format.strikeout && "line-through"].filter(Boolean).join(" ") || "none", - textShadow: format.shadow ? "0.06em 0.06em 0 #808080" : "none", backgroundColor: format.background || "transparent" }); - span.append(node); paragraph.append(span); - positions.push({ node, start: offset, end: offset + pieces[i].length }); - offset += pieces[i].length; + if (i) lines.push([]); + lines.at(-1).push({ text: pieces[i], format: run.format }); } } - if (!positions.length) { - const node = document.createTextNode(""); paragraph.append(node); - positions.push({ node, start: 0, end: 0 }); - } + if (!lines[0].length) lines[0].push({ text: "", format: this.typing }); + const previous = this.renderedLines || []; + let offset = 0; + this.renderedLines = lines.map((parts, index) => { + const key = JSON.stringify(parts.map(part => [part.text, part.format, this.faces.get(part.format.font)?.family])); + let paragraph = previous[index]?.key === key && previous[index].node.parentNode === input ? previous[index].node : null; + if (!paragraph) { + paragraph = document.createElement("div"); + for (const {text, format} of parts) { + const span = document.createElement("span"), face = this.faces.get(format.font) || this.editing.face; + Object.assign(span.style, { fontFamily: `"${face.family}"`, fontSize: `${format.size * PX_PER_MM}px`, color: format.color, + fontWeight: format.bold ? "700" : face.weight || "400", fontStyle: format.italic ? "italic" : face.style || "normal", + textDecoration: [format.underline && "underline", format.strikeout && "line-through"].filter(Boolean).join(" ") || "none", + textShadow: format.shadow ? "0.06em 0.06em 0 #808080" : "none", backgroundColor: format.background || "transparent" }); + span.append(document.createTextNode(text)); + paragraph.append(span); + } + if (!paragraph.textContent) { + paragraph.style.fontSize = paragraph.firstElementChild.style.fontSize; + paragraph.append(document.createElement("br")); + } + } + if (input.childNodes[index] !== paragraph) input.insertBefore(paragraph, input.childNodes[index] || null); + if (index) offset++; + for (const span of paragraph.children) { + if (span.nodeName !== "SPAN") continue; + const node = span.firstChild; + positions.push({ node, start: offset, end: offset + node.textContent.length }); + offset += node.textContent.length; + } + return { key, node: paragraph }; + }); + while (input.childNodes.length > lines.length) input.lastChild.remove(); input.style.fontSize = this.model.runs.length ? "0px" : `${this.typing.size * PX_PER_MM}px`; input.style.caretColor = this.typing.color || "#000000"; - for (const line of fragment.children) if (!line.textContent) { - line.style.fontSize = line.querySelector("span")?.style.fontSize || `${this.typing.size * PX_PER_MM}px`; - line.append(document.createElement("br")); - } - input.replaceChildren(fragment); this.editor.fitTextInput(); this.editing.range = null; input.focus({ preventScroll: true }); @@ -1082,6 +1121,7 @@ export class CanvasEditor { } if (drag.marquee) { this.moveMarquee(event, drag); + this.autoScroll(drag); return; } if (!drag.change && !drag.box && Math.hypot(event.clientX - drag.clientX, event.clientY - drag.clientY) < 3) { @@ -1140,12 +1180,12 @@ export class CanvasEditor { scroll() { const drag = this.drag; - if (!drag?.item || drag.corner || !drag.pointer) { + if (!drag || !drag.item && !drag.marquee || drag.corner || !drag.pointer) { this.cancel(); return; } - drag.origin ||= pagePoint(drag.clientX, drag.clientY, drag.rect, drag.item.page, drag.rotation); - drag.rect = drag.item.surface.getBoundingClientRect(); + drag.origin ||= pagePoint(drag.clientX, drag.clientY, drag.rect, drag.item?.page || drag.page, drag.rotation); + drag.rect = (drag.item?.surface || drag.surface).getBoundingClientRect(); this.move(drag.pointer); } @@ -1161,8 +1201,10 @@ export class CanvasEditor { const elapsed = Math.min(32, time - (drag.scrollTime ?? time - 16)); drag.scrollTime = time; const left = viewer.scrollLeft, top = viewer.scrollTop; - viewer.scrollLeft += speed(pointer.clientX, rect.left, viewer.clientWidth) * elapsed; - viewer.scrollTop += speed(pointer.clientY, rect.top, viewer.clientHeight) * elapsed; + const x = speed(pointer.clientX, rect.left, viewer.clientWidth) * elapsed; + const y = speed(pointer.clientY, rect.top, viewer.clientHeight) * elapsed; + if (this.options.scrollBy) this.options.scrollBy(x, y); + else { viewer.scrollLeft += x; viewer.scrollTop += y; } if (viewer.scrollLeft !== left || viewer.scrollTop !== top) this.scroll(); else drag.scrollTime = null; }); @@ -1393,7 +1435,7 @@ export class CanvasEditor { } moveMarquee(event, drag) { - const from = pagePoint(drag.clientX, drag.clientY, drag.rect, drag.page, drag.rotation); + const from = drag.origin ||= pagePoint(drag.clientX, drag.clientY, drag.rect, drag.page, drag.rotation); const to = pagePoint(event.clientX, event.clientY, drag.rect, drag.page, drag.rotation); const box = { x: Math.min(from.x, to.x), y: Math.min(from.y, to.y), width: Math.abs(to.x-from.x), height: Math.abs(to.y-from.y) }; paintShape(drag.node, "rectangle", box); @@ -1622,7 +1664,7 @@ export class CanvasEditor { }); this.input = { input, item, face, faces, frame, source: Boolean(source), fontChoice: item.fontChoice }; const editing = this.input; - if (rich) editing.rich = new RichTextSession(this, rich.model, rich.faces, rich.fonts); + if (rich) editing.rich = new RichTextSession(this, rich.model, rich.faces, rich.fonts, rich.resources); if (source) { editing.selectionListener = () => this.paintSourceSelection(); document.addEventListener("selectionchange", editing.selectionListener); @@ -1662,7 +1704,10 @@ export class CanvasEditor { }); }); input.addEventListener("paste", event => { - if (editing.rich) { editing.rich.paste(event); return; } + if (editing.rich) { + editing.rich.paste(event).catch(error => { if (this.input === editing) this.setTextError(error); }); + return; + } event.preventDefault(); document.execCommand("insertText", false, event.clipboardData.getData("text/plain")); }); @@ -1773,8 +1818,7 @@ export class CanvasEditor { if (!editing || editing.rich || editing.composing || editing.saving) return false; const caret = editTextSelection(editing.input, editing.range) || [0, 0]; const value = editTextValue(editing.input); - const session = new RichTextSession(this, rich.model, rich.faces, rich.fonts); - const original = session.snapshot(); + const session = new RichTextSession(this, rich.model, rich.faces, rich.fonts, rich.resources); const format = { ...session.typing, ...editing.style }; if (editing.fontData) { const key = `temp-font-${crypto.randomUUID()}`; @@ -1785,15 +1829,17 @@ export class CanvasEditor { } const history = editing.history; const values = history ? history.entries.slice(0, history.index + 1) : [{ value: editing.item.text }, { value }]; - const entries = [original]; for (const entry of values) { - const snapshot = { model: { ...structuredClone(rich.model), runs: normalizeRuns([{ text: entry.value, format }]) }, - typing: { ...format }, caret: entry.caret || caret }; - if (JSON.stringify(snapshot.model) !== JSON.stringify(entries.at(-1).model)) entries.push(snapshot); + const before = session.model.runs; + session.model.runs = normalizeRuns([{ text: entry.value, format }]); + session.typing = { ...format }; + session.caret = entry.caret || caret; + session.record({ start: 0, before, after: session.model.runs }); } - const current = { model: { ...structuredClone(rich.model), runs: normalizeRuns([{ text: value, format }]) }, typing: { ...format }, caret }; - if (JSON.stringify(current.model) !== JSON.stringify(entries.at(-1).model)) entries.push(current); - Object.assign(session, { entries, index: entries.length - 1, model: current.model, typing: format, caret }); + const before = session.model.runs; + session.model.runs = normalizeRuns([{ text: value, format }]); + session.caret = caret; + session.record({ start: 0, before, after: session.model.runs }); editing.rich = session; editing.source = false; document.removeEventListener("selectionchange", editing.selectionListener); diff --git a/internal/webui/webui.go b/internal/webui/webui.go index b70ce88..d744479 100644 --- a/internal/webui/webui.go +++ b/internal/webui/webui.go @@ -5018,6 +5018,32 @@ func (e *businessEngine) getRichText(args []platformValue) (any, error) { return e.browserRichText(value), nil } +// richTextFonts 提供跨文档文字复制所需的独立字体数据 +// 入参: args 富文本JSON +// 返回: any 字体集合, error 读取错误 +func (e *businessEngine) richTextFonts(args []platformValue) (any, error) { + if e.currentEditor == nil { + return nil, fmt.Errorf("no document is being edited") + } + value, err := e.readRichText(args[0]) + if err != nil { + return nil, err + } + fonts, err := e.currentEditor.RichTextFonts(value) + if err != nil { + return nil, err + } + result := make([]any, 0, len(fonts)) + for key, data := range fonts { + _, data, _, _, err = ofdgo.FixFontDataAggressive(data, false, false) + if err != nil { + return nil, err + } + result = append(result, map[string]any{"key": key, "data": bytesToJS(data)}) + } + return successResult(result), nil +} + // browserRichText 提供独立富文本快照和浏览器颜色,不改变库层样式 // 入参: value 库层富文本 // 返回: map[string]any 文字分段和段落选项 @@ -5159,35 +5185,24 @@ func (e *businessEngine) importRichTextFonts(value *ofdgo.RichText, fonts platfo if fonts.IsNull() || fonts.IsUndefined() { return nil } - registered := make(map[string]string) + data := make(map[string][]byte) for i := 0; i < fonts.Length(); i++ { entry := fonts.Index(i) key := entry.Get("key").String() - if key == "" || registered[key] != "" { + if key == "" || data[key] != nil { return fmt.Errorf("invalid rich text font key") } - used := false - for _, run := range value.Runs { - used = used || run.Format.Font == key - } - if !used { - continue - } - data, err := e.bytesFromJS(entry.Get("data")) + bytes, err := e.bytesFromJS(entry.Get("data")) if err != nil { return err } - font, err := e.currentEditor.AddFont(FontFile{Data: data}, 0) - if err != nil { - return err - } - registered[key] = font + data[key] = bytes } - for i := range value.Runs { - if font := registered[value.Runs[i].Format.Font]; font != "" { - value.Runs[i].Format.Font = font - } + imported, err := e.currentEditor.ImportRichText(*value, data) + if err != nil { + return err } + *value = imported return nil } @@ -7992,6 +8007,7 @@ func (e *businessEngine) businessCommands() map[string]func([]platformValue) (an "ofdgoStyleText": e.styleText, "ofdgoFormatText": e.formatText, "ofdgoGetRichText": e.getRichText, + "ofdgoRichTextFonts": e.richTextFonts, "ofdgoSaveRichText": e.saveRichText, "ofdgoPreviewRichText": e.previewRichText, "ofdgoCheckTextFont": e.checkTextFont, diff --git a/ofdgo_rich_text.go b/ofdgo_rich_text.go index fbbf3af..f9e7ece 100644 --- a/ofdgo_rich_text.go +++ b/ofdgo_rich_text.go @@ -17,6 +17,8 @@ package ofdgo import ( "errors" "fmt" + "maps" + "slices" "strings" "unicode/utf8" @@ -64,6 +66,59 @@ type TextFormatPatch struct { Background *string `json:"background,omitempty"` } +// RichTextFonts 提取富文本实际引用的独立字体数据,供跨文档保存和复制使用 +// 入参: value 富文本内容 +// 返回: map[string][]byte 字体标识及数据, error 字体读取错误 +func (e *Editor) RichTextFonts(value RichText) (map[string][]byte, error) { + fonts := make(map[string][]byte) + for _, run := range value.Runs { + if run.Text == "" || fonts[run.Format.Font] != nil { + continue + } + data, err := e.FontData(run.Format.Font) + if err != nil { + return nil, err + } + fonts[run.Format.Font] = data + } + return fonts, nil +} + +// ImportRichText 注册富文本引用的外来字体并返回独立内容,失败时不保留新增资源 +// 未提供数据的字体标识保留为当前文档引用,调用方可在外层事务中同时创建或更新对象 +// 入参: value 富文本内容, fonts 外来字体标识及数据 +// 返回: RichText 已映射字体标识的内容, error 字体注册错误 +func (e *Editor) ImportRichText(value RichText, fonts map[string][]byte) (RichText, error) { + value = cloneEditorData(value) + used := make(map[string]bool) + for _, run := range value.Runs { + used[run.Format.Font] = true + } + err := e.Transaction(func(editor *Editor) error { + registered := make(map[string]string) + for _, key := range slices.Sorted(maps.Keys(fonts)) { + if !used[key] { + continue + } + id, err := editor.AddFont(FontFile{Data: fonts[key]}, 0) + if err != nil { + return err + } + registered[key] = id + } + for i := range value.Runs { + if id := registered[value.Runs[i].Format.Font]; id != "" { + value.Runs[i].Format.Font = id + } + } + return nil + }) + if err != nil { + return RichText{}, err + } + return value, nil +} + // Text 返回包含显式换行的原文,不包含排版产生的软换行 // 返回: string 完整原文 func (v RichText) Text() string {