mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-29 17:27:08 +08:00
This commit is contained in:
1 parent
c68bf1f3c9
commit
d7e2456f67
6 files changed
+339
-141
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user