mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-29 16:25:49 +08:00
387 lines
12 KiB
JavaScript
387 lines
12 KiB
JavaScript
const PX_PER_MM = 96 / 25.4;
|
|
|
|
export function pagePoint(x, y, rect, page, rotation) {
|
|
const u = (x - rect.left) / rect.width;
|
|
const v = (y - rect.top) / rect.height;
|
|
const points = [[u, v], [v, 1 - u], [1 - u, 1 - v], [1 - v, u]];
|
|
const point = points[rotation / 90];
|
|
return { x: point[0] * page.width, y: point[1] * page.height };
|
|
}
|
|
|
|
export function objectTransform(box, page, 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,
|
|
};
|
|
}
|
|
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 / (box.line ? Math.max(box.width, box.height) : 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 };
|
|
}
|
|
|
|
export class CanvasEditor {
|
|
constructor(viewer, options) {
|
|
this.viewer = viewer;
|
|
this.options = options;
|
|
this.enabled = false;
|
|
this.selected = null;
|
|
this.nodes = new WeakMap();
|
|
this.pages = new WeakMap();
|
|
this.tool = "";
|
|
viewer.addEventListener("pointerdown", (event) => this.start(event));
|
|
viewer.addEventListener("pointermove", (event) => this.move(event));
|
|
viewer.addEventListener("pointerup", (event) => this.end(event));
|
|
viewer.addEventListener("pointercancel", () => this.cancel());
|
|
viewer.addEventListener("lostpointercapture", () => this.cancel());
|
|
viewer.addEventListener("keydown", (event) => this.keyDown(event));
|
|
viewer.addEventListener("dblclick", () => {
|
|
const item = this.selected;
|
|
if (this.enabled && item && !this.input && !this.options.busy()) {
|
|
this.options.onEdit(item);
|
|
}
|
|
});
|
|
viewer.addEventListener("scroll", () => this.cancel());
|
|
window.addEventListener("blur", () => this.cancel());
|
|
window.addEventListener("resize", () => this.cancel());
|
|
}
|
|
|
|
setEnabled(enabled) {
|
|
this.enabled = enabled;
|
|
this.viewer.classList.toggle("editing-select", enabled);
|
|
if (!enabled) {
|
|
this.setTool("");
|
|
this.clear();
|
|
}
|
|
}
|
|
|
|
setTool(tool) {
|
|
this.cancel();
|
|
this.tool = tool;
|
|
this.viewer.classList.toggle("editing-draw", Boolean(tool));
|
|
if (tool) {
|
|
this.select(null);
|
|
}
|
|
this.options.onTool?.(tool);
|
|
}
|
|
|
|
mount(index, page, surface) {
|
|
this.pages.set(surface, { index, width: page.width, height: page.height });
|
|
const layer = document.createElement("div");
|
|
layer.className = "edit-layer";
|
|
for (const object of page.objects) {
|
|
const node = document.createElement("div");
|
|
node.className = "edit-object";
|
|
if (object.line) {
|
|
node.classList.add("edit-line");
|
|
}
|
|
node.tabIndex = 0;
|
|
node.setAttribute("role", "button");
|
|
node.setAttribute("aria-label", { ImageObject: "图片对象", TextObject: "文字对象", PathObject: "图形对象" }[object.type]);
|
|
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 handle = document.createElement("span");
|
|
handle.className = `edit-handle edit-${corner}`;
|
|
handle.dataset.corner = corner;
|
|
node.append(handle);
|
|
}
|
|
layer.append(node);
|
|
if (this.selected?.index === index && this.selected.id === object.id) {
|
|
this.select(item);
|
|
}
|
|
}
|
|
surface.append(layer);
|
|
if (this.selectLast && layer.lastElementChild) {
|
|
this.selectLast = false;
|
|
this.select(this.nodes.get(layer.lastElementChild));
|
|
}
|
|
}
|
|
|
|
place(item, change = { x: 0, y: 0, scale: 1 }) {
|
|
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`,
|
|
});
|
|
}
|
|
|
|
select(item) {
|
|
this.selected?.node.classList.remove("selected");
|
|
this.selected = item;
|
|
item?.node.classList.add("selected");
|
|
this.options.onSelect(item);
|
|
}
|
|
|
|
clear() {
|
|
this.closeText();
|
|
this.cancel();
|
|
this.selectLast = false;
|
|
this.select(null);
|
|
}
|
|
|
|
start(event) {
|
|
if (this.input) {
|
|
return;
|
|
}
|
|
if (!this.enabled || this.options.busy() || event.button !== 0 || this.drag) {
|
|
return;
|
|
}
|
|
if (this.tool) {
|
|
this.startShape(event);
|
|
return;
|
|
}
|
|
const node = event.target.closest(".edit-object");
|
|
if (node) {
|
|
event.preventDefault();
|
|
}
|
|
this.viewer.focus({ preventScroll: true });
|
|
this.select(node ? this.nodes.get(node) : null);
|
|
if (!node || this.options.busy()) {
|
|
return;
|
|
}
|
|
const item = this.selected;
|
|
this.drag = {
|
|
item, pointerID: event.pointerId, corner: event.target.dataset.corner || "",
|
|
rect: item.surface.getBoundingClientRect(), rotation: this.options.rotation(),
|
|
clientX: event.clientX, clientY: event.clientY,
|
|
};
|
|
this.viewer.setPointerCapture(event.pointerId);
|
|
}
|
|
|
|
move(event) {
|
|
const drag = this.drag;
|
|
if (!drag || drag.pointerID !== event.pointerId) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
if (drag.shape) {
|
|
this.moveShape(event, drag);
|
|
return;
|
|
}
|
|
if (!drag.change && 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);
|
|
drag.change = objectTransform(drag.item, drag.item.page, to.x - from.x, to.y - from.y, drag.corner);
|
|
this.place(drag.item, drag.change);
|
|
}
|
|
|
|
end(event) {
|
|
const drag = this.drag;
|
|
if (!drag || drag.pointerID !== event.pointerId) {
|
|
return;
|
|
}
|
|
this.move(event);
|
|
if (drag.shape) {
|
|
this.drag = null;
|
|
this.viewer.releasePointerCapture(drag.pointerID);
|
|
if (!drag.box) {
|
|
drag.preview.remove();
|
|
return;
|
|
}
|
|
this.setTool("");
|
|
Promise.resolve(this.options.onDraw(drag.page.index, drag.shape, drag.box, drag.style))
|
|
.finally(() => drag.preview.remove());
|
|
return;
|
|
}
|
|
this.cancel();
|
|
if (drag.change && (drag.change.x || drag.change.y || drag.change.scale !== 1)) {
|
|
this.options.onTransform(drag.item, drag.change);
|
|
}
|
|
}
|
|
|
|
cancel() {
|
|
const drag = this.drag;
|
|
this.drag = null;
|
|
if (drag) {
|
|
if (drag.shape) {
|
|
drag.preview.remove();
|
|
} else {
|
|
this.place(drag.item);
|
|
}
|
|
if (this.viewer.hasPointerCapture(drag.pointerID)) {
|
|
this.viewer.releasePointerCapture(drag.pointerID);
|
|
}
|
|
}
|
|
}
|
|
|
|
startShape(event) {
|
|
const surface = event.target.closest(".page-surface");
|
|
const page = this.pages.get(surface);
|
|
if (!page) {
|
|
return;
|
|
}
|
|
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;
|
|
}
|
|
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);
|
|
}
|
|
|
|
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)) };
|
|
};
|
|
const from = point(drag.clientX, drag.clientY);
|
|
const to = point(event.clientX, event.clientY);
|
|
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
|
|
&& (drag.shape === "line" ? width > 0 || height > 0 : width > 0 && height > 0);
|
|
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);
|
|
}
|
|
}
|
|
|
|
keyDown(event) {
|
|
if (event.key === "Escape" && this.tool && !this.options.busy()) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
this.setTool("");
|
|
return;
|
|
}
|
|
if (this.input || !this.enabled || !this.selected || this.options.busy() || event.ctrlKey || event.metaKey || event.altKey || event.isComposing) {
|
|
return;
|
|
}
|
|
const directions = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
|
|
const edit = event.key === "Enter" || event.key === "F2";
|
|
if (event.key !== "Escape" && event.key !== "Delete" && !edit && !directions[event.key]) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
if (event.key === "Escape") {
|
|
this.drag ? this.cancel() : this.clear();
|
|
} else if (event.key === "Delete") {
|
|
this.options.onDelete(this.selected);
|
|
} else if (edit && !this.drag) {
|
|
this.options.onEdit(this.selected);
|
|
} else if (!this.drag) {
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
editText(item, face) {
|
|
this.cancel();
|
|
this.closeText();
|
|
this.select(item);
|
|
document.fonts.add(face);
|
|
const input = document.createElement("textarea");
|
|
input.className = "edit-text";
|
|
input.setAttribute("aria-label", "编辑文字");
|
|
input.wrap = "off";
|
|
input.spellcheck = false;
|
|
input.value = item.text;
|
|
Object.assign(input.style, {
|
|
left: `${item.x * PX_PER_MM}px`, top: `${item.y * PX_PER_MM}px`,
|
|
width: `${Math.max(item.width, Math.min(50, item.page.width - item.x)) * PX_PER_MM + 4}px`,
|
|
minHeight: `${item.height * PX_PER_MM + 4}px`,
|
|
fontFamily: `"${face.family}"`, fontSize: `${item.size * PX_PER_MM}px`,
|
|
lineHeight: item.lineHeight ? `${item.lineHeight * PX_PER_MM}px` : "normal", color: item.color,
|
|
});
|
|
this.input = { input, item, face };
|
|
item.surface.append(input);
|
|
const resize = () => {
|
|
input.style.height = "0px";
|
|
input.style.height = `${input.scrollHeight + 2}px`;
|
|
};
|
|
input.addEventListener("input", () => {
|
|
resize();
|
|
this.options.onTextChange();
|
|
});
|
|
input.addEventListener("blur", () => this.commitText());
|
|
input.addEventListener("keydown", (event) => {
|
|
event.stopPropagation();
|
|
if (event.isComposing || input.readOnly) {
|
|
return;
|
|
}
|
|
if (event.key === "Escape") {
|
|
event.preventDefault();
|
|
this.closeText();
|
|
this.viewer.focus({ preventScroll: true });
|
|
} else if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
|
|
event.preventDefault();
|
|
this.commitText();
|
|
}
|
|
});
|
|
resize();
|
|
input.focus({ preventScroll: true });
|
|
input.select();
|
|
}
|
|
|
|
async commitText() {
|
|
const editing = this.input;
|
|
if (!editing || editing.input.readOnly) {
|
|
return;
|
|
}
|
|
if (editing.input.value === editing.item.text) {
|
|
this.closeText();
|
|
return;
|
|
}
|
|
editing.input.readOnly = true;
|
|
const saved = await this.options.onCommitText(editing.item, editing.input.value);
|
|
if (this.input !== editing) {
|
|
return;
|
|
}
|
|
if (saved) {
|
|
this.closeText();
|
|
} else {
|
|
editing.input.readOnly = false;
|
|
editing.input.focus({ preventScroll: true });
|
|
}
|
|
}
|
|
|
|
closeText() {
|
|
const editing = this.input;
|
|
this.input = null;
|
|
if (editing) {
|
|
editing.input.remove();
|
|
document.fonts.delete(editing.face);
|
|
this.options.onTextChange();
|
|
}
|
|
}
|
|
}
|