diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index eb4b592..0c470d4 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -6,6 +6,7 @@ const COMPACT_LAYOUT = window.matchMedia("(max-width: 900px)"); const DEFAULT_IMAGE_DPI = 300; const PAGE_CACHE_LIMIT = 16; const PAGE_CACHE_BYTES = 32 * 1024 * 1024; +const OBJECT_CLIPBOARD_TYPE = "application/x-ofdgo-objects"; const STATUS = { ready: "选择 OFD 文件", opening: "正在打开文档", @@ -37,6 +38,7 @@ const state = { editorInfo: null, savedRevision: null, insertObject: null, + objectClipboard: null, textFonts: [], textFontID: null, textDefaults: { type: "TextObject", size: 12 * 25.4 / 72, color: "#000000", wrap: true, align: "left", paragraphHeight: 0, letterSpacing: 0 }, @@ -503,6 +505,7 @@ el.panButton.addEventListener("click", togglePan); el.continuousButton.addEventListener("click", toggleContinuous); el.annotationButton.addEventListener("click", toggleAnnotations); document.addEventListener("copy", copySelection); +el.viewerPanel.addEventListener("paste", pasteEditorContent); document.addEventListener("selectionchange", syncSelection); el.exportFormat.addEventListener("change", () => updateDPIControl()); el.exportPageButton.addEventListener("click", () => exportFile(false)); @@ -640,7 +643,7 @@ function handleKeyDown(event) { } if (key === "Enter" && target === el.pageInput && !event.shiftKey) { event.preventDefault(); - el.viewerPanel.focus({ preventScroll: true }); + if (!event.repeat) el.pageInput.dispatchEvent(new Event("change")); return; } if (key === "Enter" && target === el.searchInput && event.shiftKey && state.searchMatches.length) { @@ -742,6 +745,7 @@ async function createDocument(event) { return; } state.editing = true; + state.objectClipboard = null; canvasEditor.clear(); delete state.textDefaults.fontChoice; updateTextFonts(null, true); @@ -1143,11 +1147,14 @@ async function changeDocument(name, item, ...args) { if (!item || name === "ofdgoDeleteObject" || name === "ofdgoDeleteObjects") { canvasEditor.clear(); } - if (name === "ofdgoCopyObjects" || name === "ofdgoInsertShape" || name === "ofdgoInsertText") { + if (name === "ofdgoCopyObjects" || name === "ofdgoPasteObjects" || name === "ofdgoInsertShape" || name === "ofdgoInsertText" || name === "ofdgoInsertImage") { + state.selectObjects = true; + canvasEditor.setTool(""); + setPan(false); canvasEditor.pendingSelection = { index: item?.index ?? args[0], ids: doc.selectedIDs }; } openSeq = ++state.openSeq; - if (item || name === "ofdgoInsertShape" || name === "ofdgoInsertText") { + if (item || name === "ofdgoPasteObjects" || name === "ofdgoInsertShape" || name === "ofdgoInsertText" || name === "ofdgoInsertImage") { await refreshEditorPage(doc, item ? item.index : args[0], openSeq); } else { const samePage = doc.pages.findIndex((page) => page.id === previous.id); @@ -1668,6 +1675,7 @@ async function openOFD(file) { state.ofdBytes = bytes; state.fileName = file.name || "ofdgo.ofd"; state.editing = false; + state.objectClipboard = null; state.editorInfo = null; state.savedRevision = null; canvasEditor.clear(); @@ -2816,11 +2824,78 @@ async function selectDocumentText() { } } +function copyEditorSelection(event) { + if (!state.editing || !canvasEditor.enabled || !canvasEditor.selected || !el.viewerPanel.contains(event.target) + || canvasEditor.input || canvasEditor.crop || canvasEditor.drag || document.body.hasAttribute("aria-busy")) return false; + const items = canvasEditor.items().slice().sort((a, b) => a.order - b.order); + const clipboard = { token: crypto.randomUUID(), page: state.doc.pages[items[0].index].id, offset: 0 }; + event.clipboardData.setData(OBJECT_CLIPBOARD_TYPE, clipboard.token); + event.clipboardData.setData("text/plain", items.map(item => item.text || "").filter(Boolean).join("\n")); + event.preventDefault(); + state.objectClipboard = clipboard; + clipboard.ready = callWASM("ofdgoCaptureObjects", items[0].index, items.map(item => item.id), clipboard.token) + .then(() => true, err => { + if (state.objectClipboard === clipboard) showError(err, false); + return false; + }); + return true; +} + +async function pasteEditorContent(event) { + if (!state.editing || event.target.closest?.("input, textarea, select, [contenteditable]") || formDialogOpen()) return; + event.preventDefault(); + if (document.body.hasAttribute("aria-busy") || canvasEditor.input || canvasEditor.crop || canvasEditor.drag) return; + const token = event.clipboardData.getData(OBJECT_CLIPBOARD_TYPE); + const index = state.pageIndex, page = state.doc.pages[index]; + if (token) { + const clipboard = state.objectClipboard; + if (!clipboard || token !== clipboard.token) { + setStatus("对象剪贴板已失效"); + return; + } + const openSeq = state.openSeq; + if (!await clipboard.ready || openSeq !== state.openSeq || index !== state.pageIndex || state.objectClipboard !== clipboard) return; + const offset = clipboard.page === page.id ? clipboard.offset + 3 : 0; + if (await changeDocument("ofdgoPasteObjects", null, index, token, offset, offset)) { + clipboard.page = page.id; + clipboard.offset = offset; + } + return; + } + const files = Array.from(event.clipboardData.files); + const x = Math.min(20, page.width / 10), y = Math.min(20, page.height / 10); + if (files.length) { + if (files.length !== 1) { + setStatus("一次粘贴一张图片"); + return; + } + if (!/^image\/(png|jpeg)$/.test(files[0].type)) { + setStatus("图片仅支持 PNG、JPG"); + return; + } + await changeDocument("ofdgoInsertImage", null, index, files[0].arrayBuffer().then(bytes => new Uint8Array(bytes)), x, y, page.width - x * 2); + return; + } + const value = event.clipboardData.getData("text/plain").replace(/\t/g, " "); + if (!value.trim()) return; + const font = state.textFonts[Number(el.textFont.value)]; + if (!font) { + setStatus("尚未添加字体"); + el.textFontAdd.focus(); + return; + } + const style = currentTextStyle(); + const data = font.embedded ? callWASM("ofdgoEditorFont", font.id.slice(9)).then(result => result.bytes) : fontManager.read(font); + await changeDocument("ofdgoInsertText", null, index, value, data, x, y, style.size, style.color, + page.width - x * 2, style.wrap, style.align || "left", style.paragraphHeight || 0, style.letterSpacing || 0); +} + function copySelection(event) { if (event.target.closest?.("input, textarea, [contenteditable]")) { return; } const selection = document.getSelection(); + if (selection.isCollapsed && copyEditorSelection(event)) return; if (selection.isCollapsed || !selection.rangeCount) { return; } diff --git a/assets/webui/ofdgo_edit.js b/assets/webui/ofdgo_edit.js index a408f0c..6f436f2 100644 --- a/assets/webui/ofdgo_edit.js +++ b/assets/webui/ofdgo_edit.js @@ -27,17 +27,19 @@ export function selectionBounds(items) { height: Math.max(...items.map(item => item.y + item.height)) - y }; } -function alignmentGuides(box, targets, tolerance) { +function alignmentSnap(box, targets, tolerance) { const lines = []; + const offset = { x: 0, y: 0 }; for (const [axis, size, cross, span] of [["x", "width", "y", "height"], ["y", "height", "x", "width"]]) { let distance = Infinity, line; for (const target of targets) { for (const anchor of [0, 0.5, 1]) { const position = target[axis] + target[size] * anchor; for (const edge of [0, 0.5, 1]) { - const delta = Math.abs(box[axis] + box[size] * edge - position); - if (delta > tolerance[axis] || delta >= distance) continue; - distance = delta; + const delta = position - box[axis] - box[size] * edge; + if (Math.abs(delta) > tolerance[axis] || Math.abs(delta) >= distance) continue; + distance = Math.abs(delta); + offset[axis] = delta; const from = Math.min(box[cross], target[cross]); const to = Math.max(box[cross] + box[span], target[cross] + target[span]); line = axis === "x" ? `M${position} ${from}V${to}` : `M${from} ${position}H${to}`; @@ -46,7 +48,7 @@ function alignmentGuides(box, targets, tolerance) { } if (line) lines.push(line); } - return lines.join(""); + return { ...offset, path: lines.join("") }; } function transformedBox(box, matrix) { @@ -422,16 +424,18 @@ export class CanvasEditor { return; } drag.change = objectTransform(drag.item, to.x - from.x, to.y - from.y, drag.corner); + if (!drag.corner) this.snap(drag, event.altKey); this.place(drag.item, drag.change); - if (!drag.corner) this.showGuides(drag); } - showGuides(drag) { + snap(drag, bypass = false) { const { item, rect, rotation, change } = drag; - const path = alignmentGuides({ ...item, x: item.x + change.x, y: item.y + change.y }, drag.targets, { + const { x, y, path } = bypass ? { x: 0, y: 0, path: "" } : alignmentSnap({ ...item, x: item.x + change.x, y: item.y + change.y }, drag.targets, { x: 4 * item.page.width / (rotation % 180 ? rect.height : rect.width), y: 4 * item.page.height / (rotation % 180 ? rect.width : rect.height), }); + change.x += x; + change.y += y; if (!drag.guides && path) { drag.guides = this.createPreview(item.surface, item.page, "path", { fill: false, stroke: true, strokeColor: "var(--accent)", lineWidth: 1, @@ -620,9 +624,9 @@ export class CanvasEditor { modifierChange(event) { const pointer = this.drag?.pointer; - if (event.key === "Shift" && pointer) { + if ((event.key === "Shift" || event.key === "Alt") && pointer) { this.move({ pointerId: pointer.pointerId, clientX: pointer.clientX, clientY: pointer.clientY, - shiftKey: event.shiftKey, preventDefault() {} }); + shiftKey: event.shiftKey, altKey: event.altKey, preventDefault() {} }); } } diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index 4bc5986..962892c 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -114,6 +114,8 @@ func RunWASM() { registerCallback("ofdgoAlignObjects", alignObjects) registerCallback("ofdgoDeleteObjects", deleteObjects) registerCallback("ofdgoCopyObjects", copyObjects) + registerCallback("ofdgoCaptureObjects", captureObjects) + registerCallback("ofdgoPasteObjects", pasteObjects) registerCallback("ofdgoOrderObjects", orderObjects) registerCallback("ofdgoDistributeObjects", distributeObjects) registerCallback("ofdgoRotateObjects", rotateObjects) @@ -197,6 +199,7 @@ func openDocument(args []js.Value) (any, error) { } renderAnnotations := args[2].Bool() currentEditor = nil + copiedObjects = nil if currentSession != nil { _ = currentSession.Close() currentSession = nil @@ -530,6 +533,15 @@ func encodeResult(result apiResult) string { // currentEditor 当前新建文档 var currentEditor *ofdgo.Editor +// editorClipboard 当前创作文档中的对象快照与剪贴板标识。 +type editorClipboard struct { + token string + objects []ofdgo.GraphicObject +} + +// copiedObjects 当前对象剪贴板,不保存字体或图片的重复数据。 +var copiedObjects *editorClipboard + // editorInfo 创作文档信息与操作状态 type editorInfo struct { DocumentInfo @@ -814,7 +826,43 @@ func copyObjects(args []js.Value) (any, error) { if currentEditor == nil { return nil, fmt.Errorf("no document is being created") } - ids, err := currentEditor.CopyObjects(args[0].Int(), stringsFromJS(args[1]), args[2].Float(), args[3].Float()) + objects, err := currentEditor.Objects(args[0].Int(), stringsFromJS(args[1])) + if err != nil { + return nil, err + } + return copyEditorObjects(args[0].Int(), objects, args[2].Float(), args[3].Float()) +} + +// captureObjects 保存独立选区快照,不修改文档或历史。 +// 入参: args 页面索引、对象标识数组和剪贴板标识 +// 返回: any 空结果, error 错误信息 +func captureObjects(args []js.Value) (any, error) { + if currentEditor == nil { + return nil, fmt.Errorf("no document is being created") + } + objects, err := currentEditor.Objects(args[0].Int(), stringsFromJS(args[1])) + if err != nil { + return nil, err + } + copiedObjects = &editorClipboard{args[2].String(), objects} + return nil, nil +} + +// pasteObjects 将当前文档的对象快照粘贴到目标页。 +// 入参: args 目标页、剪贴板标识和横纵位移 +// 返回: any 文档信息及新选区, error 错误信息 +func pasteObjects(args []js.Value) (any, error) { + if currentEditor == nil || copiedObjects == nil || copiedObjects.token != args[1].String() { + return nil, fmt.Errorf("object clipboard is no longer available") + } + return copyEditorObjects(args[0].Int(), copiedObjects.objects, args[2].Float(), args[3].Float()) +} + +// copyEditorObjects 复制快照并更新预览,返回新对象标识。 +// 入参: page 目标页, objects 对象快照, dx、dy 毫米位移 +// 返回: any 文档信息及新选区, error 错误信息 +func copyEditorObjects(page int, objects []ofdgo.GraphicObject, dx, dy float64) (any, error) { + ids, err := currentEditor.CopyObjects(page, objects, dx, dy) if err != nil { return nil, err } @@ -928,6 +976,9 @@ func previewEditor(editor *ofdgo.Editor, annotations bool) (editorInfo, error) { _ = currentSession.Close() } currentSession = session + if currentEditor != editor { + copiedObjects = nil + } currentEditor = editor return editorSummary(), nil } diff --git a/ofdgo_editor_batch.go b/ofdgo_editor_batch.go index 44d4c28..bf0e032 100644 --- a/ofdgo_editor_batch.go +++ b/ofdgo_editor_batch.go @@ -24,23 +24,46 @@ import ( "github.com/tdewolff/canvas" ) -// CopyObjects 按原绘制顺序复制同页对象并平移,复用资源,提交一次撤销记录。 -// 入参: page 页面索引, ids 对象标识, dx、dy 毫米位移 -// 返回: []string 按绘制顺序排列的新对象标识, error 错误信息 -func (e *Editor) CopyObjects(page int, ids []string, dx, dy float64) ([]string, error) { - if !finite(dx) || !finite(dy) { - return nil, fmt.Errorf("copy requires finite offsets") - } +// Objects 按绘制顺序获取选区的独立快照,保留编辑中的段落信息。 +// 入参: page 页面索引, ids 对象标识,不得重复 +// 返回: []GraphicObject 独立对象副本, error 错误信息 +func (e *Editor) Objects(page int, ids []string) ([]GraphicObject, error) { _, indexes, err := e.selectedObjects(page, ids) if err != nil { return nil, err } slices.Sort(indexes) - before := e.pages[page].Content.Layer[0].Objects objects := make([]GraphicObject, len(indexes)) - result := make([]string, len(indexes)) for i, index := range indexes { - object, err := cloneEditorObject(before[index]) + objects[i], err = cloneEditorObject(e.pages[page].Content.Layer[0].Objects[index]) + if err != nil { + return nil, err + } + } + return objects, nil +} + +// CopyObjects 将对象快照按输入顺序复制到目标页并平移,分配新ID,提交一次撤销记录。 +// 对象引用当前Editor已注册的资源,保留段落信息,不修改输入快照。 +// 入参: page 目标页面索引, objects 对象快照, dx、dy 毫米位移 +// 返回: []string 按绘制顺序排列的新对象标识, error 错误信息 +func (e *Editor) CopyObjects(page int, objects []GraphicObject, dx, dy float64) ([]string, error) { + if !finite(dx) || !finite(dy) { + return nil, fmt.Errorf("copy requires finite offsets") + } + content, err := e.page(page) + if err != nil { + return nil, err + } + if len(objects) == 0 { + return nil, nil + } + before := content.Content.Layer[0].Objects + prepared := make([]GraphicObject, len(objects)) + result := make([]string, len(objects)) + for i, object := range objects { + result[i] = strconv.Itoa(e.maxID + i + 1) + object, err := e.prepareObject(result[i], object) if err != nil { return nil, err } @@ -48,14 +71,13 @@ func (e *Editor) CopyObjects(page int, ids []string, dx, dy float64) ([]string, if err != nil { return nil, err } - result[i] = strconv.Itoa(e.maxID + i + 1) - objects[i], err = e.prepareObject(result[i], object) + prepared[i], err = e.prepareObject(result[i], object) if err != nil { return nil, err } } e.maxID += len(objects) - e.replaceObjects(page, append(slices.Clone(before), objects...)) + e.replaceObjects(page, append(slices.Clone(before), prepared...)) return result, nil }