From 523f66983e7ef664ffd822bf23b52a283a38cb72 Mon Sep 17 00:00:00 2001 From: xiaoqidun Date: Tue, 13 Oct 2026 15:16:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E8=B5=84=E6=BA=90=E7=AE=A1=E7=90=86):=20?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=B5=84=E6=BA=90=E7=AE=A1=E7=90=86=E4=B8=8E?= =?UTF-8?q?=E5=9B=BE=E5=85=83=E5=AE=9A=E4=BD=8D=E9=80=89=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/webui/index.html | 8 +- assets/webui/ofdgo.css | 26 ++++- assets/webui/ofdgo.js | 190 ++++++++++++++++++++++++++++++------- internal/webui/wasm.go | 27 +++--- ofdgo_editor_resource.go | 113 +++++++++++++++++----- ofdgo_resource_location.go | 176 ++++++++++++++++++++++++++++++++++ 6 files changed, 462 insertions(+), 78 deletions(-) create mode 100644 ofdgo_resource_location.go diff --git a/assets/webui/index.html b/assets/webui/index.html index 58809ba..b760810 100644 --- a/assets/webui/index.html +++ b/assets/webui/index.html @@ -364,7 +364,7 @@ -
+
@@ -372,16 +372,14 @@ - -
-
+
diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css index 1fe6d77..685f2dd 100644 --- a/assets/webui/ofdgo.css +++ b/assets/webui/ofdgo.css @@ -1552,6 +1552,11 @@ body[data-hide-meta] .meta-panel { width: 600px; } +.resource-dialog form > .form-actions { + grid-template-columns: minmax(0, 1fr) auto; + margin-top: 12px; +} + .resource-heading, .resource-option { display: grid; @@ -1641,8 +1646,25 @@ body[data-hide-meta] .meta-panel { display: none; } -.resource-actions > :last-child { - margin-left: auto; +.resource-target { + display: flex; + align-items: stretch; + gap: 6px; + min-width: 0; +} + +.resource-target select { + flex: 1; + min-width: 0; +} + +.resource-target button { + flex: 0 0 auto; +} + +.resource-picking, +.resource-picking .edit-object { + cursor: crosshair; } .resource-binding { diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index 5613cd3..5a07cf4 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -23,8 +23,8 @@ const batch = { items: [], formats: [], running: false, canceled: false, worker: const batchElements = Object.fromEntries(["Button", "Panel", "Form", "Input", "Add", "Clear", "Count", "Format", "Compression", "FontRow", "FontMode", "Destination", "DPIRow", "DPI", "List", "Empty", "Progress", "Status", "Close", "Cancel", "Start"].map(name => [name, document.querySelector(`#convert${name}`)])); const fontBinding = { font: null, choices: [], sequence: 0 }; const fontBindingElements = Object.fromEntries(["Panel", "Form", "Title", "Name", "SourceRow", "Source", "File", "Hint", "Status", "Cancel", "Submit"].map(name => [name, document.querySelector(`#fontBinding${name}`)])); -const resourceElements = Object.fromEntries(["ManageButton", "Panel", "Form", "Kind", "List", "Empty", "Reference", "Add", "Preview", "Export", "Rename", "Replace", "Delete", "Locate", "Close", "Binding", "Page", "Object", "Bind", "Unbind", "Viewer", "Status", "File"].map(name => [name, document.querySelector(`#resource${name}`)])); -const resourceManager = { items: [], selected: null, busy: false, sequence: 0, url: null, font: null, fileAction: null }; +const resourceElements = Object.fromEntries(["ManageButton", "Panel", "Form", "Kind", "List", "Empty", "Reference", "Add", "Preview", "Export", "Rename", "Replace", "Delete", "Locate", "Close", "Binding", "Page", "Object", "Pick", "Bind", "Unbind", "Viewer", "Status", "File"].map(name => [name, document.querySelector(`#resource${name}`)])); +const resourceManager = { items: [], selected: null, targets: [], pick: null, busy: false, sequence: 0, url: null, font: null, fileAction: null }; let wasmPromise = null; let remoteDownload = null; @@ -736,19 +736,17 @@ resourceElements.Close.addEventListener("click", () => resourceElements.Panel.cl resourceElements.Form.addEventListener("submit", event => event.preventDefault()); resourceElements.Panel.addEventListener("cancel", event => { if (resourceManager.busy) event.preventDefault(); }); resourceElements.Panel.addEventListener("close", () => { resourceManager.sequence++; releaseResourcePreview(); }); -resourceElements.Kind.addEventListener("change", renderResourceList); -resourceElements.Reference.addEventListener("change", updateResourceControls); -resourceElements.Page.addEventListener("change", () => resourceTask(loadResourceTargets)); +resourceElements.Kind.addEventListener("change", () => resourceTask(renderResourceList)); +resourceElements.Reference.addEventListener("change", () => resourceTask(syncResourceReference)); +resourceElements.Page.addEventListener("change", () => resourceTask(() => loadResourceTargets(""))); resourceElements.Object.addEventListener("change", updateResourceControls); +resourceElements.Pick.addEventListener("click", () => resourceTask(beginResourcePick)); resourceElements.Preview.addEventListener("click", () => resourceTask(previewManagedResource)); resourceElements.Export.addEventListener("click", () => { const item = resourceManager.selected; if (item) downloadResource({ id: JSON.stringify([item.kind, item.id]), fileName: resourceFileName(item) }, "ofdgoExportResource", "资源"); }); -resourceElements.Locate.addEventListener("click", () => { - const reference = resourceManager.selected?.references?.[Number(resourceElements.Reference.value)]; - if (reference?.page >= 0) { resourceElements.Panel.close(); renderPage(reference.page); } -}); +resourceElements.Locate.addEventListener("click", () => resourceTask(locateManagedResource)); resourceElements.Add.addEventListener("click", () => { if (!state.editing) return; const kind = resourceElements.Kind.value; @@ -1432,6 +1430,13 @@ const sealPosition = Object.fromEntries(["Layer", "Stamp", "Image"].map(name => let sealPreview = null; document.querySelector("#signPick").addEventListener("click", openSealPosition); el.viewerPanel.addEventListener("pointerdown", beginSealDrag, true); +el.viewerPanel.addEventListener("pointerdown", pickResourceObject, true); +document.addEventListener("keydown", event => { + if (!resourceManager.pick || el.objectPicker.open) return; + if (event.key === "Escape") { event.preventDefault(); event.stopImmediatePropagation(); finishResourcePick(); return; } + if (event.target.closest?.("input, textarea, select")) return; + if (event.key !== "Tab") { event.preventDefault(); event.stopImmediatePropagation(); } +}, true); el.viewerPanel.addEventListener("pointermove", moveSealDrag, true); el.viewerPanel.addEventListener("pointerup", confirmSealPosition, true); el.viewerPanel.addEventListener("pointercancel", () => { if (sealPreview) finishSealPosition(); }, true); @@ -1757,7 +1762,7 @@ function handleKeyDown(event) { } function formDialogOpen() { - return Boolean(sealPreview) || fontBindingElements.Panel.open || el.signPanel.open || el.verifyPanel.open || el.credentialsPanel.open || el.encryptionPanel.open || batchElements.Panel.open || el.exportPanel.open || el.createPanel.open || el.insertPanel.open || el.pagePanel.open || el.paragraphPanel.open || el.importPanel.open || el.infoPanel.open || el.documentPanel.open + return Boolean(sealPreview || resourceManager.pick) || fontBindingElements.Panel.open || el.signPanel.open || el.verifyPanel.open || el.credentialsPanel.open || el.encryptionPanel.open || batchElements.Panel.open || el.exportPanel.open || el.createPanel.open || el.insertPanel.open || el.pagePanel.open || el.paragraphPanel.open || el.importPanel.open || el.infoPanel.open || el.documentPanel.open || resourceElements.Panel.open || el.structureDialog.open || el.versionPanel.open || el.batchPagesPanel.open || el.objectStylePanel.open || el.sourceTextPanel.open || el.outlinePanel.open || el.objectBoundsPanel.open || el.annotationNote.open || el.annotationCreate.open || el.objectPicker.open; } @@ -4710,6 +4715,7 @@ function updateFontPermissionHint() { } function clearSecurityForms() { + finishResourcePick(false); if (sealPreview) finishSealPosition(false); finishCredentials(null); clearEncryptionForm(); @@ -5071,10 +5077,11 @@ async function renderPage(index, options = {}) { async function openResourceManagement() { if (!state.doc || !state.ready || document.body.hasAttribute("aria-busy")) return; + if (resourceManager.pick) { finishResourcePick(); return; } resourceElements.Panel.showModal(); resourceElements.Page.replaceChildren(...state.doc.pages.map((page, index) => new Option(`第${index + 1}页`, String(index)))); resourceElements.Page.value = String(state.pageIndex); - await resourceTask(async () => { await loadManagedResources(); if (state.editing) await loadResourceTargets(); }); + await resourceTask(loadManagedResources); } async function resourceTask(action) { @@ -5110,10 +5117,10 @@ async function loadManagedResources() { const items = await callWASM("ofdgoResources"); if (sequence !== state.openSeq || !resourceElements.Panel.open) return; resourceManager.items = items || []; - renderResourceList(); + await renderResourceList(); } -function renderResourceList() { +async function renderResourceList() { const items = resourceManager.items.filter(item => item.kind === resourceElements.Kind.value); const selected = items.find(item => item.id === resourceManager.selected?.id && item.kind === resourceManager.selected?.kind) || items[0]; resourceElements.List.replaceChildren(); @@ -5130,21 +5137,24 @@ function renderResourceList() { span.textContent = value; button.append(span); } - button.addEventListener("click", () => selectManagedResource(item)); + button.addEventListener("click", () => resourceTask(() => selectManagedResource(item))); button.addEventListener("keydown", event => { const index = items.indexOf(item); const target = { ArrowDown: Math.min(items.length - 1, index + 1), ArrowUp: Math.max(0, index - 1), Home: 0, End: items.length - 1 }[event.key]; if (target == null) return; event.preventDefault(); - selectManagedResource(items[target]); - resourceElements.List.children[target].focus(); + resourceTask(() => selectManagedResource(items[target])).then(() => { + if (resourceElements.Panel.open && resourceManager.selected === items[target]) resourceElements.List.children[target]?.focus(); + }); }); resourceElements.List.append(button); } - selectManagedResource(selected); + await selectManagedResource(selected); } -function selectManagedResource(item) { +async function selectManagedResource(item) { + const same = item?.id === resourceManager.selected?.id && item?.kind === resourceManager.selected?.kind; + const reference = same ? resourceElements.Reference.value : "0"; releaseResourcePreview(); resourceManager.selected = item || null; const items = resourceManager.items.filter(entry => entry.kind === resourceElements.Kind.value); @@ -5156,7 +5166,10 @@ function selectManagedResource(item) { const roles = {Font: "字体", ResourceID: "资源", Substitution: "替换", ImageMask: "蒙版", Thumbnail: "缩略", AttachID: "附件"}; resourceElements.Reference.replaceChildren(...references.map((ref, index) => new Option(`${ref.page >= 0 ? `第${ref.page + 1}页` : ref.file}${ref.object ? ` · ${ref.object}` : ""} · ${roles[ref.role] || ref.role}`, String(index)))); if (!references.length) resourceElements.Reference.add(new Option(!item || item.complete ? "暂无引用" : "引用未知", "")); + else resourceElements.Reference.value = references[Number(reference)] ? String(Number(reference)) : "0"; updateResourceControls(); + if (same) await loadResourceTargets(); + else await syncResourceReference(); } function updateResourceControls() { @@ -5174,19 +5187,127 @@ function updateResourceControls() { resourceElements.Replace.textContent = item?.kind === "Font" ? item.file ? "外置" : "嵌入" : "替换"; resourceElements.Delete.disabled = disabled || !item || item.kind !== "Attachment" && (!item.complete || Boolean(item.references?.length)); resourceElements.Delete.title = item?.kind === "Attachment" ? "删除附件" : item?.references?.length ? "资源仍被引用" : "删除资源"; - resourceElements.Locate.disabled = disabled || !(item?.references?.[Number(resourceElements.Reference.value)]?.page >= 0); + resourceElements.Locate.disabled = disabled || !item?.references?.length; resourceElements.Binding.hidden = !editing || !actionable; - resourceElements.Bind.disabled = resourceElements.Unbind.disabled = disabled || !actionable || !resourceElements.Object.value; + const target = resourceManager.targets.find(target => target.id === resourceElements.Object.value); + resourceElements.Pick.disabled = disabled || !editing || !actionable || Boolean(state.composite); + resourceElements.Bind.disabled = disabled || !actionable || !target || target.bound; + resourceElements.Unbind.disabled = disabled || !actionable || !target?.bound; } -async function loadResourceTargets() { +async function syncResourceReference() { + const reference = resourceManager.selected?.references?.[Number(resourceElements.Reference.value)]; + if (reference?.page >= 0) resourceElements.Page.value = String(reference.page); + await loadResourceTargets(reference?.page >= 0 ? reference.object : ""); + updateResourceControls(); +} + +async function loadResourceTargets(preferred) { + const resource = resourceManager.selected; + if (preferred === undefined) { + const reference = resource?.references?.[Number(resourceElements.Reference.value)]; + preferred = resourceElements.Object.value || (reference?.page === Number(resourceElements.Page.value) ? reference.object : ""); + } + resourceManager.targets = []; resourceElements.Object.replaceChildren(new Option("选择图元", "")); + if (!state.editing || !resource || !["Audio", "Video", "Attachment"].includes(resource.kind)) return; const page = Number(resourceElements.Page.value), sequence = state.openSeq; - const items = await callWASM("ofdgoResourceTargets", page); - if (!resourceElements.Panel.open || sequence !== state.openSeq) return; - for (const item of items || []) resourceElements.Object.add(new Option(`${item.id} · ${(item.text || "图元").slice(0, 50)}`, item.id)); + const items = await callWASM("ofdgoResourceTargets", page, resource.kind, resource.id); + if (!resourceElements.Panel.open || sequence !== state.openSeq || resource !== resourceManager.selected || page !== Number(resourceElements.Page.value)) return; + resourceManager.targets = items || []; + const types = {TextObject: "文字", PathObject: "图形", ImageObject: "图片", CompositeObject: "组合", CompositeGraphicUnit: "组合"}; + for (const item of resourceManager.targets) resourceElements.Object.add(new Option(`${item.id} · ${(item.text || types[item.type] || "图元").slice(0, 50)}${item.bound ? " · 已绑定" : ""}`, item.id)); const selected = canvasEditor.selected; - if (selected?.index === page && !selected.scoped) resourceElements.Object.value = selected.id; + if (!preferred && selected?.index === page && !selected.scoped) preferred = selected.id; + resourceElements.Object.value = resourceManager.targets.some(item => item.id === preferred) ? preferred : ""; +} + +async function locateManagedResource() { + const item = resourceManager.selected, sequence = state.openSeq; + if (!item?.references?.length) return; + const locations = await callWASM("ofdgoResourceLocations", item.kind, item.id, Number(resourceElements.Reference.value)); + if (sequence !== state.openSeq || !resourceElements.Panel.open || item !== resourceManager.selected) return; + if (!locations?.length) { resourceElements.Status.textContent = "暂无可见位置"; return; } + const focus = async location => { + if (sequence !== state.openSeq) return; + resourceElements.Panel.close(); + const box = location.bounds; + await focusPageRegion({page: location.page, x: box.X, y: box.Y, width: box.W, height: box.H}, "资源", "center"); + if (sequence !== state.openSeq || !state.editing) return; + const target = canvasEditor.pageItems(location.page - 1).find(item => item.id === location.object && !item.scoped); + if (target) canvasEditor.select(target); + }; + if (locations.length === 1) await focus(locations[0]); + else resourceChoices(locations, location => `第${location.page}页 · ${location.object}`, location => resourceTask(() => focus(location))); +} + +function resourceChoices(items, label, choose) { + el.objectPickerList.replaceChildren(); + for (const item of items) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "button"; + button.textContent = label(item); + button.addEventListener("click", () => { el.objectPicker.close(); choose(item); }); + el.objectPickerList.append(button); + } + el.objectPicker.showModal(); +} + +async function beginResourcePick() { + if (!state.editing || state.composite || !["Audio", "Video", "Attachment"].includes(resourceManager.selected?.kind)) return; + const sequence = state.openSeq; + if (!await renderPage(Number(resourceElements.Page.value), {fit: false})) return; + if (sequence !== state.openSeq || !resourceElements.Panel.open) return; + resourceManager.pick = {sequence, status: el.statusText.textContent}; + resourceElements.Panel.close(); + canvasEditor.enabled = false; + el.viewerPanel.classList.add("resource-picking"); + setBusy(false); + setStatus("选取图元"); + el.viewerPanel.focus({preventScroll: true}); +} + +function finishResourcePick(reopen = true) { + const picking = resourceManager.pick; + if (!picking) return; + resourceManager.pick = null; + el.viewerPanel.classList.remove("resource-picking"); + if (picking.sequence === state.openSeq) { + updateEditorTools(); + setBusy(document.body.hasAttribute("aria-busy")); + setStatus(picking.status); + if (reopen && !resourceElements.Panel.open) { + resourceElements.Panel.showModal(); + resourceElements.Pick.focus(); + } + } +} + +function pickResourceObject(event) { + const picking = resourceManager.pick; + if (!picking || event.button !== 0) return; + event.preventDefault(); + event.stopImmediatePropagation(); + if (resourceManager.busy || document.body.hasAttribute("aria-busy")) return; + const items = canvasEditor.itemsAt(event).filter(item => !item.scoped && !item.draft); + if (!items.length) { setStatus("未选中图元"); return; } + resourceTask(async () => { + const resource = resourceManager.selected, page = items[0].index; + const targets = await callWASM("ofdgoResourceTargets", page, resource.kind, resource.id); + if (resourceManager.pick !== picking || picking.sequence !== state.openSeq) return; + const available = items.filter(item => targets?.some(target => target.id === item.id)); + if (!available.length) { setStatus("图元不可绑定"); return; } + const choose = async item => { + if (resourceManager.pick !== picking || picking.sequence !== state.openSeq) return; + finishResourcePick(); + resourceElements.Page.value = String(item.index); + await loadResourceTargets(item.id); + resourceElements.Status.textContent = "已选取"; + }; + if (available.length === 1) await choose(available[0]); + else resourceChoices(available, item => `${item.id} · ${(item.text || "图元").slice(0, 50)}`, item => resourceTask(() => choose(item))); + }); } function chooseResourceFile(action) { @@ -5207,7 +5328,7 @@ async function changeManagedResource(action, item, file) { : await changeDocument("ofdgoChangeResource", null, action, item.kind, item.id, format, file || null, Number(resourceElements.Page.value), resourceElements.Object.value); if (changed) { await loadManagedResources(); - await loadResourceTargets(); + if (action === "bind" || action === "unbind") resourceElements.Status.textContent = action === "bind" ? "已绑定" : "已解绑"; } } @@ -8922,7 +9043,7 @@ function appendSignaturePolicy(row, label, checked, ok) { appendSignatureCheck(row, label, checked, ok); } -async function focusPageRegion(region, label) { +async function focusPageRegion(region, label, alignment = "nearest") { const pageIndex = region.page - 1; if (!state.doc || pageIndex < 0 || document.body.hasAttribute("aria-busy")) { return; @@ -8935,11 +9056,11 @@ async function focusPageRegion(region, label) { if (openSeq !== state.openSeq || pageIndex !== state.pageIndex) { return; } - highlightPageRegion(region); + highlightPageRegion(region, alignment); setStatus(`已定位${label}(第${region.page}页)`); } -function highlightPageRegion(region) { +function highlightPageRegion(region, alignment = "nearest") { clearRegionHighlights(); const pageIndex = region.page - 1; const shell = pageShell(pageIndex); @@ -8955,11 +9076,12 @@ function highlightPageRegion(region) { shell.querySelector(".page-surface").append(mark); if (state.pageWindow) { const box = mark.getBoundingClientRect(), viewer = el.viewerPanel.getBoundingClientRect(); - if (box.top < viewer.top) scrollViewerBy(box.top - viewer.top); + if (alignment === "center") scrollViewerBy(box.top - viewer.top + (box.height - el.viewerPanel.clientHeight) / 2); + else if (box.top < viewer.top) scrollViewerBy(box.top - viewer.top); else if (box.bottom > viewer.top + el.viewerPanel.clientHeight) scrollViewerBy(Math.min(box.top - viewer.top, box.bottom - viewer.top - el.viewerPanel.clientHeight)); if (box.left < viewer.left) el.viewerPanel.scrollLeft += box.left - viewer.left; else if (box.right > viewer.left + el.viewerPanel.clientWidth) el.viewerPanel.scrollLeft += box.right - viewer.left - el.viewerPanel.clientWidth; - } else mark.scrollIntoView({ block: "nearest", inline: "nearest" }); + } else mark.scrollIntoView({ block: alignment, inline: "nearest" }); window.setTimeout(() => mark.remove(), 1800); } @@ -9522,7 +9644,7 @@ function updateEditorTools() { const moveDisabled = selectionDisabled || selectedPages.some(index => !pageCan("move", index)); el.movePagePrevButton.disabled = moveDisabled || selectedPages[0] === 0; el.movePageNextButton.disabled = moveDisabled || selectedPages.at(-1) === state.doc?.pageCount - 1; - const enabled = state.editing && state.selectObjects && !state.panMode && !sealPreview; + const enabled = state.editing && state.selectObjects && !state.panMode && !sealPreview && !resourceManager.pick; canvasEditor.setEnabled(enabled); updateObjectControls(canvasEditor.selected); el.undoButton.disabled = !state.editorInfo?.canUndo || !state.ready || state.exporting; @@ -9680,7 +9802,7 @@ function setBusy(busy, text = "", percent = 0, status = "") { updateDocumentControls(); renderSecurity(); updateDisplayControls(); - el.editButton.disabled = busy || Boolean(sealPreview) || !state.doc || !state.ready || state.exporting; + el.editButton.disabled = busy || Boolean(sealPreview || resourceManager.pick) || !state.doc || !state.ready || state.exporting; el.createForm.inert = busy && !state.imageImporting; updateCreateOptions(); el.insertForm.inert = busy; @@ -9703,7 +9825,7 @@ function setBusy(busy, text = "", percent = 0, status = "") { el.outlineList.inert = busy; el.navigationTabs.inert = busy; el.pageList.inert = busy; - el.editorTools.inert = busy || Boolean(sealPreview); + el.editorTools.inert = busy || Boolean(sealPreview || resourceManager.pick); el.fontList.inert = busy; el.docFontList.inert = busy; if (!busy) { diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index 3038502..64a58e6 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -223,6 +223,7 @@ func RunWASM() { registerAsyncCallback("ofdgoExportMedia", exportMedia) registerCallback("ofdgoResources", documentResources) registerCallback("ofdgoResourceTargets", resourceTargets) + registerCallback("ofdgoResourceLocations", resourceLocations) registerCallback("ofdgoChangeResource", changeResource) registerAsyncCallback("ofdgoExportResource", exportResource) registerCallback("ofdgoImages", documentImages) @@ -808,24 +809,24 @@ func documentResources(args []js.Value) (any, error) { return currentSession.Reader.Resources(context.Background()) } -// resourceTargets 列出当前页可由普通对象接口编辑动作的图元 -// 入参: args 零基页码 +// resourceTargets 列出当前页可绑定指定资源的图元及已有绑定状态 +// 入参: args 零基页码、资源类型和标识 // 返回: any 图元列表, error 页面读取错误 func resourceTargets(args []js.Value) (any, error) { - if currentEditor == nil || len(args) != 1 { + if currentEditor == nil || len(args) != 3 { return nil, fmt.Errorf("no document is being edited") } - items, err := currentSession.Renderer.StructureObjects(args[0].Int()) - if err != nil { - return nil, err + return currentEditor.ResourceTargets(context.Background(), args[1].String(), args[2].String(), args[0].Int()) +} + +// resourceLocations 定位当前文档与版本中的资源引用实例 +// 入参: args 资源类型、标识和引用索引 +// 返回: any 页面图元范围, error 引用或度量错误 +func resourceLocations(args []js.Value) (any, error) { + if currentSession == nil || len(args) != 3 { + return nil, fmt.Errorf("ofd document is not opened") } - result := items[:0] - for _, item := range items { - if _, err := currentEditor.Object(args[0].Int(), item.ID); err == nil { - result = append(result, item) - } - } - return result, nil + return currentSession.Renderer.ResourceLocations(context.Background(), args[0].String(), args[1].String(), args[2].Int()) } // changeResource 调用库层资源编辑,浏览器不改写资源声明和包路径 diff --git a/ofdgo_editor_resource.go b/ofdgo_editor_resource.go index 6487740..b27ab6b 100644 --- a/ofdgo_editor_resource.go +++ b/ofdgo_editor_resource.go @@ -26,6 +26,14 @@ import ( "strings" ) +// ResourceTarget 描述当前页可绑定资源动作的图元,Bound表示已有该资源的点击动作 +type ResourceTarget struct { + ID string `json:"id"` + Type string `json:"type"` + Text string `json:"text,omitempty"` + Bound bool `json:"bound"` +} + // Resources 获取当前编辑快照的资源,覆盖正文、模板、注解、图案和复合图元 // 入参: ctx 取消上下文 // 返回: []ResourceInfo 资源及引用位置, error 快照或结构错误 @@ -37,6 +45,46 @@ func (e *Editor) Resources(ctx context.Context) ([]ResourceInfo, error) { return reader.Resources(ctx) } +// ResourceTargets 列出当前页可独立绑定音视频或附件动作的图元及绑定状态 +// 模板和共享复合资源的内部成员不作为普通页面图元返回 +// 入参: ctx 取消上下文, kind 为Audio、Video或Attachment, id 资源标识, page 零基页码 +// 返回: []ResourceTarget 按绘制顺序排列的候选, error 资源、页面或取消错误 +func (e *Editor) ResourceTargets(ctx context.Context, kind, id string, page int) ([]ResourceTarget, error) { + if kind != "Audio" && kind != "Video" && kind != "Attachment" { + return nil, fmt.Errorf("resource does not support actions") + } + items, err := e.Resources(ctx) + if err != nil { + return nil, err + } + if _, err := findResource(items, kind, id); err != nil { + return nil, err + } + content, err := e.page(page) + if err != nil { + return nil, err + } + var result []ResourceTarget + for _, layer := range content.Content.Layer { + for _, object := range layer.Objects { + if err := ctx.Err(); err != nil { + return nil, err + } + actions, err := resourceObjectActions(object) + if err != nil { + continue + } + target := ResourceTarget{ID: editorObjectID(object), Type: object.Type} + if object.Type == "TextObject" { + target.Text = object.TextObject.Text() + } + target.Bound = slices.ContainsFunc(actions, func(action Action) bool { return resourceActionMatches(action, kind, id) }) + result = append(result, target) + } + } + return result, ctx.Err() +} + // ImportMedia 导入可独立管理的音视频资源,即使尚未绑定动作也保留在当前文档中 // 不解码或转码,格式由调用方提供;修改隔离其他文档及历史版本,支持撤销 // 入参: ctx 取消上下文, kind 为Audio或Video, format 媒体格式, data 完整媒体数据 @@ -459,33 +507,13 @@ func (e *Editor) bindResourceAction(ctx context.Context, kind, id string, page i if err != nil { return err } - var actions []Action - switch target.Type { - case "TextObject": - actions = target.TextObject.Actions - case "ImageObject": - actions = target.ImageObject.Actions - case "Path", "PathObject": - actions = target.PathObject.Actions - case "CompositeObject", "CompositeGraphicUnit": - actions = target.CompositeGraphicUnit.Actions - default: - return fmt.Errorf("object does not support actions") + actions, err := resourceObjectActions(target) + if err != nil { + return err } found := false actions = slices.DeleteFunc(slices.Clone(actions), func(action Action) bool { - if action.Event != "CLICK" { - return false - } - match := false - switch kind { - case "Attachment": - match = action.GotoA != nil && action.GotoA.AttachID == id - case "Audio": - match = action.Sound != nil && sameResourceID(action.Sound.ResourceID, id) - case "Video": - match = action.Movie != nil && sameResourceID(action.Movie.ResourceID, id) && (action.Movie.Operator == "" || action.Movie.Operator == "Play") - } + match := resourceActionMatches(action, kind, id) found = found || match return match && !bind }) @@ -509,3 +537,40 @@ func (e *Editor) bindResourceAction(ctx context.Context, kind, id string, page i } return e.SetObjectActions(page, []string{object}, actions) } + +// resourceObjectActions 读取支持动作的图元,不复制或修改动作 +// 入参: object 页面图元 +// 返回: []Action 原动作, error 不支持动作的图元类型 +func resourceObjectActions(object GraphicObject) ([]Action, error) { + switch object.Type { + case "TextObject": + return object.TextObject.Actions, nil + case "ImageObject": + return object.ImageObject.Actions, nil + case "Path", "PathObject": + return object.PathObject.Actions, nil + case "CompositeObject", "CompositeGraphicUnit": + return object.CompositeGraphicUnit.Actions, nil + default: + return nil, fmt.Errorf("object does not support actions") + } +} + +// resourceActionMatches 检查资源的点击动作,视频停止或暂停不视为播放绑定 +// 入参: action 动作, kind 资源类型, id 资源标识 +// 返回: bool 是否匹配 +func resourceActionMatches(action Action, kind, id string) bool { + if action.Event != "CLICK" { + return false + } + switch kind { + case "Attachment": + return action.GotoA != nil && action.GotoA.AttachID == id + case "Audio": + return action.Sound != nil && sameResourceID(action.Sound.ResourceID, id) + case "Video": + return action.Movie != nil && sameResourceID(action.Movie.ResourceID, id) && (action.Movie.Operator == "" || action.Movie.Operator == "Play") + default: + return false + } +} diff --git a/ofdgo_resource_location.go b/ofdgo_resource_location.go new file mode 100644 index 0000000..9f5dce7 --- /dev/null +++ b/ofdgo_resource_location.go @@ -0,0 +1,176 @@ +// 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 ( + "context" + "fmt" + "maps" + "slices" + "strings" +) + +// ResourceLocation 保存资源引用的页面实例范围,Page从1开始,Bounds单位为毫米 +// Object为承载引用的图元标识;隐藏或完全裁剪的图元不产生定位结果 +type ResourceLocation struct { + Page int `json:"page"` + Object string `json:"object"` + Bounds Box `json:"bounds"` +} + +// ResourceLocations 定位当前文档及版本内的一处资源引用,返回各页面上的可见使用范围 +// 模板、图案和复合资源沿引用关系定位承载图元,不将资源声明位置当作页面位置 +// 入参: ctx 取消上下文, kind 资源类型, id 资源标识, reference 为Resources返回的引用索引 +// 返回: []ResourceLocation 页面位置,无可见图元时为空, error 引用、度量或取消错误 +func (r *Renderer) ResourceLocations(ctx context.Context, kind, id string, reference int) ([]ResourceLocation, error) { + catalog, err := r.Reader.resourceCatalog(ctx) + if err != nil { + return nil, err + } + item, err := findResource(catalog.items, kind, id) + if err != nil { + return nil, err + } + if reference < 0 || reference >= len(item.References) { + return nil, fmt.Errorf("resource reference not found") + } + ref := item.References[reference] + wanted := make(map[string]bool) + users := make(map[string][]string) + for _, file := range catalog.scan.files { + root, err := parseEditorXMLContext(ctx, file.data) + if err != nil { + return nil, err + } + var visit func(*editorXML, string) error + visit = func(node *editorXML, object string) error { + if err := ctx.Err(); err != nil { + return err + } + if !node.matchesOFD(node.name.Local) || node.matchesOFD("Attachments") { + return nil + } + if slices.Contains([]string{"TextObject", "PathObject", "ImageObject", "CompositeObject"}, node.name.Local) { + object = node.attr("ID") + } + owner := resourceReferenceOwner(node) + add := func(role, value string) { + if owner == "" { + return + } + if role != "AttachID" { + if dependency := editorResourceID(value); dependency != "" && dependency != owner { + users[dependency] = append(users[dependency], owner) + } + } + match := value == item.ID + if kind != "Attachment" { + match = sameResourceID(value, item.ID) + } + if file.name == ref.File && object == ref.Object && role == ref.Role && match { + wanted[owner] = true + } + } + for _, attr := range node.attrs { + if attr.Name.Space == "" && (editorResourceReferenceKind(attr.Name.Local, root.matchesOFD("Res")) == editorReferenceID || attr.Name.Local == "AttachID") { + add(attr.Name.Local, attr.Value) + } + } + if slices.Contains([]string{"Thumbnail", "Substitution"}, node.name.Local) { + add(node.name.Local, strings.TrimSpace(editorImportText(file.data, node))) + } + if owner != "" && node.parent != nil && node.parent.matchesOFD("Layer") { + layer := editorResourceID(node.parent.attr("ID")) + users[layer] = append(users[layer], owner) + } + for _, child := range node.children { + if err := visit(child, object); err != nil { + return err + } + } + return nil + } + if err := visit(root, ""); err != nil { + return nil, err + } + } + queue := slices.Collect(maps.Keys(wanted)) + for len(queue) != 0 { + if err := ctx.Err(); err != nil { + return nil, err + } + id := queue[0] + queue = queue[1:] + for _, user := range users[id] { + if !wanted[user] { + wanted[user] = true + queue = append(queue, user) + } + } + } + doc, err := r.Reader.DocContext(ctx) + if err != nil { + return nil, err + } + view := *r + view.RenderAnnotations, view.pageContext = true, ctx + var result []ResourceLocation + for page := range doc.Pages.Page { + if ref.Page >= 0 && ref.Page != page || len(wanted) == 0 { + continue + } + content, err := r.Reader.PageContentByIndexContext(ctx, page) + if err != nil { + return nil, err + } + targets := make(map[string]*StructureTarget, len(wanted)) + for id := range wanted { + targets[id] = &StructureTarget{ID: id} + } + visitor := &structureVisitor{ctx: ctx, renderer: &view, targets: targets} + if err := view.WalkPage(content, visitor); err != nil { + return nil, err + } + for _, id := range slices.Sorted(maps.Keys(targets)) { + box := targets[id].Bounds + if box.W > 0 && box.H > 0 { + result = append(result, ResourceLocation{Page: page + 1, Object: id, Bounds: box}) + } + } + } + if err := ctx.Err(); err != nil { + return nil, err + } + return result, nil +} + +// resourceReferenceOwner 查找承载引用的资源定义或最外层图元,图层只承接其自身的绘制参数 +// 入参: node 引用节点 +// 返回: string 图元或资源标识,无法映射时为空 +func resourceReferenceOwner(node *editorXML) string { + id := "" + for current := node; current != nil; current = current.parent { + if !current.matchesOFD(current.name.Local) { + return "" + } + if slices.Contains([]string{"TextObject", "PathObject", "ImageObject", "CompositeObject", "CompositeGraphicUnit"}, current.name.Local) || current.parent != nil && current.name.Local == editorResourceElement(current.parent.name.Local) { + id = editorResourceID(current.attr("ID")) + } + if id == "" && current.matchesOFD("Layer") { + id = editorResourceID(current.attr("ID")) + } + } + return id +}