From 651e3cf5b58f104e71a4c87b77643a92fc2a5114 Mon Sep 17 00:00:00 2001 From: xiaoqidun Date: Mon, 14 Sep 2026 15:12:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=8A=9F=E8=83=BD=E5=A2=9E=E5=BC=BA):=20?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=85=A8=E6=96=87=E5=85=A8=E9=80=89=E5=A4=8D?= =?UTF-8?q?=E5=88=B6=EF=BC=8C=E5=AE=8C=E5=96=84=E9=80=89=E5=8C=BA=E5=8F=96?= =?UTF-8?q?=E6=B6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/webui/ofdgo.js | 98 +++++++++++++++++++++++++++++++++++++---- internal/webui/wasm.go | 11 +++++ internal/webui/webui.go | 18 ++++++++ ofdgo_search.go | 16 +++++++ 4 files changed, 135 insertions(+), 8 deletions(-) diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index efb7988..7c7cdc3 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -57,6 +57,7 @@ const state = { pageCache: new Map(), svgFonts: new Map(), selectedPages: new Set(), + documentSelection: null, pageInFlight: new Map(), pageRenderQueue: [], pageRenderRunning: false, @@ -207,11 +208,7 @@ el.panButton.addEventListener("click", togglePan); el.continuousButton.addEventListener("click", toggleContinuous); el.annotationButton.addEventListener("click", toggleAnnotations); document.addEventListener("copy", copySelection); -document.addEventListener("selectionchange", () => { - for (const index of state.selectedPages) { - unmountPage(index); - } -}); +document.addEventListener("selectionchange", syncSelection); el.exportFormat.addEventListener("change", () => updateDPIControl()); el.exportPageButton.addEventListener("click", () => exportFile(false)); el.exportButton.addEventListener("click", openExportPanel); @@ -289,10 +286,22 @@ updateSidebarState(); boot(); function handleKeyDown(event) { - if (event.defaultPrevented || event.isComposing || event.altKey || document.body.hasAttribute("aria-busy") || el.exportPanel.open) { + if (event.defaultPrevented || event.isComposing || event.altKey) { return; } const key = event.key; + const target = event.target; + 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) { + selectDocumentText(); + } + return; + } + if (document.body.hasAttribute("aria-busy") || el.exportPanel.open) { + return; + } if (event.ctrlKey || event.metaKey) { if (!event.shiftKey && key.toLowerCase() === "o") { event.preventDefault(); @@ -306,7 +315,13 @@ function handleKeyDown(event) { if (!state.doc) { return; } - const target = event.target; + if (key === "Escape" && !event.shiftKey && state.documentSelection && el.viewerPanel.contains(target) + && !target.closest("input, textarea, select, [contenteditable]")) { + event.preventDefault(); + document.getSelection().removeAllRanges(); + syncSelection(); + return; + } if (key === "F3") { event.preventDefault(); if (state.searchMatches.length) { @@ -1684,6 +1699,7 @@ function resetPageFlow() { state.svgFonts.clear(); state.pageCache.clear(); state.selectedPages.clear(); + state.documentSelection = null; state.visiblePages.clear(); for (const task of state.pageInFlight.values()) { task.resolve(null); @@ -1740,7 +1756,8 @@ function flowPageObserver() { function unmountPage(index) { const shell = pageShell(index); const selection = document.getSelection(); - if (selection.rangeCount && selection.getRangeAt(0).intersectsNode(shell)) { + const range = selection.rangeCount ? selection.getRangeAt(0) : null; + if (range && (!state.documentSelection || !isDocumentSelection(range)) && range.intersectsNode(shell)) { state.selectedPages.add(index); return; } @@ -1943,6 +1960,62 @@ function createTextLayer(text) { return layer; } +function syncSelection() { + const selection = document.getSelection(); + if (state.documentSelection && (!selection.rangeCount || !isDocumentSelection(selection.getRangeAt(0)))) { + state.documentSelection = null; + if (state.doc && !document.body.hasAttribute("aria-busy")) { + setStatus(pageStatus(state.pageIndex, state.doc.pageCount)); + } + } + for (const index of state.selectedPages) { + unmountPage(index); + } +} + +function isDocumentSelection(range) { + return range.startContainer === el.svgHost && range.startOffset === 0 + && range.endContainer === el.svgHost && range.endOffset === el.svgHost.childNodes.length; +} + +async function selectDocumentText() { + const selection = document.getSelection(); + if (state.documentSelection && selection.rangeCount && isDocumentSelection(selection.getRangeAt(0))) { + return; + } + const openSeq = state.openSeq; + const current = { text: null }; + const active = () => state.documentSelection === current && openSeq === state.openSeq + && selection.rangeCount > 0 && isDocumentSelection(selection.getRangeAt(0)); + state.documentSelection = current; + const range = document.createRange(); + range.selectNodeContents(el.svgHost); + selection.removeAllRanges(); + selection.addRange(range); + const pages = []; + const count = state.doc.pageCount; + try { + for (let index = 0; index < count; index += 1) { + setStatus(`正在全选 ${index + 1} / ${count} 页`); + const text = await callWASM("ofdgoPageTextString", index); + if (!active()) { + return; + } + if (text) { + pages.push(text); + } + } + current.text = pages.join("\n"); + setStatus(current.text ? "文字已全选" : "暂无文字"); + } catch (err) { + if (active()) { + state.documentSelection = null; + selection.removeAllRanges(); + showError(err, false); + } + } +} + function copySelection(event) { if (event.target.closest?.("input, textarea, [contenteditable]")) { return; @@ -1952,6 +2025,15 @@ function copySelection(event) { return; } const range = selection.getRangeAt(0); + if (state.documentSelection && isDocumentSelection(range)) { + event.preventDefault(); + if (state.documentSelection.text === null) { + setStatus("正在全选文字"); + } else { + event.clipboardData.setData("text/plain", state.documentSelection.text); + } + return; + } if (!el.svgHost.contains(range.startContainer) || !el.svgHost.contains(range.endContainer)) { return; } diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index d007137..b85d5cd 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -85,6 +85,7 @@ func RunWASM() { registerCallback("ofdgoRenderPage", renderPage) registerCallback("ofdgoSVGFontData", svgFontData) registerCallback("ofdgoSearchPage", searchPage) + registerCallback("ofdgoPageTextString", pageTextString) registerCallback("ofdgoExportFormats", exportFormats) registerCallback("ofdgoParsePageRange", parsePageRange) registerExportCallback("ofdgoExportPage", exportPage) @@ -283,6 +284,16 @@ func renderPage(args []js.Value) (any, error) { }), nil } +// pageTextString 提取OFD页面原文 +// 入参: args 浏览器参数 +// 返回: any 页面原文, error 错误信息 +func pageTextString(args []js.Value) (any, error) { + if currentSession == nil { + return nil, fmt.Errorf("ofd document is not opened") + } + return currentSession.PageTextString(args[0].Int()) +} + // searchPage 搜索OFD页面文字 // 入参: args 浏览器参数 // 返回: any 搜索结果, error 错误信息 diff --git a/internal/webui/webui.go b/internal/webui/webui.go index 2559300..008cc21 100644 --- a/internal/webui/webui.go +++ b/internal/webui/webui.go @@ -289,6 +289,24 @@ func (s *Session) PageText(index int) (*ofdgo.PageText, error) { return text, nil } +// PageTextString 获取页面原文,不为全文复制保留额外的页面和文字索引 +// 入参: index 页面索引 +// 返回: string 页面原文, error 错误信息 +func (s *Session) PageTextString(index int) (string, error) { + text := s.textCache[index] + if text == nil { + page, err := s.Reader.PageContentByIndex(index) + if err != nil { + return "", err + } + text, err = s.Renderer.PageText(page) + if err != nil { + return "", err + } + } + return text.String(), nil +} + // SearchPage 搜索指定页面,复用当前会话的文字索引 // 入参: index 页面索引, query 搜索文字 // 返回: []ofdgo.TextMatch 匹配结果, error 错误信息 diff --git a/ofdgo_search.go b/ofdgo_search.go index 2060209..49ca122 100644 --- a/ofdgo_search.go +++ b/ofdgo_search.go @@ -74,6 +74,22 @@ func (r *Renderer) PageText(page *PageContent) (*PageText, error) { return renderer.pageText, nil } +// String 按文本对象顺序合并原文,以换行分隔非空对象 +// 返回: string 页面原文 +func (p *PageText) String() string { + var text strings.Builder + for _, run := range p.Runs { + if run.Text == "" { + continue + } + if text.Len() > 0 { + text.WriteByte('\n') + } + text.WriteString(run.Text) + } + return text.String() +} + // Search 按原文进行忽略大小写的字面匹配,不跨文本对象拼接或识别图像 // 入参: query 搜索文字 // 返回: []TextMatch 匹配结果及上下文