feat(创建编辑): 新增基础图形绘制与样式设置,完善缩放和撤销重做
build.yaml / build (push) Successful in 38s

This commit is contained in:
xiaoqidun committed 2026-09-15 19:08:22 +08:00
1 parent cfcf1939d1
commit 1a90df1d83
6 files changed
+474 -23

No files matched your search

+12
View File
@@ -89,6 +89,18 @@
<button id="insertImageButton" class="button" type="button" aria-label="添加图片">图片</button> <button id="insertImageButton" class="button" type="button" aria-label="添加图片">图片</button>
<span>插入</span> <span>插入</span>
</div> </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>
</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>
<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>
<span>图形</span>
</div>
<div class="editor-group"> <div class="editor-group">
<button id="addPageButton" class="button" type="button" aria-label="新增页面">新增</button> <button id="addPageButton" class="button" type="button" aria-label="新增页面">新增</button>
<button id="copyPageButton" class="button" type="button" aria-label="复制页面">复制</button> <button id="copyPageButton" class="button" type="button" aria-label="复制页面">复制</button>
+45
View File
@@ -87,6 +87,8 @@ a:focus-visible {
.editor-toolbar { .editor-toolbar {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
max-height: 40dvh;
overflow: auto;
gap: 12px; gap: 12px;
padding: 8px 12px 6px; padding: 8px 12px 6px;
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
@@ -120,6 +122,19 @@ a:focus-visible {
min-width: 0; min-width: 0;
} }
.shape-paint,
.shape-paint label {
display: flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
.shape-paint input[type="checkbox"] {
margin: 0;
accent-color: var(--accent);
}
.editor-group:has(#addPageButton) { .editor-group:has(#addPageButton) {
grid-template-columns: repeat(6, max-content); grid-template-columns: repeat(6, max-content);
} }
@@ -1162,6 +1177,30 @@ button.outline-link:hover {
pointer-events: none; pointer-events: none;
} }
.editing-draw .page-surface {
cursor: crosshair;
touch-action: none;
user-select: none;
}
.editing-draw .edit-layer,
.editing-draw .page-surface > :not(.edit-preview) {
pointer-events: none;
}
.editing-draw .edit-object {
pointer-events: none;
}
.edit-preview {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
overflow: visible;
}
.edit-object { .edit-object {
position: absolute; position: absolute;
pointer-events: auto; pointer-events: auto;
@@ -1175,6 +1214,12 @@ button.outline-link:hover {
outline: calc(1px / var(--surface-scale)) solid var(--accent); outline: calc(1px / var(--surface-scale)) solid var(--accent);
} }
.edit-line::before {
position: absolute;
inset: calc(-4px / var(--surface-scale));
content: "";
}
.edit-object.selected { .edit-object.selected {
background: rgba(15, 123, 104, 0.04); background: rgba(15, 123, 104, 0.04);
} }
+115 -18
View File
@@ -94,6 +94,14 @@ const el = {
textFont: document.querySelector("#textFont"), textFont: document.querySelector("#textFont"),
textSize: document.querySelector("#textSize"), textSize: document.querySelector("#textSize"),
textColor: document.querySelector("#textColor"), textColor: document.querySelector("#textColor"),
drawLineButton: document.querySelector("#drawLineButton"),
drawRectangleButton: document.querySelector("#drawRectangleButton"),
drawEllipseButton: document.querySelector("#drawEllipseButton"),
shapeFill: document.querySelector("#shapeFill"),
shapeFillColor: document.querySelector("#shapeFillColor"),
shapeStroke: document.querySelector("#shapeStroke"),
shapeStrokeColor: document.querySelector("#shapeStrokeColor"),
shapeWidth: document.querySelector("#shapeWidth"),
undoButton: document.querySelector("#undoButton"), undoButton: document.querySelector("#undoButton"),
redoButton: document.querySelector("#redoButton"), redoButton: document.querySelector("#redoButton"),
insertTextButton: document.querySelector("#insertTextButton"), insertTextButton: document.querySelector("#insertTextButton"),
@@ -231,6 +239,10 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, {
change.x + item.x * (1 - change.scale), change.y + item.y * (1 - change.scale), change.scale), change.x + item.x * (1 - change.scale), change.y + item.y * (1 - change.scale), change.scale),
onDelete: (item) => changeDocument("ofdgoDeleteObject", item), onDelete: (item) => changeDocument("ofdgoDeleteObject", item),
onEdit: editCanvasObject, onEdit: editCanvasObject,
drawStyle: shapeStyle,
onTool: updateDrawingControls,
onDraw: (index, shape, box, style) => changeDocument("ofdgoInsertShape", null, index, shape,
box.x, box.y, box.width, box.height, style.fill, style.fillColor, style.stroke, style.strokeColor, style.lineWidth),
onCommitText: (item, value) => changeDocument("ofdgoUpdateText", item, value, null, item.size, null), onCommitText: (item, value) => changeDocument("ofdgoUpdateText", item, value, null, item.size, null),
onTextChange: () => { onTextChange: () => {
const editing = canvasEditor.input; const editing = canvasEditor.input;
@@ -246,7 +258,8 @@ el.selectObjectButton.addEventListener("click", () => {
if (document.body.hasAttribute("aria-busy")) { if (document.body.hasAttribute("aria-busy")) {
return; return;
} }
state.selectObjects = !state.selectObjects || state.panMode; state.selectObjects = !state.selectObjects || state.panMode || Boolean(canvasEditor.tool);
canvasEditor.setTool("");
if (state.selectObjects && state.panMode) { if (state.selectObjects && state.panMode) {
togglePan(); togglePan();
} }
@@ -259,7 +272,7 @@ el.deleteObjectButton.addEventListener("click", () => {
}); });
el.editObjectButton.addEventListener("click", () => { el.editObjectButton.addEventListener("click", () => {
if (canvasEditor.selected) { if (canvasEditor.selected) {
openInsertPanel(!canvasEditor.selected.image, canvasEditor.selected); openInsertPanel(canvasEditor.selected.type === "TextObject", canvasEditor.selected);
} }
}); });
el.textSize.addEventListener("change", () => changeTextStyle(false)); el.textSize.addEventListener("change", () => changeTextStyle(false));
@@ -267,19 +280,25 @@ el.textColor.addEventListener("change", () => changeTextStyle(true));
el.textFont.addEventListener("change", () => { el.textFont.addEventListener("change", () => {
const item = canvasEditor.selected; const item = canvasEditor.selected;
const font = state.textFonts[Number(el.textFont.value)]; const font = state.textFonts[Number(el.textFont.value)];
if (item && !item.image && state.ready && !document.body.hasAttribute("aria-busy") && font && !font.embedded) { if (item?.type === "TextObject" && state.ready && !document.body.hasAttribute("aria-busy") && font && !font.embedded) {
return changeDocument("ofdgoUpdateText", item, item.text, fontManager.read(font), item.size, null); return changeDocument("ofdgoUpdateText", item, item.text, fontManager.read(font), item.size, null);
} }
}); });
el.textSize.addEventListener("keydown", (event) => { el.shapeWidth.addEventListener("focus", () => { el.shapeWidth.defaultValue = el.shapeWidth.value; });
for (const input of [el.textSize, el.shapeWidth]) {
input.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === "Escape") { if (event.key === "Enter" || event.key === "Escape") {
event.preventDefault(); event.preventDefault();
if (event.key === "Escape") { if (event.key === "Escape") {
updateObjectControls(canvasEditor.selected, true); updateObjectControls(canvasEditor.selected, true);
if (input === el.shapeWidth && canvasEditor.selected?.type !== "PathObject") {
input.value = input.defaultValue;
} }
el.textSize.blur(); }
input.blur();
} }
}); });
}
el.copyObjectButton.addEventListener("click", () => { el.copyObjectButton.addEventListener("click", () => {
const item = canvasEditor.selected; const item = canvasEditor.selected;
if (!item) { if (!item) {
@@ -310,6 +329,21 @@ el.objectAlign.addEventListener("change", () => {
}); });
el.undoButton.addEventListener("click", () => changeDocument("ofdgoUndo")); el.undoButton.addEventListener("click", () => changeDocument("ofdgoUndo"));
el.redoButton.addEventListener("click", () => changeDocument("ofdgoRedo")); el.redoButton.addEventListener("click", () => changeDocument("ofdgoRedo"));
for (const [button, tool] of [[el.drawLineButton, "line"], [el.drawRectangleButton, "rectangle"], [el.drawEllipseButton, "ellipse"]]) {
button.addEventListener("click", () => {
if (!state.editing || document.body.hasAttribute("aria-busy")) {
return;
}
const next = canvasEditor.tool === tool ? "" : tool;
state.selectObjects = true;
setPan(false);
canvasEditor.setTool(next);
el.viewerPanel.focus({ preventScroll: true });
});
}
for (const input of [el.shapeFill, el.shapeFillColor, el.shapeStroke, el.shapeStrokeColor, el.shapeWidth]) {
input.addEventListener("change", changeShapeStyle);
}
el.addPageButton.addEventListener("click", () => { el.addPageButton.addEventListener("click", () => {
const page = currentPageInfo(); const page = currentPageInfo();
return changeDocument("ofdgoChangePage", null, "add", state.pageIndex, page.width, page.height); return changeDocument("ofdgoChangePage", null, "add", state.pageIndex, page.width, page.height);
@@ -651,7 +685,11 @@ async function createDocument(event) {
} }
async function editCanvasObject(item) { async function editCanvasObject(item) {
if (item.image) { if (item.type === "PathObject") {
el.shapeWidth.focus();
return;
}
if (item.type === "ImageObject") {
return openInsertPanel(false, item); return openInsertPanel(false, item);
} }
if (!state.editing || document.body.hasAttribute("aria-busy") || canvasEditor.input) { if (!state.editing || document.body.hasAttribute("aria-busy") || canvasEditor.input) {
@@ -709,7 +747,7 @@ async function openInsertPanel(text, item = null) {
function updateInsertFonts() { function updateInsertFonts() {
const selected = state.insertFonts[Number(el.insertFont.value)]; const selected = state.insertFonts[Number(el.insertFont.value)];
state.insertFonts = editorFonts(state.insertObject?.image ? null : state.insertObject); state.insertFonts = editorFonts(state.insertObject?.type === "TextObject" ? state.insertObject : null);
setFontOptions(el.insertFont, state.insertFonts, selected); setFontOptions(el.insertFont, state.insertFonts, selected);
el.insertSubmit.disabled = !el.insertTextRow.hidden && !state.insertFonts.length; el.insertSubmit.disabled = !el.insertTextRow.hidden && !state.insertFonts.length;
el.insertStatus.textContent = el.insertSubmit.disabled ? "尚未添加字体" : ""; el.insertStatus.textContent = el.insertSubmit.disabled ? "尚未添加字体" : "";
@@ -736,7 +774,7 @@ function setFontOptions(select, fonts, selected) {
} }
function updateTextFonts(item, refresh = false) { function updateTextFonts(item, refresh = false) {
const text = item && !item.image; const text = item?.type === "TextObject";
if (refresh || state.textFonts[0]?.id !== (text ? `embedded:${item.font}` : undefined)) { if (refresh || state.textFonts[0]?.id !== (text ? `embedded:${item.font}` : undefined)) {
state.textFonts = text ? editorFonts(item) : []; state.textFonts = text ? editorFonts(item) : [];
setFontOptions(el.textFont, state.textFonts, state.textFonts[0]); setFontOptions(el.textFont, state.textFonts, state.textFonts[0]);
@@ -815,7 +853,7 @@ function textPoints(size) {
async function changeTextStyle(color) { async function changeTextStyle(color) {
const item = canvasEditor.selected; const item = canvasEditor.selected;
if (!item || item.image || !state.ready || state.exporting) { if (item?.type !== "TextObject" || !state.ready || state.exporting) {
return; return;
} }
if (!color && !el.textSize.checkValidity()) { if (!color && !el.textSize.checkValidity()) {
@@ -831,6 +869,55 @@ async function changeTextStyle(color) {
updateObjectControls(canvasEditor.selected, true); updateObjectControls(canvasEditor.selected, true);
} }
function shapeStyle() {
return { fill: el.shapeFill.checked, fillColor: el.shapeFillColor.value,
stroke: el.shapeStroke.checked, strokeColor: el.shapeStrokeColor.value,
lineWidth: Number(el.shapeWidth.value) * 25.4 / 72 };
}
function strokePoints(width) {
return Number((width * 72 / 25.4).toPrecision(6));
}
async function changeShapeStyle() {
const item = canvasEditor.selected;
if (!state.editing || document.body.hasAttribute("aria-busy")) {
return;
}
if (!el.shapeWidth.checkValidity() || Number(el.shapeWidth.value) <= 0) {
el.shapeWidth.value = String(item?.type === "PathObject" ? strokePoints(item.lineWidth) : 1);
return;
}
if (!el.shapeFill.checked && !el.shapeStroke.checked) {
el.shapeStroke.checked = true;
}
const style = shapeStyle();
if (item?.type === "PathObject") {
await changeDocument("ofdgoUpdatePathStyle", item, style.fill, style.fillColor, style.stroke, style.strokeColor,
Number(el.shapeWidth.value) === strokePoints(item.lineWidth) ? item.lineWidth : style.lineWidth);
updateObjectControls(canvasEditor.selected, true);
}
updateDrawingControls();
}
function updateDrawingControls() {
const tool = canvasEditor.tool;
const line = tool === "line" || !tool && canvasEditor.selected?.line;
if (line) {
el.shapeFill.checked = false;
el.shapeStroke.checked = true;
}
const disabled = !state.editing || !state.ready || state.exporting;
for (const [button, name] of [[el.drawLineButton, "line"], [el.drawRectangleButton, "rectangle"], [el.drawEllipseButton, "ellipse"]]) {
button.disabled = disabled;
button.setAttribute("aria-pressed", String(tool === name));
}
el.selectObjectButton.setAttribute("aria-pressed", String(canvasEditor.enabled && !tool));
el.shapeFill.disabled = el.shapeStroke.disabled = disabled || line;
el.shapeFillColor.disabled = disabled || line || !el.shapeFill.checked;
el.shapeStrokeColor.disabled = el.shapeWidth.disabled = disabled || !line && !el.shapeStroke.checked;
}
async function changeDocument(name, item, ...args) { async function changeDocument(name, item, ...args) {
if (!state.editing || document.body.hasAttribute("aria-busy")) { if (!state.editing || document.body.hasAttribute("aria-busy")) {
return; return;
@@ -855,12 +942,12 @@ async function changeDocument(name, item, ...args) {
if (!item || name === "ofdgoDeleteObject") { if (!item || name === "ofdgoDeleteObject") {
canvasEditor.clear(); canvasEditor.clear();
} }
if (name === "ofdgoCopyObject") { if (name === "ofdgoCopyObject" || name === "ofdgoInsertShape") {
canvasEditor.selectLast = true; canvasEditor.selectLast = true;
} }
openSeq = ++state.openSeq; openSeq = ++state.openSeq;
if (item) { if (item || name === "ofdgoInsertShape") {
await refreshEditorPage(doc, item.index, openSeq); await refreshEditorPage(doc, item ? item.index : args[0], openSeq);
} else { } else {
const samePage = doc.pages.findIndex((page) => page.id === previous.id); const samePage = doc.pages.findIndex((page) => page.id === previous.id);
const pageIndex = doc.pageIndex ?? (samePage < 0 ? Math.min(state.pageIndex, doc.pageCount - 1) : samePage); const pageIndex = doc.pageIndex ?? (samePage < 0 ? Math.min(state.pageIndex, doc.pageCount - 1) : samePage);
@@ -2700,6 +2787,7 @@ function layoutPages() {
if (!state.doc) { if (!state.doc) {
return; return;
} }
canvasEditor.cancel();
for (const page of state.doc.pages || []) { for (const page of state.doc.pages || []) {
const shell = pageShell(page.index); const shell = pageShell(page.index);
if (shell) { if (shell) {
@@ -3657,7 +3745,6 @@ function rotatePages() {
if (document.body.hasAttribute("aria-busy")) { if (document.body.hasAttribute("aria-busy")) {
return; return;
} }
canvasEditor.cancel();
state.rotation = (state.rotation + 90) % 360; state.rotation = (state.rotation + 90) % 360;
clearRegionHighlights(); clearRegionHighlights();
el.viewerPanel.classList.remove("single-page-fits-height"); el.viewerPanel.classList.remove("single-page-fits-height");
@@ -3938,7 +4025,6 @@ function updateEditorTools() {
el.movePageNextButton.disabled = pagesDisabled || state.pageIndex === state.doc?.pageCount - 1; el.movePageNextButton.disabled = pagesDisabled || state.pageIndex === state.doc?.pageCount - 1;
const enabled = state.editing && state.selectObjects && !state.panMode; const enabled = state.editing && state.selectObjects && !state.panMode;
canvasEditor.setEnabled(enabled); canvasEditor.setEnabled(enabled);
el.selectObjectButton.setAttribute("aria-pressed", String(enabled));
updateObjectControls(canvasEditor.selected); updateObjectControls(canvasEditor.selected);
el.undoButton.disabled = !state.editorInfo?.canUndo || !state.ready || state.exporting; el.undoButton.disabled = !state.editorInfo?.canUndo || !state.ready || state.exporting;
el.redoButton.disabled = !state.editorInfo?.canRedo || !state.ready || state.exporting; el.redoButton.disabled = !state.editorInfo?.canRedo || !state.ready || state.exporting;
@@ -3946,15 +4032,26 @@ function updateEditorTools() {
function updateObjectControls(item, reset = false) { function updateObjectControls(item, reset = false) {
const disabled = !item || !state.ready || state.exporting; const disabled = !item || !state.ready || state.exporting;
el.deleteObjectButton.disabled = el.copyObjectButton.disabled = el.editObjectButton.disabled = el.objectAlign.disabled = disabled; el.deleteObjectButton.disabled = el.copyObjectButton.disabled = el.objectAlign.disabled = disabled;
el.textFont.disabled = el.textSize.disabled = el.textColor.disabled = disabled || item.image; el.editObjectButton.disabled = disabled || item.type === "PathObject";
el.textFont.disabled = el.textSize.disabled = el.textColor.disabled = disabled || item.type !== "TextObject";
updateTextFonts(item); updateTextFonts(item);
if (reset || el.textSize.disabled || document.activeElement !== el.textSize) { if (reset || el.textSize.disabled || document.activeElement !== el.textSize) {
el.textSize.value = item && !item.image ? textPoints(item.size) : ""; el.textSize.value = item?.type === "TextObject" ? textPoints(item.size) : "";
} }
if (reset || el.textColor.disabled || document.activeElement !== el.textColor) { if (reset || el.textColor.disabled || document.activeElement !== el.textColor) {
el.textColor.value = item && !item.image ? item.color : "#000000"; el.textColor.value = item?.type === "TextObject" ? item.color : "#000000";
} }
if (item?.type === "PathObject") {
el.shapeFill.checked = item.fill;
el.shapeStroke.checked = item.stroke;
el.shapeFillColor.value = item.fillColor;
el.shapeStrokeColor.value = item.strokeColor;
if (reset || document.activeElement !== el.shapeWidth) {
el.shapeWidth.value = strokePoints(item.lineWidth);
}
}
updateDrawingControls();
el.objectOrder.disabled = disabled || item.count <= 1; el.objectOrder.disabled = disabled || item.count <= 1;
for (const option of el.objectOrder.options) { 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) ? item.order === item.count - 1 : item.order === 0);
+101 -2
View File
@@ -24,7 +24,8 @@ export function objectTransform(box, page, dx, dy, corner = "") {
(left ? box.x + box.width : page.width - box.x) / box.width, (left ? box.x + box.width : page.width - box.x) / box.width,
(top ? box.y + box.height : page.height - box.y) / box.height, (top ? box.y + box.height : page.height - box.y) / box.height,
); );
const scale = Math.max(Math.min(1 / Math.min(box.width, box.height), limit), Math.min(limit, 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))); 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 }; return { x: left ? box.width * (1 - scale) : 0, y: top ? box.height * (1 - scale) : 0, scale };
} }
@@ -36,6 +37,8 @@ export class CanvasEditor {
this.enabled = false; this.enabled = false;
this.selected = null; this.selected = null;
this.nodes = new WeakMap(); this.nodes = new WeakMap();
this.pages = new WeakMap();
this.tool = "";
viewer.addEventListener("pointerdown", (event) => this.start(event)); viewer.addEventListener("pointerdown", (event) => this.start(event));
viewer.addEventListener("pointermove", (event) => this.move(event)); viewer.addEventListener("pointermove", (event) => this.move(event));
viewer.addEventListener("pointerup", (event) => this.end(event)); viewer.addEventListener("pointerup", (event) => this.end(event));
@@ -57,19 +60,34 @@ export class CanvasEditor {
this.enabled = enabled; this.enabled = enabled;
this.viewer.classList.toggle("editing-select", enabled); this.viewer.classList.toggle("editing-select", enabled);
if (!enabled) { if (!enabled) {
this.setTool("");
this.clear(); 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) { mount(index, page, surface) {
this.pages.set(surface, { index, width: page.width, height: page.height });
const layer = document.createElement("div"); const layer = document.createElement("div");
layer.className = "edit-layer"; layer.className = "edit-layer";
for (const object of page.objects) { for (const object of page.objects) {
const node = document.createElement("div"); const node = document.createElement("div");
node.className = "edit-object"; node.className = "edit-object";
if (object.line) {
node.classList.add("edit-line");
}
node.tabIndex = 0; node.tabIndex = 0;
node.setAttribute("role", "button"); node.setAttribute("role", "button");
node.setAttribute("aria-label", object.image ? "图片对象" : "文字对象"); node.setAttribute("aria-label", { ImageObject: "图片对象", TextObject: "文字对象", PathObject: "图形对象" }[object.type]);
const item = { ...object, index, page: { width: page.width, height: page.height }, node, surface }; const item = { ...object, index, page: { width: page.width, height: page.height }, node, surface };
this.nodes.set(node, item); this.nodes.set(node, item);
node.addEventListener("focus", () => this.select(item)); node.addEventListener("focus", () => this.select(item));
@@ -120,6 +138,10 @@ export class CanvasEditor {
if (!this.enabled || this.options.busy() || event.button !== 0 || this.drag) { if (!this.enabled || this.options.busy() || event.button !== 0 || this.drag) {
return; return;
} }
if (this.tool) {
this.startShape(event);
return;
}
const node = event.target.closest(".edit-object"); const node = event.target.closest(".edit-object");
if (node) { if (node) {
event.preventDefault(); event.preventDefault();
@@ -144,6 +166,10 @@ export class CanvasEditor {
return; return;
} }
event.preventDefault(); event.preventDefault();
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 && Math.hypot(event.clientX - drag.clientX, event.clientY - drag.clientY) < 3) {
return; return;
} }
@@ -159,6 +185,18 @@ export class CanvasEditor {
return; return;
} }
this.move(event); 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(); this.cancel();
if (drag.change && (drag.change.x || drag.change.y || drag.change.scale !== 1)) { if (drag.change && (drag.change.x || drag.change.y || drag.change.scale !== 1)) {
this.options.onTransform(drag.item, drag.change); this.options.onTransform(drag.item, drag.change);
@@ -169,14 +207,75 @@ export class CanvasEditor {
const drag = this.drag; const drag = this.drag;
this.drag = null; this.drag = null;
if (drag) { if (drag) {
if (drag.shape) {
drag.preview.remove();
} else {
this.place(drag.item); this.place(drag.item);
}
if (this.viewer.hasPointerCapture(drag.pointerID)) { if (this.viewer.hasPointerCapture(drag.pointerID)) {
this.viewer.releasePointerCapture(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) { 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) { if (this.input || !this.enabled || !this.selected || this.options.busy() || event.ctrlKey || event.metaKey || event.altKey || event.isComposing) {
return; return;
} }
+137 -3
View File
@@ -27,6 +27,7 @@ import (
"io" "io"
"math" "math"
"strconv" "strconv"
"strings"
"syscall/js" "syscall/js"
"github.com/xiaoqidun/ofdgo" "github.com/xiaoqidun/ofdgo"
@@ -102,6 +103,8 @@ func RunWASM() {
registerCallback("ofdgoInsertText", insertText) registerCallback("ofdgoInsertText", insertText)
registerCallback("ofdgoUpdateText", updateText) registerCallback("ofdgoUpdateText", updateText)
registerCallback("ofdgoInsertImage", insertImage) registerCallback("ofdgoInsertImage", insertImage)
registerCallback("ofdgoInsertShape", insertShape)
registerCallback("ofdgoUpdatePathStyle", updatePathStyle)
registerCallback("ofdgoReplaceImage", replaceImage) registerCallback("ofdgoReplaceImage", replaceImage)
registerCallback("ofdgoEditorFont", editorFont) registerCallback("ofdgoEditorFont", editorFont)
registerCallback("ofdgoAlignObject", alignObject) registerCallback("ofdgoAlignObject", alignObject)
@@ -609,11 +612,25 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
case "PathObject":
id = object.PathObject.ID
box, err = ofdgo.ParseBox(object.PathObject.Boundary)
if err != nil {
return nil, err
}
default: default:
continue continue
} }
if box.W > 0 && box.H > 0 { if box.W > 0 && box.H > 0 {
item := map[string]any{"id": id, "image": object.Type == "ImageObject", "x": box.X, "y": box.Y, "width": box.W, "height": box.H, "order": order, "count": len(layer.Objects)} 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)
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))
item["lineWidth"] = path.LineWidth * editorPathScale(path)
}
if object.Type == "TextObject" { if object.Type == "TextObject" {
item["text"], item["font"], item["fontName"], item["size"] = textValues[id], object.TextObject.Font, fontNames[object.TextObject.Font], object.TextObject.Size item["text"], item["font"], item["fontName"], item["size"] = textValues[id], object.TextObject.Font, fontNames[object.TextObject.Font], object.TextObject.Size
if codes := object.TextObject.TextCode; len(codes) > 1 { if codes := object.TextObject.TextCode; len(codes) > 1 {
@@ -733,6 +750,8 @@ func copyObject(args []js.Value) (any, error) {
boundary = &object.TextObject.Boundary boundary = &object.TextObject.Boundary
case "ImageObject": case "ImageObject":
boundary = &object.ImageObject.Boundary boundary = &object.ImageObject.Boundary
case "PathObject":
boundary = &object.PathObject.Boundary
default: default:
return nil, fmt.Errorf("unsupported object type %q", object.Type) return nil, fmt.Errorf("unsupported object type %q", object.Type)
} }
@@ -903,6 +922,16 @@ func updateText(args []js.Value) (any, error) {
// 入参: object 文字对象, value 十六进制色值 // 入参: object 文字对象, value 十六进制色值
// 返回: error 错误信息 // 返回: error 错误信息
func setTextColor(object *ofdgo.TextObject, value string) error { func setTextColor(object *ofdgo.TextObject, value string) error {
if object.FillColor == nil {
object.FillColor = &ofdgo.FillColor{}
}
return setEditorColor(object.FillColor, value)
}
// setEditorColor 写入浏览器RGB色值,保留其他颜色属性
// 入参: color 颜色, value 十六进制色值
// 返回: error 错误信息
func setEditorColor(color *ofdgo.FillColor, value string) error {
if len(value) != 7 || value[0] != '#' { if len(value) != 7 || value[0] != '#' {
return fmt.Errorf("invalid RGB color %q", value) return fmt.Errorf("invalid RGB color %q", value)
} }
@@ -910,11 +939,116 @@ func setTextColor(object *ofdgo.TextObject, value string) error {
if err != nil { if err != nil {
return err return err
} }
color.Value = fmt.Sprintf("%d %d %d", rgb[0], rgb[1], rgb[2])
return nil
}
// editorColorHex 将创作对象的RGB颜色转换为浏览器色值
// 入参: color 颜色
// 返回: string 十六进制色值
func editorColorHex(color *ofdgo.FillColor) string {
var r, g, b int
if color != nil {
fmt.Sscan(color.Value, &r, &g, &b)
}
return fmt.Sprintf("#%02x%02x%02x", r, g, b)
}
// editorPathScale 获取路径线宽的页面缩放比例
// 入参: object 路径对象
// 返回: float64 缩放比例
func editorPathScale(object ofdgo.PathObject) float64 {
m := ofdgo.NewMatrix(object.CTM)
x, y := m.Transform(0, 0)
ax, ay := m.Transform(1, 0)
bx, by := m.Transform(0, 1)
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) {
fill, stroke = false, true
}
if !fill && !stroke {
return fmt.Errorf("fill or stroke must be enabled")
}
if width <= 0 || math.IsNaN(width) || math.IsInf(width, 0) {
return fmt.Errorf("line width must be positive and finite")
}
object.Fill, object.Stroke = &fill, &stroke
if scale := editorPathScale(*object); width != object.LineWidth*scale {
object.LineWidth = width / scale
}
if object.FillColor == nil { if object.FillColor == nil {
object.FillColor = &ofdgo.FillColor{} object.FillColor = &ofdgo.FillColor{}
} }
object.FillColor.Value = fmt.Sprintf("%d %d %d", rgb[0], rgb[1], rgb[2]) if object.StrokeColor == nil {
return nil object.StrokeColor = &ofdgo.StrokeColor{}
}
if err := setEditorColor(object.FillColor, args[1].String()); err != nil {
return err
}
return setEditorColor((*ofdgo.FillColor)(object.StrokeColor), args[3].String())
}
// insertShape 创建标准路径图形
// 入参: args 页码、图形类型、范围和绘制样式
// 返回: any 文档信息, error 错误信息
func insertShape(args []js.Value) (any, error) {
if currentEditor == nil {
return nil, fmt.Errorf("no document is being created")
}
path, err := ofdgo.NewShape(ofdgo.ShapeKind(args[1].String()), ofdgo.Box{X: args[2].Float(), Y: args[3].Float(), W: args[4].Float(), H: args[5].Float()})
if err != nil {
return nil, err
}
if err := setPathStyle(&path, args[6:]); err != nil {
return nil, err
}
if _, err := currentEditor.AddObject(args[0].Int(), ofdgo.GraphicObject{Type: "PathObject", PathObject: path}); err != nil {
return nil, err
}
return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations)
}
// updatePathStyle 更新选中路径的绘制样式
// 入参: args 页码、对象标识和绘制样式
// 返回: any 文档信息, error 错误信息
func updatePathStyle(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")
}
if err := setPathStyle(&object.PathObject, args[2:]); 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)
} }
// insertText 使用选定字体创建文字对象 // insertText 使用选定字体创建文字对象
+64
View File
@@ -0,0 +1,64 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package ofdgo
import (
"fmt"
"math"
)
// ShapeKind 基本图形类型
type ShapeKind string
const (
ShapeLine ShapeKind = "line"
ShapeRectangle ShapeKind = "rectangle"
ShapeEllipse ShapeKind = "ellipse"
)
// NewShape 创建使用标准紧缩路径的图形,默认黑色描边、不填充。
// 可调整返回对象的颜色、线宽等属性后通过 Editor.AddObject 写入。
// 入参: kind 图形类型, box 图形范围;直线从 (X,Y) 到 (X+W,Y+H),W、H 可为负或零
// 返回: PathObject 路径对象, error 错误信息
func NewShape(kind ShapeKind, box Box) (PathObject, error) {
if !finite(box.X) || !finite(box.Y) || !finite(box.W) || !finite(box.H) || !finite(box.X+box.W) || !finite(box.Y+box.H) {
return PathObject{}, fmt.Errorf("shape coordinates must be finite")
}
object := PathObject{LineWidth: defaultPathLineWidth}
switch kind {
case ShapeLine:
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}
case ShapeRectangle, ShapeEllipse:
if box.W <= 0 || box.H <= 0 {
return PathObject{}, fmt.Errorf("shape dimensions must be positive")
}
if kind == ShapeRectangle {
object.AbbreviatedData = fmt.Sprintf("M 0 0 L %g 0 L %g %g L 0 %g C", box.W, box.W, box.H, box.H)
} else {
rx, ry := box.W/2, box.H/2
object.AbbreviatedData = fmt.Sprintf("M 0 %g A %g %g 0 0 1 %g %g A %g %g 0 0 1 0 %g C", ry, rx, ry, box.W, ry, rx, ry, ry)
}
default:
return PathObject{}, fmt.Errorf("unsupported shape %q", kind)
}
object.Boundary = fmt.Sprintf("%g %g %g %g", box.X, box.Y, box.W, box.H)
return object, nil
}