feat(创建编辑): 完善粘贴与跨页复制,新增对齐吸附,统一操作焦点与工具状态
build.yaml / build (push) Successful in 37s

This commit is contained in:
xiaoqidun committed 2026-09-16 08:21:08 +08:00
1 parent 89797af8d6
commit 11b3f38441
4 files changed
+179 -27

No files matched your search

+78 -3
View File
@@ -6,6 +6,7 @@ const COMPACT_LAYOUT = window.matchMedia("(max-width: 900px)");
const DEFAULT_IMAGE_DPI = 300;
const PAGE_CACHE_LIMIT = 16;
const PAGE_CACHE_BYTES = 32 * 1024 * 1024;
const OBJECT_CLIPBOARD_TYPE = "application/x-ofdgo-objects";
const STATUS = {
ready: "选择 OFD 文件",
opening: "正在打开文档",
@@ -37,6 +38,7 @@ const state = {
editorInfo: null,
savedRevision: null,
insertObject: null,
objectClipboard: null,
textFonts: [],
textFontID: null,
textDefaults: { type: "TextObject", size: 12 * 25.4 / 72, color: "#000000", wrap: true, align: "left", paragraphHeight: 0, letterSpacing: 0 },
@@ -503,6 +505,7 @@ el.panButton.addEventListener("click", togglePan);
el.continuousButton.addEventListener("click", toggleContinuous);
el.annotationButton.addEventListener("click", toggleAnnotations);
document.addEventListener("copy", copySelection);
el.viewerPanel.addEventListener("paste", pasteEditorContent);
document.addEventListener("selectionchange", syncSelection);
el.exportFormat.addEventListener("change", () => updateDPIControl());
el.exportPageButton.addEventListener("click", () => exportFile(false));
@@ -640,7 +643,7 @@ function handleKeyDown(event) {
}
if (key === "Enter" && target === el.pageInput && !event.shiftKey) {
event.preventDefault();
el.viewerPanel.focus({ preventScroll: true });
if (!event.repeat) el.pageInput.dispatchEvent(new Event("change"));
return;
}
if (key === "Enter" && target === el.searchInput && event.shiftKey && state.searchMatches.length) {
@@ -742,6 +745,7 @@ async function createDocument(event) {
return;
}
state.editing = true;
state.objectClipboard = null;
canvasEditor.clear();
delete state.textDefaults.fontChoice;
updateTextFonts(null, true);
@@ -1143,11 +1147,14 @@ async function changeDocument(name, item, ...args) {
if (!item || name === "ofdgoDeleteObject" || name === "ofdgoDeleteObjects") {
canvasEditor.clear();
}
if (name === "ofdgoCopyObjects" || name === "ofdgoInsertShape" || name === "ofdgoInsertText") {
if (name === "ofdgoCopyObjects" || name === "ofdgoPasteObjects" || name === "ofdgoInsertShape" || name === "ofdgoInsertText" || name === "ofdgoInsertImage") {
state.selectObjects = true;
canvasEditor.setTool("");
setPan(false);
canvasEditor.pendingSelection = { index: item?.index ?? args[0], ids: doc.selectedIDs };
}
openSeq = ++state.openSeq;
if (item || name === "ofdgoInsertShape" || name === "ofdgoInsertText") {
if (item || name === "ofdgoPasteObjects" || name === "ofdgoInsertShape" || name === "ofdgoInsertText" || name === "ofdgoInsertImage") {
await refreshEditorPage(doc, item ? item.index : args[0], openSeq);
} else {
const samePage = doc.pages.findIndex((page) => page.id === previous.id);
@@ -1668,6 +1675,7 @@ async function openOFD(file) {
state.ofdBytes = bytes;
state.fileName = file.name || "ofdgo.ofd";
state.editing = false;
state.objectClipboard = null;
state.editorInfo = null;
state.savedRevision = null;
canvasEditor.clear();
@@ -2816,11 +2824,78 @@ async function selectDocumentText() {
}
}
function copyEditorSelection(event) {
if (!state.editing || !canvasEditor.enabled || !canvasEditor.selected || !el.viewerPanel.contains(event.target)
|| canvasEditor.input || canvasEditor.crop || canvasEditor.drag || document.body.hasAttribute("aria-busy")) return false;
const items = canvasEditor.items().slice().sort((a, b) => a.order - b.order);
const clipboard = { token: crypto.randomUUID(), page: state.doc.pages[items[0].index].id, offset: 0 };
event.clipboardData.setData(OBJECT_CLIPBOARD_TYPE, clipboard.token);
event.clipboardData.setData("text/plain", items.map(item => item.text || "").filter(Boolean).join("\n"));
event.preventDefault();
state.objectClipboard = clipboard;
clipboard.ready = callWASM("ofdgoCaptureObjects", items[0].index, items.map(item => item.id), clipboard.token)
.then(() => true, err => {
if (state.objectClipboard === clipboard) showError(err, false);
return false;
});
return true;
}
async function pasteEditorContent(event) {
if (!state.editing || event.target.closest?.("input, textarea, select, [contenteditable]") || formDialogOpen()) return;
event.preventDefault();
if (document.body.hasAttribute("aria-busy") || canvasEditor.input || canvasEditor.crop || canvasEditor.drag) return;
const token = event.clipboardData.getData(OBJECT_CLIPBOARD_TYPE);
const index = state.pageIndex, page = state.doc.pages[index];
if (token) {
const clipboard = state.objectClipboard;
if (!clipboard || token !== clipboard.token) {
setStatus("对象剪贴板已失效");
return;
}
const openSeq = state.openSeq;
if (!await clipboard.ready || openSeq !== state.openSeq || index !== state.pageIndex || state.objectClipboard !== clipboard) return;
const offset = clipboard.page === page.id ? clipboard.offset + 3 : 0;
if (await changeDocument("ofdgoPasteObjects", null, index, token, offset, offset)) {
clipboard.page = page.id;
clipboard.offset = offset;
}
return;
}
const files = Array.from(event.clipboardData.files);
const x = Math.min(20, page.width / 10), y = Math.min(20, page.height / 10);
if (files.length) {
if (files.length !== 1) {
setStatus("一次粘贴一张图片");
return;
}
if (!/^image\/(png|jpeg)$/.test(files[0].type)) {
setStatus("图片仅支持 PNG、JPG");
return;
}
await changeDocument("ofdgoInsertImage", null, index, files[0].arrayBuffer().then(bytes => new Uint8Array(bytes)), x, y, page.width - x * 2);
return;
}
const value = event.clipboardData.getData("text/plain").replace(/\t/g, " ");
if (!value.trim()) return;
const font = state.textFonts[Number(el.textFont.value)];
if (!font) {
setStatus("尚未添加字体");
el.textFontAdd.focus();
return;
}
const style = currentTextStyle();
const data = font.embedded ? callWASM("ofdgoEditorFont", font.id.slice(9)).then(result => result.bytes) : fontManager.read(font);
await changeDocument("ofdgoInsertText", null, index, value, data, x, y, style.size, style.color,
page.width - x * 2, style.wrap, style.align || "left", style.paragraphHeight || 0, style.letterSpacing || 0);
}
function copySelection(event) {
if (event.target.closest?.("input, textarea, [contenteditable]")) {
return;
}
const selection = document.getSelection();
if (selection.isCollapsed && copyEditorSelection(event)) return;
if (selection.isCollapsed || !selection.rangeCount) {
return;
}
+14 -10
View File
@@ -27,17 +27,19 @@ export function selectionBounds(items) {
height: Math.max(...items.map(item => item.y + item.height)) - y };
}
function alignmentGuides(box, targets, tolerance) {
function alignmentSnap(box, targets, tolerance) {
const lines = [];
const offset = { x: 0, y: 0 };
for (const [axis, size, cross, span] of [["x", "width", "y", "height"], ["y", "height", "x", "width"]]) {
let distance = Infinity, line;
for (const target of targets) {
for (const anchor of [0, 0.5, 1]) {
const position = target[axis] + target[size] * anchor;
for (const edge of [0, 0.5, 1]) {
const delta = Math.abs(box[axis] + box[size] * edge - position);
if (delta > tolerance[axis] || delta >= distance) continue;
distance = delta;
const delta = position - box[axis] - box[size] * edge;
if (Math.abs(delta) > tolerance[axis] || Math.abs(delta) >= distance) continue;
distance = Math.abs(delta);
offset[axis] = delta;
const from = Math.min(box[cross], target[cross]);
const to = Math.max(box[cross] + box[span], target[cross] + target[span]);
line = axis === "x" ? `M${position} ${from}V${to}` : `M${from} ${position}H${to}`;
@@ -46,7 +48,7 @@ function alignmentGuides(box, targets, tolerance) {
}
if (line) lines.push(line);
}
return lines.join("");
return { ...offset, path: lines.join("") };
}
function transformedBox(box, matrix) {
@@ -422,16 +424,18 @@ export class CanvasEditor {
return;
}
drag.change = objectTransform(drag.item, to.x - from.x, to.y - from.y, drag.corner);
if (!drag.corner) this.snap(drag, event.altKey);
this.place(drag.item, drag.change);
if (!drag.corner) this.showGuides(drag);
}
showGuides(drag) {
snap(drag, bypass = false) {
const { item, rect, rotation, change } = drag;
const path = alignmentGuides({ ...item, x: item.x + change.x, y: item.y + change.y }, drag.targets, {
const { x, y, path } = bypass ? { x: 0, y: 0, path: "" } : alignmentSnap({ ...item, x: item.x + change.x, y: item.y + change.y }, drag.targets, {
x: 4 * item.page.width / (rotation % 180 ? rect.height : rect.width),
y: 4 * item.page.height / (rotation % 180 ? rect.width : rect.height),
});
change.x += x;
change.y += y;
if (!drag.guides && path) {
drag.guides = this.createPreview(item.surface, item.page, "path", {
fill: false, stroke: true, strokeColor: "var(--accent)", lineWidth: 1,
@@ -620,9 +624,9 @@ export class CanvasEditor {
modifierChange(event) {
const pointer = this.drag?.pointer;
if (event.key === "Shift" && pointer) {
if ((event.key === "Shift" || event.key === "Alt") && pointer) {
this.move({ pointerId: pointer.pointerId, clientX: pointer.clientX, clientY: pointer.clientY,
shiftKey: event.shiftKey, preventDefault() {} });
shiftKey: event.shiftKey, altKey: event.altKey, preventDefault() {} });
}
}