feat(创建编辑): 完善多选排版,优化工具栏与页外绘制
build.yaml / build (push) Successful in 36s

This commit is contained in:
xiaoqidun committed 2026-09-15 22:10:01 +08:00
1 parent c68bf1f3c9
commit d7e2456f67
6 files changed
+339 -141

No files matched your search

+8 -3
View File
@@ -83,11 +83,16 @@
<option value="middle">垂直</option>
<option value="bottom">底部</option>
</select>
<select id="objectDistribute" class="order-select" aria-label="对象分布" disabled>
<option value="" hidden>分布</option>
<option value="horizontal">水平</option>
<option value="vertical">垂直</option>
</select>
<span>对象</span>
</div>
<div class="editor-group">
<div class="text-font"><select id="textFont" aria-label="字体" title="字体" disabled></select><button id="textFontAdd" class="icon-button" type="button" title="添加字体" aria-label="添加字体">+</button></div>
<div class="form-unit text-size"><input id="textSize" type="number" min="1" step="any" required aria-label="字号" disabled><span>pt</span></div>
<div class="editor-number"><input id="textSize" type="number" min="1" step="any" required aria-label="字号" disabled><span>pt</span></div>
<input id="textColor" class="color-input" type="color" value="#000000" aria-label="文字颜色" title="文字颜色" disabled>
<span>文字</span>
</div>
@@ -99,7 +104,7 @@
<option value="justify">两端</option>
</select>
<button id="textWrap" class="button" type="button" aria-label="自动折行" aria-pressed="false" disabled>折行</button>
<label class="form-unit paragraph-height"><span>行距</span><input id="textLineHeight" type="number" min="0" step="any" placeholder="自动" aria-label="行距" title="行距,0为自动" disabled><span>pt</span></label>
<label class="editor-field"><span>行距</span><span class="editor-number"><input id="textLineHeight" type="number" min="0" step="any" placeholder="自动" aria-label="行距" title="行距,0为自动" disabled><span>pt</span></span></label>
<span>段落</span>
</div>
<div class="editor-group">
@@ -113,7 +118,7 @@
<div class="editor-group">
<div class="shape-paint"><label><input id="shapeFill" type="checkbox">填充</label><input id="shapeFillColor" class="color-input" type="color" value="#dcefeb" aria-label="填充颜色" title="填充颜色"></div>
<div class="shape-paint"><label><input id="shapeStroke" type="checkbox" checked>描边</label><input id="shapeStrokeColor" class="color-input" type="color" value="#000000" aria-label="描边颜色" title="描边颜色"></div>
<div class="form-unit text-size"><input id="shapeWidth" type="number" value="1" min="0" step="any" required aria-label="线宽" title="线宽"><span>pt</span></div>
<label class="editor-field"><span>线宽</span><span class="editor-number"><input id="shapeWidth" type="number" value="1" min="0" step="any" required aria-label="线宽" title="线宽"><span>pt</span></span></label>
<span>图形</span>
</div>
<div class="editor-group">
+63 -20
View File
@@ -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;
+23 -14
View File
@@ -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);
}
}
+41 -45
View File
@@ -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 });
}
}
+35 -54
View File
@@ -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]))
})
}
+169 -5
View File
@@ -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
}