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 @@
+
对象
@@ -113,7 +118,7 @@
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
}