mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-29 17:27:08 +08:00
This commit is contained in:
1 parent
1a90df1d83
commit
21999afcc1
6 files changed
+352
-78
No files matched your search
+7
-10
@@ -55,6 +55,12 @@
|
||||
</div>
|
||||
</header>
|
||||
<div id="editorTools" class="editor-toolbar" role="toolbar" aria-label="创作工具" hidden>
|
||||
<div class="editor-group editor-file">
|
||||
<button id="saveButton" class="button" type="button" aria-label="保存OFD">保存</button>
|
||||
<button id="undoButton" class="button" type="button" disabled>撤销</button>
|
||||
<button id="redoButton" class="button" type="button" disabled>重做</button>
|
||||
<span>文件</span>
|
||||
</div>
|
||||
<div class="editor-group">
|
||||
<button id="selectObjectButton" class="button" type="button" aria-pressed="true">选择</button>
|
||||
<button id="editObjectButton" class="button" type="button" aria-label="修改对象" disabled>修改</button>
|
||||
@@ -87,13 +93,10 @@
|
||||
<div class="editor-group">
|
||||
<button id="insertTextButton" class="button" type="button" aria-label="添加文字">文字</button>
|
||||
<button id="insertImageButton" class="button" type="button" aria-label="添加图片">图片</button>
|
||||
<span>插入</span>
|
||||
</div>
|
||||
<div class="editor-group">
|
||||
<button id="drawLineButton" class="button" type="button" aria-label="绘制直线" aria-pressed="false">直线</button>
|
||||
<button id="drawRectangleButton" class="button" type="button" aria-label="绘制矩形" aria-pressed="false">矩形</button>
|
||||
<button id="drawEllipseButton" class="button" type="button" aria-label="绘制椭圆" aria-pressed="false">椭圆</button>
|
||||
<span>绘制</span>
|
||||
<span>插入</span>
|
||||
</div>
|
||||
<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>
|
||||
@@ -110,12 +113,6 @@
|
||||
<button id="pageSettingsButton" class="button" type="button" aria-label="页面设置" aria-haspopup="dialog" aria-controls="pagePanel">设置</button>
|
||||
<span>页面</span>
|
||||
</div>
|
||||
<div class="editor-group">
|
||||
<button id="saveButton" class="button" type="button" aria-label="保存OFD">保存</button>
|
||||
<button id="undoButton" class="button" type="button" disabled>撤销</button>
|
||||
<button id="redoButton" class="button" type="button" disabled>重做</button>
|
||||
<span>文件</span>
|
||||
</div>
|
||||
</div>
|
||||
<dialog id="createPanel" class="form-dialog" aria-label="新建文档">
|
||||
<form id="createForm">
|
||||
|
||||
+62
-31
@@ -86,7 +86,6 @@ a:focus-visible {
|
||||
|
||||
.editor-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
max-height: 40dvh;
|
||||
overflow: auto;
|
||||
gap: 12px;
|
||||
@@ -102,24 +101,34 @@ a:focus-visible {
|
||||
|
||||
.editor-group {
|
||||
display: grid;
|
||||
flex: 0 0 auto;
|
||||
grid-template-columns: repeat(3, max-content);
|
||||
grid-template-rows: repeat(2, 28px) 16px;
|
||||
column-gap: 6px;
|
||||
row-gap: 4px;
|
||||
padding-right: 12px;
|
||||
border-right: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.editor-group:has(#selectObjectButton) {
|
||||
grid-template-columns: repeat(6, max-content);
|
||||
}
|
||||
|
||||
.editor-group:has(#insertTextButton) {
|
||||
.editor-file,
|
||||
.editor-group:has(#textFont),
|
||||
.editor-group:has(#shapeFill) {
|
||||
grid-template-columns: repeat(2, max-content);
|
||||
}
|
||||
|
||||
.editor-file #saveButton {
|
||||
grid-row: 1 / 3;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.editor-file #redoButton {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
#textFont {
|
||||
grid-column: 1 / -1;
|
||||
width: 128px;
|
||||
min-width: 0;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.shape-paint,
|
||||
@@ -135,20 +144,13 @@ a:focus-visible {
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.editor-group:has(#addPageButton) {
|
||||
grid-template-columns: repeat(6, max-content);
|
||||
}
|
||||
|
||||
.editor-group:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.editor-group > :not(span) {
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.editor-group > span {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 3;
|
||||
align-self: end;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
@@ -612,6 +614,7 @@ body[data-hide-meta] .meta-panel {
|
||||
.page-list-panel,
|
||||
.viewer-panel,
|
||||
.search-results,
|
||||
.editor-toolbar,
|
||||
.meta-panel {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--line-strong) transparent;
|
||||
@@ -1247,25 +1250,65 @@ button.outline-link:hover {
|
||||
.edit-nw {
|
||||
left: 0;
|
||||
top: 0;
|
||||
cursor: nwse-resize;
|
||||
cursor: var(--resize-diagonal, nwse-resize);
|
||||
}
|
||||
|
||||
.edit-ne {
|
||||
left: 100%;
|
||||
top: 0;
|
||||
cursor: nesw-resize;
|
||||
cursor: var(--resize-antidiagonal, nesw-resize);
|
||||
}
|
||||
|
||||
.edit-sw {
|
||||
left: 0;
|
||||
top: 100%;
|
||||
cursor: nesw-resize;
|
||||
cursor: var(--resize-antidiagonal, nesw-resize);
|
||||
}
|
||||
|
||||
.edit-se {
|
||||
left: 100%;
|
||||
top: 100%;
|
||||
cursor: nwse-resize;
|
||||
cursor: var(--resize-diagonal, nwse-resize);
|
||||
}
|
||||
|
||||
.edit-n,
|
||||
.edit-s {
|
||||
left: 50%;
|
||||
cursor: var(--resize-vertical, ns-resize);
|
||||
}
|
||||
|
||||
.edit-n {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.edit-s {
|
||||
top: 100%;
|
||||
}
|
||||
|
||||
.edit-w,
|
||||
.edit-e {
|
||||
top: 50%;
|
||||
cursor: var(--resize-horizontal, ew-resize);
|
||||
}
|
||||
|
||||
.edit-w {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.edit-e {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
.edit-start,
|
||||
.edit-end {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.page-surface.sideways {
|
||||
--resize-diagonal: nesw-resize;
|
||||
--resize-antidiagonal: nwse-resize;
|
||||
--resize-vertical: ew-resize;
|
||||
--resize-horizontal: ns-resize;
|
||||
}
|
||||
|
||||
.text-layer {
|
||||
@@ -1908,15 +1951,3 @@ button.outline-link:hover {
|
||||
width: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
.editor-group:has(#selectObjectButton),
|
||||
.editor-group:has(#addPageButton) {
|
||||
grid-template-columns: repeat(3, max-content);
|
||||
}
|
||||
|
||||
.editor-group:has(#addPageButton) > button,
|
||||
.editor-group:has(#selectObjectButton) > :not(span) {
|
||||
grid-row: auto;
|
||||
}
|
||||
}
|
||||
@@ -237,6 +237,7 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, {
|
||||
onSelect: updateObjectControls,
|
||||
onTransform: (item, change) => changeDocument("ofdgoTransformObject", item,
|
||||
change.x + item.x * (1 - change.scale), change.y + item.y * (1 - change.scale), change.scale),
|
||||
onReshape: (item, box) => changeDocument("ofdgoReshapeObject", item, box.x, box.y, box.width, box.height),
|
||||
onDelete: (item) => changeDocument("ofdgoDeleteObject", item),
|
||||
onEdit: editCanvasObject,
|
||||
drawStyle: shapeStyle,
|
||||
@@ -902,7 +903,7 @@ async function changeShapeStyle() {
|
||||
|
||||
function updateDrawingControls() {
|
||||
const tool = canvasEditor.tool;
|
||||
const line = tool === "line" || !tool && canvasEditor.selected?.line;
|
||||
const line = tool === "line" || !tool && canvasEditor.selected?.shape === "line";
|
||||
if (line) {
|
||||
el.shapeFill.checked = false;
|
||||
el.shapeStroke.checked = true;
|
||||
@@ -2811,8 +2812,9 @@ function layoutPageShell(shell, page) {
|
||||
surface.style.height = `${height}px`;
|
||||
const x = state.rotation === 90 || state.rotation === 180 ? viewWidth : 0;
|
||||
const y = state.rotation >= 180 ? viewHeight : 0;
|
||||
surface.style.transform = `scale(${scale}) translate(${x}px, ${y}px) rotate(${state.rotation}deg)`;
|
||||
surface.style.setProperty("--surface-scale", String(scale));
|
||||
surface.style.transform = `scale(${scale}) translate(${x}px, ${y}px) rotate(${state.rotation}deg)`;
|
||||
surface.style.setProperty("--surface-scale", String(scale));
|
||||
surface.classList.toggle("sideways", state.rotation % 180 !== 0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+137
-22
@@ -24,12 +24,71 @@ export function objectTransform(box, page, dx, dy, corner = "") {
|
||||
(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 / (box.line ? Math.max(box.width, box.height) : Math.min(box.width, 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)));
|
||||
return { x: left ? box.width * (1 - scale) : 0, y: top ? box.height * (1 - scale) : 0, scale };
|
||||
}
|
||||
|
||||
// constrainedPoint applies drawing constraints before clipping the endpoint to the page.
|
||||
export function constrainedPoint(from, to, page, 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]];
|
||||
const [x, y] = directions[(Math.round(Math.atan2(dy, dx) / (Math.PI / 4)) + 8) % 8];
|
||||
const length = (dx * x + dy * y) / (x * x + y * y);
|
||||
dx = x * length;
|
||||
dy = y * length;
|
||||
} else if (shift) {
|
||||
const size = Math.max(Math.abs(dx), Math.abs(dy));
|
||||
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 };
|
||||
}
|
||||
|
||||
// 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;
|
||||
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 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);
|
||||
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));
|
||||
return { x, y, width: right - x, height: bottom - y };
|
||||
}
|
||||
|
||||
// paintShape uses the same page-space geometry for creation and endpoint previews.
|
||||
function paintShape(node, shape, box) {
|
||||
const { x, y, width, height } = box;
|
||||
const attributes = shape === "line" ? { x1: x, y1: y, x2: x + width, y2: y + height }
|
||||
: shape === "ellipse" ? { cx: x + width / 2, cy: y + height / 2, rx: width / 2, ry: height / 2 }
|
||||
: { x, y, width, height };
|
||||
for (const [key, value] of Object.entries(attributes)) {
|
||||
node.setAttribute(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
export class CanvasEditor {
|
||||
constructor(viewer, options) {
|
||||
this.viewer = viewer;
|
||||
@@ -45,6 +104,7 @@ export class CanvasEditor {
|
||||
viewer.addEventListener("pointercancel", () => this.cancel());
|
||||
viewer.addEventListener("lostpointercapture", () => this.cancel());
|
||||
viewer.addEventListener("keydown", (event) => this.keyDown(event));
|
||||
viewer.addEventListener("keyup", (event) => this.modifierChange(event));
|
||||
viewer.addEventListener("dblclick", () => {
|
||||
const item = this.selected;
|
||||
if (this.enabled && item && !this.input && !this.options.busy()) {
|
||||
@@ -82,7 +142,7 @@ export class CanvasEditor {
|
||||
for (const object of page.objects) {
|
||||
const node = document.createElement("div");
|
||||
node.className = "edit-object";
|
||||
if (object.line) {
|
||||
if (object.shape === "line") {
|
||||
node.classList.add("edit-line");
|
||||
}
|
||||
node.tabIndex = 0;
|
||||
@@ -91,13 +151,15 @@ export class CanvasEditor {
|
||||
const item = { ...object, index, page: { width: page.width, height: page.height }, node, surface };
|
||||
this.nodes.set(node, item);
|
||||
node.addEventListener("focus", () => this.select(item));
|
||||
this.place(item);
|
||||
for (const corner of ["nw", "ne", "sw", "se"]) {
|
||||
const handles = object.shape === "line" ? ["start", "end"]
|
||||
: object.shape ? ["nw", "n", "ne", "e", "se", "s", "sw", "w"] : ["nw", "ne", "sw", "se"];
|
||||
for (const corner of handles) {
|
||||
const handle = document.createElement("span");
|
||||
handle.className = `edit-handle edit-${corner}`;
|
||||
handle.dataset.corner = corner;
|
||||
node.append(handle);
|
||||
}
|
||||
this.place(item);
|
||||
layer.append(node);
|
||||
if (this.selected?.index === index && this.selected.id === object.id) {
|
||||
this.select(item);
|
||||
@@ -111,12 +173,33 @@ export class CanvasEditor {
|
||||
}
|
||||
|
||||
place(item, change = { x: 0, y: 0, scale: 1 }) {
|
||||
if (item.shape) {
|
||||
this.placeShape(item, { x: item.geometry.x + change.x, y: item.geometry.y + change.y,
|
||||
width: item.geometry.width * change.scale, height: item.geometry.height * change.scale });
|
||||
return;
|
||||
}
|
||||
Object.assign(item.node.style, {
|
||||
left: `${(item.x + change.x) * PX_PER_MM}px`, top: `${(item.y + change.y) * PX_PER_MM}px`,
|
||||
width: `${item.width * change.scale * PX_PER_MM}px`, height: `${item.height * change.scale * PX_PER_MM}px`,
|
||||
});
|
||||
}
|
||||
|
||||
// placeShape positions basic-shape handles at their geometry rather than the padded boundary.
|
||||
placeShape(item, box) {
|
||||
const x = Math.min(box.x, box.x + box.width), y = Math.min(box.y, box.y + box.height);
|
||||
Object.assign(item.node.style, {
|
||||
left: `${x * PX_PER_MM}px`, top: `${y * PX_PER_MM}px`,
|
||||
width: `${Math.abs(box.width) * PX_PER_MM}px`, height: `${Math.abs(box.height) * PX_PER_MM}px`,
|
||||
});
|
||||
if (item.shape === "line") {
|
||||
for (const handle of item.node.children) {
|
||||
const end = handle.dataset.corner === "end";
|
||||
handle.style.left = `${(box.x + (end ? box.width : 0) - x) * PX_PER_MM}px`;
|
||||
handle.style.top = `${(box.y + (end ? box.height : 0) - y) * PX_PER_MM}px`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
select(item) {
|
||||
this.selected?.node.classList.remove("selected");
|
||||
this.selected = item;
|
||||
@@ -157,6 +240,11 @@ export class CanvasEditor {
|
||||
rect: item.surface.getBoundingClientRect(), rotation: this.options.rotation(),
|
||||
clientX: event.clientX, clientY: event.clientY,
|
||||
};
|
||||
if (item.shape === "line" && this.drag.corner) {
|
||||
Object.assign(this.drag, this.createPreview(item.surface, item.page, item.shape, {
|
||||
fill: false, stroke: true, strokeColor: "var(--accent)", lineWidth: item.lineWidth,
|
||||
}));
|
||||
}
|
||||
this.viewer.setPointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
@@ -166,15 +254,22 @@ export class CanvasEditor {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
drag.pointer = event;
|
||||
if (drag.shape) {
|
||||
this.moveShape(event, drag);
|
||||
return;
|
||||
}
|
||||
if (!drag.change && Math.hypot(event.clientX - drag.clientX, event.clientY - drag.clientY) < 3) {
|
||||
if (!drag.change && !drag.box && Math.hypot(event.clientX - drag.clientX, event.clientY - drag.clientY) < 3) {
|
||||
return;
|
||||
}
|
||||
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.shape && drag.corner) {
|
||||
drag.box = reshapeBox(drag.item, to.x - from.x, to.y - from.y, drag.corner, event.shiftKey);
|
||||
this.placeShape(drag.item, drag.box || drag.item.geometry);
|
||||
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);
|
||||
this.place(drag.item, drag.change);
|
||||
}
|
||||
@@ -197,6 +292,16 @@ export class CanvasEditor {
|
||||
.finally(() => drag.preview.remove());
|
||||
return;
|
||||
}
|
||||
if (drag.item.shape && drag.corner) {
|
||||
this.drag = null;
|
||||
this.viewer.releasePointerCapture(drag.pointerID);
|
||||
const changed = drag.box && Object.keys(drag.box).some((key) => drag.box[key] !== drag.item.geometry[key]);
|
||||
Promise.resolve(changed && this.options.onReshape(drag.item, drag.box)).finally(() => {
|
||||
drag.preview?.remove();
|
||||
this.placeShape(drag.item, drag.item.geometry);
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.cancel();
|
||||
if (drag.change && (drag.change.x || drag.change.y || drag.change.scale !== 1)) {
|
||||
this.options.onTransform(drag.item, drag.change);
|
||||
@@ -207,9 +312,8 @@ export class CanvasEditor {
|
||||
const drag = this.drag;
|
||||
this.drag = null;
|
||||
if (drag) {
|
||||
if (drag.shape) {
|
||||
drag.preview.remove();
|
||||
} else {
|
||||
drag.preview?.remove();
|
||||
if (drag.item) {
|
||||
this.place(drag.item);
|
||||
}
|
||||
if (this.viewer.hasPointerCapture(drag.pointerID)) {
|
||||
@@ -226,25 +330,30 @@ export class CanvasEditor {
|
||||
}
|
||||
event.preventDefault();
|
||||
this.viewer.focus({ preventScroll: true });
|
||||
const preview = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
preview.classList.add("edit-preview");
|
||||
preview.setAttribute("viewBox", `0 0 ${page.width} ${page.height}`);
|
||||
const shape = this.tool;
|
||||
const node = document.createElementNS("http://www.w3.org/2000/svg", shape === "rectangle" ? "rect" : shape);
|
||||
const style = this.options.drawStyle();
|
||||
if (shape === "line") {
|
||||
style.fill = false;
|
||||
style.stroke = true;
|
||||
}
|
||||
this.drag = { shape, page, ...this.createPreview(surface, page, shape, style), style, pointerID: event.pointerId,
|
||||
rect: surface.getBoundingClientRect(), rotation: this.options.rotation(),
|
||||
clientX: event.clientX, clientY: event.clientY };
|
||||
this.viewer.setPointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
// 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");
|
||||
preview.classList.add("edit-preview");
|
||||
preview.setAttribute("viewBox", `0 0 ${page.width} ${page.height}`);
|
||||
const node = document.createElementNS("http://www.w3.org/2000/svg", shape === "rectangle" ? "rect" : shape);
|
||||
node.setAttribute("fill", style.fill ? style.fillColor : "none");
|
||||
node.setAttribute("stroke", style.stroke ? style.strokeColor : "none");
|
||||
node.setAttribute("stroke-width", style.lineWidth);
|
||||
preview.append(node);
|
||||
surface.append(preview);
|
||||
this.drag = { shape, page, preview, node, style, pointerID: event.pointerId,
|
||||
rect: surface.getBoundingClientRect(), rotation: this.options.rotation(),
|
||||
clientX: event.clientX, clientY: event.clientY };
|
||||
this.viewer.setPointerCapture(event.pointerId);
|
||||
return { preview, node };
|
||||
}
|
||||
|
||||
moveShape(event, drag) {
|
||||
@@ -253,7 +362,7 @@ export class CanvasEditor {
|
||||
return { x: Math.max(0, Math.min(p.x, drag.page.width)), y: Math.max(0, Math.min(p.y, drag.page.height)) };
|
||||
};
|
||||
const from = point(drag.clientX, drag.clientY);
|
||||
const to = point(event.clientX, event.clientY);
|
||||
const to = constrainedPoint(from, pagePoint(event.clientX, event.clientY, drag.rect, drag.page, drag.rotation), drag.page, drag.shape, event.shiftKey);
|
||||
const x = Math.min(from.x, to.x), y = Math.min(from.y, to.y);
|
||||
const width = Math.abs(to.x - from.x), height = Math.abs(to.y - from.y);
|
||||
const valid = Math.hypot(event.clientX - drag.clientX, event.clientY - drag.clientY) >= 3
|
||||
@@ -261,15 +370,21 @@ export class CanvasEditor {
|
||||
drag.box = valid ? drag.shape === "line"
|
||||
? { x: from.x, y: from.y, width: to.x - from.x, height: to.y - from.y }
|
||||
: { x, y, width, height } : null;
|
||||
const attributes = drag.shape === "line" ? { x1: from.x, y1: from.y, x2: to.x, y2: to.y }
|
||||
: drag.shape === "ellipse" ? { cx: x + width / 2, cy: y + height / 2, rx: width / 2, ry: height / 2 }
|
||||
: { x, y, width, height };
|
||||
for (const [key, value] of Object.entries(attributes)) {
|
||||
drag.node.setAttribute(key, value);
|
||||
paintShape(drag.node, drag.shape, drag.shape === "line"
|
||||
? { x: from.x, y: from.y, width: to.x - from.x, height: to.y - from.y } : { x, y, width, height });
|
||||
}
|
||||
|
||||
// modifierChange refreshes the preview even when Shift changes without pointer movement.
|
||||
modifierChange(event) {
|
||||
const pointer = this.drag?.pointer;
|
||||
if (event.key === "Shift" && pointer) {
|
||||
this.move({ pointerId: pointer.pointerId, clientX: pointer.clientX, clientY: pointer.clientY,
|
||||
shiftKey: event.shiftKey, preventDefault() {} });
|
||||
}
|
||||
}
|
||||
|
||||
keyDown(event) {
|
||||
this.modifierChange(event);
|
||||
if (event.key === "Escape" && this.tool && !this.options.busy()) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
+36
-11
@@ -27,7 +27,6 @@ import (
|
||||
"io"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall/js"
|
||||
|
||||
"github.com/xiaoqidun/ofdgo"
|
||||
@@ -109,6 +108,7 @@ func RunWASM() {
|
||||
registerCallback("ofdgoEditorFont", editorFont)
|
||||
registerCallback("ofdgoAlignObject", alignObject)
|
||||
registerCallback("ofdgoTransformObject", transformObject)
|
||||
registerCallback("ofdgoReshapeObject", reshapeObject)
|
||||
registerCallback("ofdgoCopyObject", copyObject)
|
||||
registerCallback("ofdgoMoveObject", moveObject)
|
||||
registerCallback("ofdgoDeleteObject", deleteObject)
|
||||
@@ -625,7 +625,11 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) {
|
||||
item := map[string]any{"id": id, "type": object.Type, "x": box.X, "y": box.Y, "width": box.W, "height": box.H, "order": order, "count": len(layer.Objects)}
|
||||
if object.Type == "PathObject" {
|
||||
path := object.PathObject
|
||||
item["line"] = editorLine(path)
|
||||
kind, geometry := path.Shape()
|
||||
if kind != "" {
|
||||
item["shape"] = string(kind)
|
||||
item["geometry"] = map[string]any{"x": geometry.X, "y": geometry.Y, "width": geometry.W, "height": geometry.H}
|
||||
}
|
||||
item["fill"], item["stroke"] = path.Fill != nil && *path.Fill, path.Stroke == nil || *path.Stroke
|
||||
item["fillColor"] = editorColorHex(path.FillColor)
|
||||
item["strokeColor"] = editorColorHex((*ofdgo.FillColor)(path.StrokeColor))
|
||||
@@ -732,6 +736,35 @@ func transformObject(args []js.Value) (any, error) {
|
||||
return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations)
|
||||
}
|
||||
|
||||
// reshapeObject 调整基本图形的尺寸或直线端点,保留绘制样式
|
||||
// 入参: args 页码、对象标识和页面几何范围
|
||||
// 返回: any 文档信息, error 错误信息
|
||||
func reshapeObject(args []js.Value) (any, error) {
|
||||
if currentEditor == nil {
|
||||
return nil, fmt.Errorf("no document is being created")
|
||||
}
|
||||
page, id := args[0].Int(), args[1].String()
|
||||
object, err := currentEditor.Object(page, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if object.Type != "PathObject" {
|
||||
return nil, fmt.Errorf("object is not a path")
|
||||
}
|
||||
object.PathObject, err = object.PathObject.Reshape(ofdgo.Box{X: args[2].Float(), Y: args[3].Float(), W: args[4].Float(), H: args[5].Float()})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
revision := currentEditor.Revision()
|
||||
if err := currentEditor.UpdateObject(page, id, object); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if currentEditor.Revision() == revision {
|
||||
return editorSummary(), nil
|
||||
}
|
||||
return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations)
|
||||
}
|
||||
|
||||
// copyObject 复制选中对象并平移,复用字体和图片资源
|
||||
// 入参: args 页码、对象标识和位移
|
||||
// 返回: any 文档信息, error 错误信息
|
||||
@@ -965,20 +998,12 @@ func editorPathScale(object ofdgo.PathObject) float64 {
|
||||
return math.Sqrt(math.Abs((ax-x)*(by-y) - (ay-y)*(bx-x)))
|
||||
}
|
||||
|
||||
// editorLine 判断路径是否为单一直线
|
||||
// 入参: object 路径对象
|
||||
// 返回: bool 是否为单一直线
|
||||
func editorLine(object ofdgo.PathObject) bool {
|
||||
tokens := strings.Fields(object.AbbreviatedData)
|
||||
return len(tokens) == 6 && tokens[0] == "M" && tokens[3] == "L"
|
||||
}
|
||||
|
||||
// setPathStyle 应用图形填充、描边和线宽
|
||||
// 入参: object 路径对象, args 填充开关及色值、描边开关及色值、线宽
|
||||
// 返回: error 错误信息
|
||||
func setPathStyle(object *ofdgo.PathObject, args []js.Value) error {
|
||||
fill, stroke, width := args[0].Bool(), args[2].Bool(), args[4].Float()
|
||||
if editorLine(*object) {
|
||||
if kind, _ := object.Shape(); kind == ofdgo.ShapeLine {
|
||||
fill, stroke = false, true
|
||||
}
|
||||
if !fill && !stroke {
|
||||
|
||||
+105
-1
@@ -17,6 +17,8 @@ package ofdgo
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ShapeKind 基本图形类型
|
||||
@@ -30,6 +32,7 @@ const (
|
||||
|
||||
// NewShape 创建使用标准紧缩路径的图形,默认黑色描边、不填充。
|
||||
// 可调整返回对象的颜色、线宽等属性后通过 Editor.AddObject 写入。
|
||||
// 水平、垂直直线使用正尺寸边界,路径端点不变。
|
||||
// 入参: kind 图形类型, box 图形范围;直线从 (X,Y) 到 (X+W,Y+H),W、H 可为负或零
|
||||
// 返回: PathObject 路径对象, error 错误信息
|
||||
func NewShape(kind ShapeKind, box Box) (PathObject, error) {
|
||||
@@ -42,7 +45,6 @@ func NewShape(kind ShapeKind, box Box) (PathObject, error) {
|
||||
if box.W == 0 && box.H == 0 {
|
||||
return PathObject{}, fmt.Errorf("line endpoints must differ")
|
||||
}
|
||||
// 为水平、垂直直线保留有效的正尺寸边界,路径端点不变。
|
||||
x, y := math.Min(0, box.W)-defaultPathLineWidth/2, math.Min(0, box.H)-defaultPathLineWidth/2
|
||||
object.AbbreviatedData = fmt.Sprintf("M %g %g L %g %g", -x, -y, box.W-x, box.H-y)
|
||||
box = Box{X: box.X + x, Y: box.Y + y, W: math.Abs(box.W) + defaultPathLineWidth, H: math.Abs(box.H) + defaultPathLineWidth}
|
||||
@@ -62,3 +64,105 @@ func NewShape(kind ShapeKind, box Box) (PathObject, error) {
|
||||
object.Boundary = fmt.Sprintf("%g %g %g %g", box.X, box.Y, box.W, box.H)
|
||||
return object, nil
|
||||
}
|
||||
|
||||
// Shape 识别 NewShape 生成的基本路径及其正向轴对齐缩放、平移,返回应用 Boundary 和 CTM 后的几何范围。
|
||||
// 直线范围以起点和有符号的端点位移表示;非基本路径或不支持的变换返回空类型。
|
||||
// 返回: ShapeKind 图形类型, Box 几何范围(不含描边)
|
||||
func (p PathObject) Shape() (ShapeKind, Box) {
|
||||
tokens := strings.Fields(p.AbbreviatedData)
|
||||
var commands string
|
||||
var values []float64
|
||||
for _, token := range tokens {
|
||||
if token == "M" || token == "L" || token == "A" || token == "C" {
|
||||
commands += token
|
||||
continue
|
||||
}
|
||||
value, err := strconv.ParseFloat(token, 64)
|
||||
if err != nil || !finite(value) {
|
||||
return "", Box{}
|
||||
}
|
||||
values = append(values, value)
|
||||
}
|
||||
var kind ShapeKind
|
||||
var box Box
|
||||
switch {
|
||||
case commands == "ML" && len(values) == 4 && len(tokens) == 6 && tokens[0] == "M" && tokens[3] == "L":
|
||||
kind, box = ShapeLine, Box{X: values[0], Y: values[1], W: values[2] - values[0], H: values[3] - values[1]}
|
||||
case commands == "MLLLC" && len(values) == 8:
|
||||
kind, box = ShapeRectangle, Box{W: values[2], H: values[5]}
|
||||
case commands == "MAAC" && len(values) == 16:
|
||||
kind, box = ShapeEllipse, Box{W: values[7], H: values[1] * 2}
|
||||
default:
|
||||
return "", Box{}
|
||||
}
|
||||
if kind != ShapeLine {
|
||||
shape, err := NewShape(kind, box)
|
||||
if err != nil || !sameShapePath(tokens, strings.Fields(shape.AbbreviatedData)) {
|
||||
return "", Box{}
|
||||
}
|
||||
} else if box.W == 0 && box.H == 0 {
|
||||
return "", Box{}
|
||||
}
|
||||
if p.CTM != "" {
|
||||
if _, err := creationNumbers(p.CTM, 6); err != nil {
|
||||
return "", Box{}
|
||||
}
|
||||
}
|
||||
boundary, err := ParseBox(p.Boundary)
|
||||
m := NewMatrix(p.CTM)
|
||||
if err != nil || m.a <= 0 || m.d <= 0 || m.b != 0 || m.c != 0 {
|
||||
return "", Box{}
|
||||
}
|
||||
x, y := m.Transform(box.X, box.Y)
|
||||
box = Box{X: boundary.X + x, Y: boundary.Y + y, W: box.W * m.a, H: box.H * m.d}
|
||||
if !finite(box.X) || !finite(box.Y) || !finite(box.W) || !finite(box.H) || !finite(box.X+box.W) || !finite(box.Y+box.H) {
|
||||
return "", Box{}
|
||||
}
|
||||
return kind, box
|
||||
}
|
||||
|
||||
// sameShapePath 比较基本路径的命令与数值,忽略数值格式差异。
|
||||
// 入参: a、b 路径词元
|
||||
// 返回: bool 是否相同
|
||||
func sameShapePath(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i, token := range a {
|
||||
if token == b[i] {
|
||||
continue
|
||||
}
|
||||
x, errX := strconv.ParseFloat(token, 64)
|
||||
y, errY := strconv.ParseFloat(b[i], 64)
|
||||
if errX != nil || errY != nil || x != y {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Reshape 调整基本路径在所在坐标系中的几何范围,保留对象标识、变换和绘制属性,不缩放线宽。
|
||||
// 返回对象可通过 Editor.UpdateObject 写入;仅支持 Shape 可识别的路径。
|
||||
// 入参: box 新几何范围,直线使用起点和有符号的端点位移
|
||||
// 返回: PathObject 调整后的对象, error 错误信息
|
||||
func (p PathObject) Reshape(box Box) (PathObject, error) {
|
||||
kind, previous := p.Shape()
|
||||
if kind == "" {
|
||||
return PathObject{}, fmt.Errorf("path is not a supported basic shape")
|
||||
}
|
||||
if box == previous {
|
||||
return p, nil
|
||||
}
|
||||
if !finite(box.X) || !finite(box.Y) || !finite(box.X+box.W) || !finite(box.Y+box.H) {
|
||||
return PathObject{}, fmt.Errorf("shape coordinates must be finite")
|
||||
}
|
||||
m := NewMatrix(p.CTM)
|
||||
shape, err := NewShape(kind, Box{W: box.W / m.a, H: box.H / m.d})
|
||||
if err != nil {
|
||||
return PathObject{}, err
|
||||
}
|
||||
boundary, _ := ParseBox(shape.Boundary)
|
||||
p.Boundary = fmt.Sprintf("%g %g %g %g", box.X+boundary.X*m.a-m.e, box.Y+boundary.Y*m.d-m.f, boundary.W*m.a, boundary.H*m.d)
|
||||
p.AbbreviatedData = shape.AbbreviatedData
|
||||
return p, nil
|
||||
}
|
||||
Reference in new issue
Block a user