diff --git a/assets/webui/index.html b/assets/webui/index.html index c40cd20..b83faac 100644 --- a/assets/webui/index.html +++ b/assets/webui/index.html @@ -83,11 +83,16 @@ + 对象
-
pt
+
pt
文字
@@ -99,7 +104,7 @@ - + 段落
@@ -113,7 +118,7 @@
-
pt
+ 图形
diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css index ed8fae2..be4f67e 100644 --- a/assets/webui/ofdgo.css +++ b/assets/webui/ofdgo.css @@ -121,15 +121,68 @@ a:focus-visible { grid-template-columns: repeat(4, max-content); } -.form-unit.paragraph-height { +.editor-field { + display: grid; + align-items: center; grid-column: 1 / -1; - grid-template-columns: auto 58px auto; - gap: 4px; + grid-template-columns: 2em minmax(0, 1fr); + gap: 6px; + white-space: nowrap; } -.paragraph-height input { - width: 58px; +.editor-number { + display: flex; + align-items: center; min-width: 0; + height: var(--control-height); + border: 1px solid var(--line); + background: #fff; +} + +.editor-number:focus-within { + outline: 2px solid var(--focus); + outline-offset: 2px; +} + +.editor-number input { + width: 100%; + min-width: 0; + height: 100%; + padding: 0 6px; + border: 0; + outline: 0; + background: transparent; + color: #1f2428; + text-align: right; + font-variant-numeric: tabular-nums; +} + +.editor-number > span { + padding-right: 6px; + color: var(--text-muted); + font-size: 12px; +} + +.editor-number:has(input:disabled) { + opacity: 0.5; +} + +.editor-number input:disabled { + opacity: 1; +} + +.editor-group:has(#shapeFill) .editor-field { + grid-template-columns: 2em 100px; + justify-content: end; +} + +.editor-group:has(#textFont) { + grid-template-columns: 100px 54px; +} + +.editor-paragraph { + width: 172px; + grid-template-columns: repeat(2, minmax(0, 1fr)); } .editor-file #saveButton { @@ -353,17 +406,14 @@ button:not(:disabled):hover { } .page-control input, -.paragraph-height input, -.text-size input { +.editor-number input { appearance: textfield; } .page-control input::-webkit-inner-spin-button, .page-control input::-webkit-outer-spin-button, -.paragraph-height input::-webkit-inner-spin-button, -.paragraph-height input::-webkit-outer-spin-button, -.text-size input::-webkit-inner-spin-button, -.text-size input::-webkit-outer-spin-button { +.editor-number input::-webkit-inner-spin-button, +.editor-number input::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; } @@ -468,9 +518,7 @@ button:not(:disabled):hover { .form-row input[type="number"], .form-row input[type="file"], .form-row select, -.form-row textarea, -.paragraph-height input, -.text-size input { +.form-row textarea { width: 100%; min-width: 0; height: var(--control-height); @@ -489,11 +537,6 @@ button:not(:disabled):hover { resize: vertical; } -.form-unit.text-size { - grid-template-columns: calc(2em + 22px) auto; - gap: 6px; -} - .color-input { width: calc(2em + 22px); height: var(--control-height); @@ -1201,7 +1244,7 @@ button.outline-link:hover { pointer-events: none; } -.editing-draw .page-surface { +.editing-draw { cursor: crosshair; touch-action: none; user-select: none; diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index 135eed0..5a549a7 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -1,4 +1,4 @@ -import { CanvasEditor, objectTransform } from "./ofdgo_edit.js"; +import { CanvasEditor } from "./ofdgo_edit.js"; import { FontManager } from "./ofdgo_font.js"; const MM_TO_PX = 96 / 25.4; @@ -92,6 +92,7 @@ const el = { copyObjectButton: document.querySelector("#copyObjectButton"), objectOrder: document.querySelector("#objectOrder"), objectAlign: document.querySelector("#objectAlign"), + objectDistribute: document.querySelector("#objectDistribute"), multiSelectButton: document.querySelector("#multiSelectButton"), textAlign: document.querySelector("#textAlign"), textWrap: document.querySelector("#textWrap"), @@ -341,11 +342,7 @@ editorClick(el.copyObjectButton, () => { if (!item) { return; } - let change = objectTransform(item, item.page, 3, 3); - if (!change.x && !change.y) { - change = objectTransform(item, item.page, -3, -3); - } - return changeDocument("ofdgoCopyObject", item, change.x, change.y); + return changeDocument("ofdgoCopyObjects", { ...item, id: canvasEditor.items().map(member => member.id) }, 3, 3); }); el.objectOrder.addEventListener("change", () => { const action = el.objectOrder.value; @@ -354,8 +351,7 @@ el.objectOrder.addEventListener("change", () => { if (!item || !action) { return; } - const targets = { up: item.order + 1, down: item.order - 1, top: item.count - 1, bottom: 0 }; - return changeDocument("ofdgoMoveObject", item, targets[action]); + return changeDocument("ofdgoOrderObjects", { ...item, id: canvasEditor.items().map(member => member.id) }, action); }); el.objectAlign.addEventListener("change", () => { const alignment = el.objectAlign.value; @@ -364,6 +360,13 @@ el.objectAlign.addEventListener("change", () => { return changeDocument(canvasEditor.selected.items ? "ofdgoAlignObjects" : "ofdgoAlignObject", canvasEditor.selected, alignment); } }); +el.objectDistribute.addEventListener("change", () => { + const axis = el.objectDistribute.value; + el.objectDistribute.value = ""; + if (axis && canvasEditor.items().length >= 3) { + return changeDocument("ofdgoDistributeObjects", canvasEditor.selected, axis); + } +}); editorClick(el.undoButton, () => changeDocument("ofdgoUndo")); editorClick(el.redoButton, () => changeDocument("ofdgoRedo")); for (const [button, tool] of [[el.drawLineButton, "line"], [el.drawRectangleButton, "rectangle"], [el.drawEllipseButton, "ellipse"]]) { @@ -885,7 +888,7 @@ async function insertObject(event) { return; } setEditorInfo(doc); - canvasEditor.selectLast = !item; + canvasEditor.pendingSelection = item ? null : { index }; state.selectObjects = true; setPan(false); openSeq = ++state.openSeq; @@ -1031,8 +1034,8 @@ async function changeDocument(name, item, ...args) { if (!item || name === "ofdgoDeleteObject" || name === "ofdgoDeleteObjects") { canvasEditor.clear(); } - if (name === "ofdgoCopyObject" || name === "ofdgoInsertShape" || name === "ofdgoInsertText") { - canvasEditor.selectLast = true; + if (name === "ofdgoCopyObjects" || name === "ofdgoInsertShape" || name === "ofdgoInsertText") { + canvasEditor.pendingSelection = { index: item?.index ?? args[0], ids: doc.selectedIDs }; } openSeq = ++state.openSeq; if (item || name === "ofdgoInsertShape" || name === "ofdgoInsertText") { @@ -4127,7 +4130,8 @@ function updateEditorTools() { function updateObjectControls(item, reset = false) { const disabled = !item || Boolean(item.draft) || !state.ready || state.exporting; el.deleteObjectButton.disabled = el.objectAlign.disabled = disabled; - el.copyObjectButton.disabled = disabled || Boolean(item?.items); + el.copyObjectButton.disabled = disabled; + el.objectDistribute.disabled = disabled || !item.items || item.items.length < 3; el.editObjectButton.disabled = disabled || Boolean(item.items) || item.type === "PathObject"; el.multiSelectButton.disabled = !state.editing || !canvasEditor.enabled || !state.ready || state.exporting; const text = item?.type === "TextObject" ? item : state.textDefaults; @@ -4158,9 +4162,14 @@ function updateObjectControls(item, reset = false) { } } updateDrawingControls(); - el.objectOrder.disabled = disabled || Boolean(item.items) || item.count <= 1; + const members = item?.items || (item ? [item] : []); + const orders = members.map(member => member.order).sort((a, b) => a - b); + const count = members[0]?.count || 0; + const canRaise = orders.some((order, i) => order !== count - orders.length + i); + const canLower = orders.some((order, i) => order !== i); + el.objectOrder.disabled = disabled || count <= orders.length; for (const option of el.objectOrder.options) { - option.disabled = disabled || (["up", "top"].includes(option.value) ? item.order === item.count - 1 : item.order === 0); + option.disabled = disabled || (["up", "top"].includes(option.value) ? !canRaise : !canLower); } } diff --git a/assets/webui/ofdgo_edit.js b/assets/webui/ofdgo_edit.js index c0b4fad..ab52201 100644 --- a/assets/webui/ofdgo_edit.js +++ b/assets/webui/ofdgo_edit.js @@ -8,25 +8,16 @@ export function pagePoint(x, y, rect, page, rotation) { return { x: point[0] * page.width, y: point[1] * page.height }; } -export function objectTransform(box, page, dx, dy, corner = "") { +export function objectTransform(box, dx, dy, corner = "") { if (!corner) { - return { - x: Math.max(-box.x, Math.min(dx, Math.max(-box.x, page.width - box.x - box.width))), - y: Math.max(-box.y, Math.min(dy, Math.max(-box.y, page.height - box.y - box.height))), - scale: 1, - }; + return { x: dx, y: dy, scale: 1 }; } const left = corner.includes("w"); const top = corner.includes("n"); const x = left ? -dx : dx; const y = top ? -dy : dy; - const limit = Math.min( - (left ? box.x + box.width : page.width - box.x) / box.width, - (top ? box.y + box.height : page.height - box.y) / box.height, - ); const minimum = Math.min(1, 1 / Math.min(box.width, box.height)); - const scale = Math.max(Math.min(minimum, limit), Math.min(limit, - 1 + (x * box.width + y * box.height) / (box.width ** 2 + box.height ** 2))); + const scale = Math.max(minimum, 1 + (x * box.width + y * box.height) / (box.width ** 2 + box.height ** 2)); return { x: left ? box.width * (1 - scale) : 0, y: top ? box.height * (1 - scale) : 0, scale }; } @@ -37,8 +28,8 @@ export function selectionBounds(items) { height: Math.max(...items.map(item => item.y + item.height)) - y }; } -// constrainedPoint applies drawing constraints before clipping the endpoint to the page. -export function constrainedPoint(from, to, page, shape, shift) { +// constrainedPoint preserves shape constraints without changing off-page geometry. +export function constrainedPoint(from, to, shape, shift) { let dx = to.x - from.x, dy = to.y - from.y; if (shift && shape === "line") { const directions = [[1, 0], [1, 1], [0, 1], [-1, 1], [-1, 0], [-1, -1], [0, -1], [1, -1]]; @@ -51,37 +42,31 @@ export function constrainedPoint(from, to, page, shape, shift) { dx = (dx < 0 ? -1 : 1) * size; dy = (dy < 0 ? -1 : 1) * size; } - if (!shift) { - return { x: Math.max(0, Math.min(to.x, page.width)), y: Math.max(0, Math.min(to.y, page.height)) }; - } - const limit = Math.min(1, - dx ? (dx > 0 ? page.width - from.x : -from.x) / dx : 1, - dy ? (dy > 0 ? page.height - from.y : -from.y) / dy : 1); - return { x: from.x + dx * limit, y: from.y + dy * limit }; + return { x: from.x + dx, y: from.y + dy }; } // reshapeBox moves line endpoints or resizes basic shapes without scaling their stroke. export function reshapeBox(item, dx, dy, handle, shift) { - const box = item.geometry, page = item.page; + const box = item.geometry; if (!dx && !dy && !shift) return { ...box }; if (item.shape === "line") { const start = { x: box.x, y: box.y }, end = { x: box.x + box.width, y: box.y + box.height }; const fixed = handle === "start" ? end : start, moving = handle === "start" ? start : end; - const point = constrainedPoint(fixed, { x: moving.x + dx, y: moving.y + dy }, page, "line", shift); + const point = constrainedPoint(fixed, { x: moving.x + dx, y: moving.y + dy }, "line", shift); const from = handle === "start" ? point : fixed, to = handle === "start" ? fixed : point; return from.x === to.x && from.y === to.y ? null : { x: from.x, y: from.y, width: to.x - from.x, height: to.y - from.y }; } if (shift && handle.length === 2) { - const change = objectTransform(box, page, dx, dy, handle); + const change = objectTransform(box, dx, dy, handle); return { x: box.x + change.x, y: box.y + change.y, width: box.width * change.scale, height: box.height * change.scale }; } let x = box.x, y = box.y, right = x + box.width, bottom = y + box.height; const minWidth = Math.min(1, box.width), minHeight = Math.min(1, box.height); - if (handle.includes("w")) x = Math.max(0, Math.min(x + dx, right - minWidth)); - if (handle.includes("e")) right = Math.min(page.width, Math.max(right + dx, x + minWidth)); - if (handle.includes("n")) y = Math.max(0, Math.min(y + dy, bottom - minHeight)); - if (handle.includes("s")) bottom = Math.min(page.height, Math.max(bottom + dy, y + minHeight)); + if (handle.includes("w")) x = Math.min(x + dx, right - minWidth); + if (handle.includes("e")) right = Math.max(right + dx, x + minWidth); + if (handle.includes("n")) y = Math.min(y + dy, bottom - minHeight); + if (handle.includes("s")) bottom = Math.max(bottom + dy, y + minHeight); return { x, y, width: right - x, height: bottom - y }; } @@ -144,7 +129,9 @@ export class CanvasEditor { } mount(index, page, surface) { - const selected = this.items().filter(item => item.index === index).map(item => item.id); + const pending = this.pendingSelection?.index === index; + const selected = pending ? this.pendingSelection.ids || page.objects.slice(-1).map(item => item.id) + : this.items().filter(item => item.index === index).map(item => item.id); const selection = []; this.pages.set(surface, { index, width: page.width, height: page.height }); const layer = document.createElement("div"); @@ -178,10 +165,7 @@ export class CanvasEditor { } surface.append(layer); if (selected.length) this.setSelection(selection); - if (this.selectLast && layer.lastElementChild) { - this.selectLast = false; - this.select(this.nodes.get(layer.lastElementChild)); - } + if (pending) this.pendingSelection = null; } place(item, change = { x: 0, y: 0, scale: 1 }) { @@ -249,7 +233,7 @@ export class CanvasEditor { clear() { this.closeText(); this.cancel(); - this.selectLast = false; + this.pendingSelection = null; this.select(null); } @@ -319,7 +303,7 @@ export class CanvasEditor { const from = pagePoint(drag.clientX, drag.clientY, drag.rect, drag.item.page, drag.rotation); const to = pagePoint(event.clientX, event.clientY, drag.rect, drag.item.page, drag.rotation); if (drag.item.type === "TextObject" && (drag.corner === "w" || drag.corner === "e")) { - drag.box = reshapeBox({ geometry: drag.item, page: drag.item.page }, to.x - from.x, 0, drag.corner, false); + drag.box = reshapeBox({ geometry: drag.item }, to.x - from.x, 0, drag.corner, false); Object.assign(drag.item.node.style, { left: `${drag.box.x * PX_PER_MM}px`, width: `${drag.box.width * PX_PER_MM}px` }); return; } @@ -329,7 +313,7 @@ export class CanvasEditor { if (drag.node) paintShape(drag.node, drag.item.shape, drag.box || drag.item.geometry); return; } - drag.change = objectTransform(drag.item, drag.item.page, to.x - from.x, to.y - from.y, drag.corner); + drag.change = objectTransform(drag.item, to.x - from.x, to.y - from.y, drag.corner); this.place(drag.item, drag.change); } @@ -421,7 +405,7 @@ export class CanvasEditor { // moveMarquee includes intersecting objects while preserving an additive selection. moveMarquee(event, drag) { const from = pagePoint(drag.clientX, drag.clientY, drag.rect, drag.page, drag.rotation); - const to = constrainedPoint(from, pagePoint(event.clientX, event.clientY, drag.rect, drag.page, drag.rotation), drag.page, "rectangle", false); + 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); if (Math.hypot(event.clientX-drag.clientX, event.clientY-drag.clientY) < 3) return; @@ -436,7 +420,7 @@ export class CanvasEditor { } startShape(event) { - const surface = event.target.closest(".page-surface"); + const surface = event.target.closest(".page-surface") || (this.tool !== "text" && this.nearestSurface(event)); const page = this.pages.get(surface); if (!page) { return; @@ -455,6 +439,22 @@ export class CanvasEditor { this.viewer.setPointerCapture(event.pointerId); } + // nearestSurface assigns a pasteboard gesture to one mounted page for its entire duration. + nearestSurface(event) { + let nearest = null, distance = Infinity; + for (const surface of this.viewer.querySelectorAll(".page-surface")) { + if (!this.pages.has(surface)) continue; + const rect = surface.getBoundingClientRect(); + const next = Math.hypot(Math.max(rect.left - event.clientX, 0, event.clientX - rect.right), + Math.max(rect.top - event.clientY, 0, event.clientY - rect.bottom)); + if (next < distance) { + nearest = surface; + distance = next; + } + } + return nearest; + } + // createPreview provides a temporary SVG overlay without rerendering the page during a drag. createPreview(surface, page, shape, style) { const preview = document.createElementNS("http://www.w3.org/2000/svg", "svg"); @@ -472,10 +472,10 @@ export class CanvasEditor { moveShape(event, drag) { const point = (x, y) => { const p = pagePoint(x, y, drag.rect, drag.page, drag.rotation); - return { x: Math.max(0, Math.min(p.x, drag.page.width)), y: Math.max(0, Math.min(p.y, drag.page.height)) }; + return drag.shape === "text" ? { x: Math.max(0, Math.min(p.x, drag.page.width)), y: Math.max(0, Math.min(p.y, drag.page.height)) } : p; }; const from = point(drag.clientX, drag.clientY); - const to = constrainedPoint(from, pagePoint(event.clientX, event.clientY, drag.rect, drag.page, drag.rotation), drag.page, drag.shape, event.shiftKey && drag.shape !== "text"); + const to = constrainedPoint(from, point(event.clientX, event.clientY), drag.shape, event.shiftKey && drag.shape !== "text"); const x = Math.min(from.x, to.x), y = Math.min(from.y, to.y); const width = Math.abs(to.x - from.x), height = drag.shape === "text" ? Math.max(5, Math.abs(to.y - from.y)) : Math.abs(to.y - from.y); const valid = Math.hypot(event.clientX - drag.clientX, event.clientY - drag.clientY) >= 3 @@ -531,11 +531,7 @@ export class CanvasEditor { const [x, y] = directions[event.key]; const [dx, dy] = [[x, y], [y, -x], [-x, -y], [-y, x]][this.options.rotation() / 90]; const step = event.shiftKey ? 10 : 1; - const change = objectTransform(this.selected, this.selected.page, - dx * step, dy * step); - if (change.x || change.y) { - this.options.onTransform(this.selected, change); - } + this.options.onTransform(this.selected, { x: dx * step, y: dy * step, scale: 1 }); } } diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index 720f992..9ffd749 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -113,8 +113,9 @@ func RunWASM() { registerCallback("ofdgoTransformObjects", transformObjects) registerCallback("ofdgoAlignObjects", alignObjects) registerCallback("ofdgoDeleteObjects", deleteObjects) - registerCallback("ofdgoCopyObject", copyObject) - registerCallback("ofdgoMoveObject", moveObject) + registerCallback("ofdgoCopyObjects", copyObjects) + registerCallback("ofdgoOrderObjects", orderObjects) + registerCallback("ofdgoDistributeObjects", distributeObjects) registerCallback("ofdgoDeleteObject", deleteObject) registerCallback("ofdgoUndo", func([]js.Value) (any, error) { return restoreEditor(false) }) registerCallback("ofdgoRedo", func([]js.Value) (any, error) { return restoreEditor(true) }) @@ -542,6 +543,12 @@ type editorPageInfo struct { PageIndex int `json:"pageIndex"` } +// editorSelectionInfo 对象复制结果及新选区。 +type editorSelectionInfo struct { + editorInfo + SelectedIDs []string `json:"selectedIDs"` +} + // editorSummary 获取当前创作文档状态 // 返回: editorInfo 文档信息 func editorSummary() editorInfo { @@ -780,55 +787,38 @@ func reshapeObject(args []js.Value) (any, error) { return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) } -// copyObject 复制选中对象并平移,复用字体和图片资源 -// 入参: args 页码、对象标识和位移 +// copyObjects 复制选区并返回新对象标识,复用字体和图片资源 +// 入参: args 页码、对象标识列表和位移 // 返回: any 文档信息, error 错误信息 -func copyObject(args []js.Value) (any, error) { +func copyObjects(args []js.Value) (any, error) { if currentEditor == nil { return nil, fmt.Errorf("no document is being created") } - page := args[0].Int() - object, err := currentEditor.Object(page, args[1].String()) + ids, err := currentEditor.CopyObjects(args[0].Int(), stringsFromJS(args[1]), args[2].Float(), args[3].Float()) if err != nil { return nil, err } - var boundary *string - switch object.Type { - case "TextObject": - boundary = &object.TextObject.Boundary - case "ImageObject": - boundary = &object.ImageObject.Boundary - case "PathObject": - boundary = &object.PathObject.Boundary - default: - return nil, fmt.Errorf("unsupported object type %q", object.Type) - } - box, err := ofdgo.ParseBox(*boundary) - if err != nil { - return nil, err - } - *boundary = fmt.Sprintf("%g %g %g %g", box.X+args[2].Float(), box.Y+args[3].Float(), box.W, box.H) - if _, err := currentEditor.AddObject(page, object); err != nil { - return nil, err - } - return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) -} - -// moveObject 调整选中对象的绘制顺序 -// 入参: args 页码、对象标识和目标索引 -// 返回: any 文档信息, error 错误信息 -func moveObject(args []js.Value) (any, error) { - if currentEditor == nil { - return nil, fmt.Errorf("no document is being created") - } - revision := currentEditor.Revision() - if err := currentEditor.MoveObject(args[0].Int(), args[1].String(), args[2].Int()); err != nil { - return nil, err - } - if currentEditor.Revision() == revision { + if len(ids) == 0 { return editorSummary(), nil } - return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) + info, err := previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) + return editorSelectionInfo{info, ids}, err +} + +// orderObjects 调整选区的绘制顺序 +// 入参: args 页码、对象标识列表和层级动作 +// 返回: any 文档信息, error 错误信息 +func orderObjects(args []js.Value) (any, error) { + return changeObjects(func() error { + return currentEditor.OrderObjects(args[0].Int(), stringsFromJS(args[1]), args[2].String()) + }) +} + +// distributeObjects 按指定轴等距分布选区。 +func distributeObjects(args []js.Value) (any, error) { + return changeObjects(func() error { + return currentEditor.DistributeObjects(args[0].Int(), stringsFromJS(args[1]), args[2].String()) + }) } // deleteObject 删除创作画布中选中的对象 @@ -1175,33 +1165,24 @@ func layoutText(args []js.Value) (any, error) { return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) } -// selectedIDs 读取多选对象标识,交由库统一校验。 -func selectedIDs(value js.Value) []string { - ids := make([]string, value.Length()) - for i := range ids { - ids[i] = value.Index(i).String() - } - return ids -} - // transformObjects 统一移动或缩放选区,生成一次撤销记录。 func transformObjects(args []js.Value) (any, error) { return changeObjects(func() error { - return currentEditor.TransformObjects(args[0].Int(), selectedIDs(args[1]), args[2].Float(), args[3].Float(), args[4].Float()) + return currentEditor.TransformObjects(args[0].Int(), stringsFromJS(args[1]), args[2].Float(), args[3].Float(), args[4].Float()) }) } // alignObjects 对齐选区中的对象。 func alignObjects(args []js.Value) (any, error) { return changeObjects(func() error { - return currentEditor.AlignObjects(args[0].Int(), selectedIDs(args[1]), args[2].String()) + return currentEditor.AlignObjects(args[0].Int(), stringsFromJS(args[1]), args[2].String()) }) } // deleteObjects 一次删除选区中的对象。 func deleteObjects(args []js.Value) (any, error) { return changeObjects(func() error { - return currentEditor.DeleteObjects(args[0].Int(), selectedIDs(args[1])) + return currentEditor.DeleteObjects(args[0].Int(), stringsFromJS(args[1])) }) } diff --git a/ofdgo_editor_batch.go b/ofdgo_editor_batch.go index e524de8..b7add08 100644 --- a/ofdgo_editor_batch.go +++ b/ofdgo_editor_batch.go @@ -15,11 +15,179 @@ package ofdgo import ( + "cmp" "fmt" "reflect" "slices" + "strconv" + + "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") + } + _, 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]) + if err != nil { + return nil, err + } + object, err = transformEditorObject(object, dx, dy, 1) + if err != nil { + return nil, err + } + result[i] = strconv.Itoa(e.maxID + i + 1) + objects[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...)) + return result, nil +} + +// OrderObjects 调整同页选区的绘制顺序,保留选中及未选中对象各自的相对顺序。 +// 入参: page 页面索引, ids 对象标识, order 为up、down、top或bottom +// 返回: error 错误信息 +func (e *Editor) OrderObjects(page int, ids []string, order string) error { + if !slices.Contains([]string{"up", "down", "top", "bottom"}, order) { + return fmt.Errorf("invalid object order %q", order) + } + _, indexes, err := e.selectedObjects(page, ids) + if err != nil || len(indexes) == 0 { + return err + } + objects := slices.Clone(e.pages[page].Content.Layer[0].Objects) + selected := make([]bool, len(objects)) + for _, index := range indexes { + selected[index] = true + } + switch order { + case "up": + for i := len(objects) - 2; i >= 0; i-- { + if selected[i] && !selected[i+1] { + objects[i], objects[i+1] = objects[i+1], objects[i] + selected[i], selected[i+1] = false, true + } + } + case "down": + for i := 1; i < len(objects); i++ { + if selected[i] && !selected[i-1] { + objects[i], objects[i-1] = objects[i-1], objects[i] + selected[i], selected[i-1] = false, true + } + } + default: + ordered := make([]GraphicObject, 0, len(objects)) + for _, takeSelected := range []bool{order == "bottom", order != "bottom"} { + for i, object := range objects { + if selected[i] == takeSelected { + ordered = append(ordered, object) + } + } + } + objects = ordered + } + e.replaceObjects(page, objects) + return nil +} + +// DistributeObjects 按可见范围等距分布同页对象,固定两端对象,保留绘制顺序。 +// 不足三个对象时不修改;允许负间距,无法保持位置顺序的重叠布局返回错误。 +// 入参: page 页面索引, ids 对象标识, axis 为horizontal或vertical +// 返回: error 错误信息 +func (e *Editor) DistributeObjects(page int, ids []string, axis string) error { + if axis != "horizontal" && axis != "vertical" { + return fmt.Errorf("invalid distribution axis %q", axis) + } + objects, indexes, err := e.selectedObjects(page, ids) + if err != nil || len(objects) < 3 { + return err + } + boxes, err := e.objectBounds(page, objects) + if err != nil { + return err + } + positions, sizes := make([]float64, len(boxes)), make([]float64, len(boxes)) + order := make([]int, len(boxes)) + total := 0.0 + for i, box := range boxes { + positions[i], sizes[i] = box.X, box.W + if axis == "vertical" { + positions[i], sizes[i] = box.Y, box.H + } + order[i] = i + total += sizes[i] + } + slices.SortFunc(order, func(i, j int) int { + if c := cmp.Compare(positions[i], positions[j]); c != 0 { + return c + } + return cmp.Compare(indexes[i], indexes[j]) + }) + first, last := order[0], order[len(order)-1] + gap := (positions[last] + sizes[last] - positions[first] - total) / float64(len(order)-1) + for i, index := range order[:len(order)-1] { + step := sizes[index] + gap + if canvas.Equal(step, 0) { + step = 0 + } + if step < 0 || step == 0 && indexes[index] > indexes[order[i+1]] { + return fmt.Errorf("overlap prevents ordered equal-gap distribution") + } + } + next := positions[first] + sizes[first] + gap + updates := make([]GraphicObject, 0, len(order)-2) + for _, index := range order[1 : len(order)-1] { + position := next + next += sizes[index] + gap + if canvas.Equal(position, positions[index]) { + continue + } + dx, dy := position-positions[index], 0.0 + if axis == "vertical" { + dx, dy = 0, dx + } + object, err := cloneEditorObject(objects[index]) + if err != nil { + return err + } + object, err = transformEditorObject(object, dx, dy, 1) + if err != nil { + return err + } + updates = append(updates, object) + } + return e.UpdateObjects(page, updates) +} + +// replaceObjects 替换正文对象列表,隔离历史快照与后续的增删、排序操作。 +func (e *Editor) replaceObjects(page int, objects []GraphicObject) { + before := e.pages[page].Content.Layer[0].Objects + if reflect.DeepEqual(before, objects) { + return + } + before, after := slices.Clone(before), slices.Clone(objects) + e.pages[page].Content.Layer[0].Objects = slices.Clone(after) + if change := e.recordChange(); change != nil { + change.undo = func(e *Editor) { e.pages[page].Content.Layer[0].Objects = slices.Clone(before) } + change.redo = func(e *Editor) { e.pages[page].Content.Layer[0].Objects = slices.Clone(after) } + } +} + // UpdateObjects 原子替换同页对象,按各对象ID定位,全部校验通过后提交一次历史记录。 // 入参: page 页面索引, objects 新对象内容,ID不得重复 // 返回: error 错误信息 @@ -146,11 +314,7 @@ func (e *Editor) DeleteObjects(page int, ids []string) error { for _, index := range slices.Backward(indexes) { after = slices.Delete(after, index, index+1) } - e.pages[page].Content.Layer[0].Objects = slices.Clone(after) - if change := e.recordChange(); change != nil { - change.undo = func(e *Editor) { e.pages[page].Content.Layer[0].Objects = slices.Clone(before) } - change.redo = func(e *Editor) { e.pages[page].Content.Layer[0].Objects = slices.Clone(after) } - } + e.replaceObjects(page, after) return nil }