feat(编辑能力): 修复复制页面后删除原页可能导致的字体、图片丢失问题
build.yaml / build (push) Successful in 38s

This commit is contained in:
xiaoqidun committed 2026-09-16 23:03:45 +08:00
1 parent 299ff8d002
commit ee48ca666d
13 files changed
+464 -84

No files matched your search

+12 -1
View File
@@ -206,6 +206,14 @@
<div class="form-actions"><div id="paragraphStatus" class="form-status" role="status"></div><button id="paragraphCancel" class="button" type="button">取消</button><button class="button" type="submit">确定</button></div>
</form>
</dialog>
<dialog id="infoPanel" class="form-dialog" aria-label="文档信息">
<form id="infoForm">
<label class="form-row"><span>标题</span><input id="infoTitle" type="text" autofocus></label>
<label class="form-row"><span>作者</span><input id="infoAuthor" type="text"></label>
<label class="form-row"><span>主题</span><input id="infoSubject" type="text"></label>
<div class="form-actions"><div id="infoStatus" class="form-status" role="status"></div><button id="infoCancel" class="button" type="button">取消</button><button class="button" type="submit">确定</button></div>
</form>
</dialog>
<dialog id="pagePanel" class="form-dialog" aria-label="页面设置">
<form id="pageForm">
<div class="form-row">
@@ -320,7 +328,10 @@
</div>
</div>
</section>
<div class="panel-title">文档</div>
<div class="panel-row">
<div class="panel-title compact">文档</div>
<button id="infoButton" class="small-button" type="button" aria-label="编辑文档信息" aria-haspopup="dialog" aria-controls="infoPanel" hidden>修改</button>
</div>
<div id="editNotice" class="font-permission-hint" role="status" hidden></div>
<dl class="meta-list">
<div>
+12 -2
View File
@@ -225,6 +225,9 @@ a:focus-visible {
}
.font-picker #textFont {
flex: 1 1 0;
width: 0;
text-overflow: ellipsis;
padding: 0 6px;
border: 0;
outline: 0;
@@ -263,6 +266,7 @@ a:focus-visible {
color: #1f2428;
box-shadow: 0 5px 18px rgba(20, 40, 50, 0.14);
overflow-y: auto;
overflow-x: hidden;
font-size: 14px;
}
@@ -1250,6 +1254,7 @@ button.outline-link:hover {
}
.empty-state[hidden],
.panel-row > [hidden],
.meta-list > [hidden],
.navigation-tabs[hidden],
.form-row[hidden],
@@ -1387,7 +1392,7 @@ button.outline-link:hover {
.edit-text {
position: absolute;
overflow: hidden;
overflow: visible;
z-index: 1;
margin: -2px;
padding: 1px;
@@ -1395,7 +1400,6 @@ button.outline-link:hover {
border-radius: 0;
background: #fff;
outline: none;
resize: none;
transform-origin: 0 0;
font-kerning: none;
font-variant-ligatures: none;
@@ -1404,6 +1408,12 @@ button.outline-link:hover {
touch-action: auto;
}
.edit-text div,
.edit-text p {
margin: 0;
text-indent: inherit;
}
.edit-text:focus-visible {
outline: none;
}
+68 -12
View File
@@ -1,4 +1,4 @@
import { CanvasEditor, canEditObject, missingGlyphMessage, objectEditReason, selectedText } from "./ofdgo_edit.js";
import { CanvasEditor, canEditObject, editTextValue, missingGlyphMessage, objectEditReason, selectedText } from "./ofdgo_edit.js";
import { FontManager, FontPicker } from "./ofdgo_font.js";
const MM_TO_PX = 96 / 25.4;
@@ -116,6 +116,14 @@ const el = {
textFontList: document.querySelector("#textFontList"),
textFontAdd: document.querySelector("#textFontAdd"),
paragraphButton: document.querySelector("#paragraphButton"),
infoButton: document.querySelector("#infoButton"),
infoPanel: document.querySelector("#infoPanel"),
infoForm: document.querySelector("#infoForm"),
infoTitle: document.querySelector("#infoTitle"),
infoAuthor: document.querySelector("#infoAuthor"),
infoSubject: document.querySelector("#infoSubject"),
infoCancel: document.querySelector("#infoCancel"),
infoStatus: document.querySelector("#infoStatus"),
paragraphPanel: document.querySelector("#paragraphPanel"),
paragraphForm: document.querySelector("#paragraphForm"),
paragraphCancel: document.querySelector("#paragraphCancel"),
@@ -329,7 +337,7 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, {
function updatePendingChanges() {
const editing = canvasEditor.input;
setDirty(Boolean(state.editorInfo) && (state.editorInfo.revision !== state.savedRevision
|| Boolean(editing && (editing.input.value !== editing.item.text || editing.fontData)) || canvasEditor.cropChanged()));
|| Boolean(editing && (editTextValue(editing.input) !== editing.item.text || editing.fontData)) || canvasEditor.cropChanged()));
}
el.editorTools.addEventListener("pointerdown", (event) => {
@@ -383,6 +391,16 @@ editorClick(el.paragraphButton, () => {
el.paragraphPanel.showModal();
});
el.paragraphCancel.addEventListener("click", () => el.paragraphPanel.close());
editorClick(el.infoButton, () => {
for (const key of ["Title", "Author", "Subject"]) el[`info${key}`].value = state.doc[key.toLowerCase()] || "";
el.infoStatus.textContent = "";
el.infoPanel.showModal();
});
el.infoCancel.addEventListener("click", () => el.infoPanel.close());
el.infoForm.addEventListener("submit", async event => {
event.preventDefault();
if (await changeDocument("ofdgoUpdateInfo", null, el.infoTitle.value, el.infoAuthor.value, el.infoSubject.value)) el.infoPanel.close();
});
el.paragraphForm.addEventListener("submit", async event => {
event.preventDefault();
if (document.body.hasAttribute("aria-busy") || !el.paragraphForm.reportValidity()) return;
@@ -779,7 +797,7 @@ function handleKeyDown(event) {
}
function formDialogOpen() {
return el.exportPanel.open || el.createPanel.open || el.insertPanel.open || el.pagePanel.open || el.paragraphPanel.open || el.importPanel.open;
return el.exportPanel.open || el.createPanel.open || el.insertPanel.open || el.pagePanel.open || el.paragraphPanel.open || el.importPanel.open || el.infoPanel.open;
}
function openImportPanel() {
@@ -1182,7 +1200,7 @@ async function changeTextFont() {
setBusy(true);
try {
const data = await readTextFont(font);
await callWASM("ofdgoCheckTextFont", data, editing.input.value);
await callWASM("ofdgoCheckTextFont", data, editTextValue(editing.input));
const face = new FontFace("ofdgo-edit-font", data);
await face.load();
if (openSeq !== state.openSeq || canvasEditor.input !== editing) return;
@@ -1408,9 +1426,13 @@ async function changeDocument(name, item, ...args) {
return true;
}
setEditorInfo(doc);
if (!item || name === "ofdgoDeleteObject" || name === "ofdgoDeleteObjects" || name === "ofdgoEraseObjects" || name === "ofdgoEraseObjectsPath") {
canvasEditor.clear();
if (name === "ofdgoUpdateInfo") {
Object.assign(state.doc, { title: doc.title, author: doc.author, subject: doc.subject });
renderMeta();
updateControls();
return true;
}
const clearSelection = !item || ["ofdgoDeleteObject", "ofdgoDeleteObjects", "ofdgoEraseObjects", "ofdgoEraseObjectsPath"].includes(name);
if (name === "ofdgoCopyObjects" || name === "ofdgoPasteObjects" || name === "ofdgoInsertShape" || name === "ofdgoInsertText" || name === "ofdgoInsertImage") {
state.selectObjects = true;
canvasEditor.setTool("");
@@ -1419,7 +1441,7 @@ async function changeDocument(name, item, ...args) {
}
openSeq = ++state.openSeq;
if (item || name === "ofdgoPasteObjects" || name === "ofdgoInsertShape" || name === "ofdgoInsertText" || name === "ofdgoInsertImage") {
await refreshEditorPage(doc, item ? item.index : args[0], openSeq);
await refreshEditorPage(doc, item ? item.index : args[0], openSeq, clearSelection);
} else {
const samePage = doc.pages.findIndex((page) => page.id === previous.id);
const pageIndex = doc.pageIndex ?? (samePage < 0 ? Math.min(state.pageIndex, doc.pageCount - 1) : samePage);
@@ -1427,7 +1449,7 @@ async function changeDocument(name, item, ...args) {
const keepScroll = doc.pageIndex === undefined && page.id === previous.id && pageIndex === state.pageIndex
&& page.width === previous.width && page.height === previous.height;
await openDocument({ doc, openSeq, skipAutoFonts: true, pageIndex, fitMode: state.fitMode, scale: state.scale,
keepPreview: true, previewScroll: keepScroll ? { scrollLeft, scrollTop } : null });
keepPreview: true, clearSelection, previewScroll: keepScroll ? { scrollLeft, scrollTop } : null });
}
if (openSeq === state.openSeq) {
if (!toolbarFocus) el.viewerPanel.focus({ preventScroll: true });
@@ -1435,7 +1457,9 @@ async function changeDocument(name, item, ...args) {
}
} catch (err) {
if (openSeq === state.openSeq) {
if (el.pagePanel.open) {
if (el.infoPanel.open) {
el.infoStatus.textContent = err.message;
} else if (el.pagePanel.open) {
el.pageStatus.textContent = err.message;
} else if (el.paragraphPanel.open) {
el.paragraphStatus.textContent = err.message;
@@ -1453,7 +1477,7 @@ async function changeDocument(name, item, ...args) {
}
}
async function refreshEditorPage(doc, index, openSeq) {
async function refreshEditorPage(doc, index, openSeq, clearSelection = false) {
state.doc = doc;
resetSearch();
state.documentSelection = null;
@@ -1463,6 +1487,11 @@ async function refreshEditorPage(doc, index, openSeq) {
if (openSeq !== state.openSeq) {
return;
}
if (clearSelection) {
const pending = canvasEditor.pendingSelection;
canvasEditor.clear();
canvasEditor.pendingSelection = pending;
}
mountPageSVG(index, page, openSeq);
updateThumbnail(index, openSeq);
setStatus(pageStatus(state.pageIndex, doc.pageCount));
@@ -1953,6 +1982,7 @@ async function openOFD(file) {
el.insertPanel.close();
el.pagePanel.close();
el.paragraphPanel.close();
el.infoPanel.close();
el.importPanel.close();
updateControls();
await openDocument({ pageIndex: 0, resetScroll: true, openSeq });
@@ -2332,7 +2362,7 @@ async function openDocument(options = {}) {
return;
}
const openSeq = options.openSeq || (state.openSeq += 1);
const previewIDs = options.keepPreview ? [...state.visiblePages].map((index) => state.doc.pages[index].id) : [];
const previewIDs = options.keepPreview ? [...new Set([...state.visiblePages, ...state.visibleThumbnails])].map((index) => state.doc.pages[index].id) : [];
if (!state.ready || state.wasmExited) {
await ensureWASM();
}
@@ -2405,9 +2435,10 @@ async function openDocument(options = {}) {
}
}
}
if (options.clearSelection) canvasEditor.clear();
resetPageFlow(options.keepPreview);
renderPageList();
if (options.resetScroll || el.outlineList.childElementCount === 0) {
if (options.keepPreview || options.resetScroll || el.outlineList.childElementCount === 0) {
renderOutlines();
}
renderMeta();
@@ -2893,6 +2924,17 @@ async function processPageRenderQueue() {
}
page.imageNames = page.images.map((image) => image.name);
delete page.images;
if (task.refresh && state.editing) {
await Promise.all(page.imageNames.map(name => {
const image = new Image();
image.src = state.svgImages.get(name).url;
return image.decode();
}));
}
if (task.openSeq !== state.openSeq) {
task.resolve(null);
continue;
}
page.cacheBytes = (page.svg.length + page.text.length) * 2;
page.text = JSON.parse(page.text);
state.pageCache.set(task.index, page);
@@ -3613,6 +3655,17 @@ async function selectSearchMatch(index) {
} else {
scrollToPage(match.page);
}
if (state.editing) {
const node = [...pageShell(match.page).querySelectorAll(".edit-object")].find(node => canvasEditor.nodes.get(node)?.id === match.id);
const item = node && canvasEditor.nodes.get(node);
if (item?.type === "TextObject" && canEditObject(item, "update")) {
state.selectObjects = true;
setPan(false);
canvasEditor.setTool("");
canvasEditor.select(item);
updateControls();
} else canvasEditor.select(null);
}
const button = el.searchResults.querySelector("[aria-current=true]");
const row = button.getBoundingClientRect();
const panel = el.searchResults.getBoundingClientRect();
@@ -3621,6 +3674,7 @@ async function selectSearchMatch(index) {
} else if (row.bottom > panel.bottom) {
el.searchResults.scrollTop += row.bottom - panel.bottom;
}
return true;
}
function clearSearchHighlights() {
@@ -4605,6 +4659,7 @@ function updateEditorTools() {
el.editButton.title = state.editing ? "阅读" : "编辑";
el.editButton.setAttribute("aria-label", el.editButton.title);
el.editNotice.textContent = state.editorInfo?.editWarnings?.join(";") || "";
el.infoButton.hidden = !state.editing;
el.editNotice.hidden = !state.editing || !el.editNotice.textContent;
el.editorTools.hidden = !state.editing;
const pagesDisabled = !state.editing || !state.ready || state.exporting;
@@ -4746,6 +4801,7 @@ function setBusy(busy, text = "", percent = 0, status = "") {
el.insertForm.inert = busy;
el.pageForm.inert = busy;
el.paragraphForm.inert = busy;
el.infoForm.inert = busy;
el.importForm.inert = busy;
el.editorTools.inert = busy;
el.fontList.inert = busy;
+73 -37
View File
@@ -1,5 +1,16 @@
const PX_PER_MM = 96 / 25.4;
export function editTextValue(input) {
let text = "", previous;
for (const node of input.childNodes) {
const block = node.nodeName === "DIV" || node.nodeName === "P";
if (previous && (block || previous.nodeName === "DIV" || previous.nodeName === "P") && previous.nodeName !== "BR") text += "\n";
text += node.nodeType === 3 ? node.textContent : node.nodeName === "BR" ? (node.nextSibling ? "\n" : "") : editTextValue(node);
previous = node;
}
return text;
}
export function pagePoint(x, y, rect, page, rotation) {
const u = (x - rect.left) / rect.width;
const v = (y - rect.top) / rect.height;
@@ -27,6 +38,21 @@ export function selectionBounds(items) {
height: Math.max(...items.map(item => item.y + item.height)) - y };
}
function resizeCorner(node, event) {
let corner = event.target.dataset.corner || "", distance = Infinity;
if (!corner) return corner;
for (const handle of node.children) {
if (!handle.dataset.corner) continue;
const rect = handle.getBoundingClientRect();
const next = (event.clientX - rect.left - rect.width / 2) ** 2 + (event.clientY - rect.top - rect.height / 2) ** 2;
if (next < distance || next === distance && handle.dataset.corner === event.target.dataset.corner) {
corner = handle.dataset.corner;
distance = next;
}
}
return corner;
}
function alignmentSnap(box, targets, tolerance) {
const lines = [];
const offset = { x: 0, y: 0 };
@@ -353,7 +379,7 @@ export class CanvasEditor {
start(event) {
if (this.input) {
if (event.target !== this.input.input) this.commitText();
if (!this.input.input.contains(event.target)) this.commitText();
return;
}
if (!this.enabled || this.options.busy() || event.button !== 0 || this.drag) {
@@ -367,7 +393,7 @@ export class CanvasEditor {
event.preventDefault();
this.viewer.focus({ preventScroll: true });
const { item, box } = this.crop;
this.drag = { crop: true, box: { ...box }, pointerID: event.pointerId, corner: event.target.dataset.corner || "",
this.drag = { crop: true, box: { ...box }, pointerID: event.pointerId, corner: resizeCorner(this.crop.node, event),
page: item.page, rect: item.surface.getBoundingClientRect(), rotation: this.options.rotation(), clientX: event.clientX, clientY: event.clientY };
this.viewer.setPointerCapture(event.pointerId);
return;
@@ -403,7 +429,7 @@ export class CanvasEditor {
const item = this.selected;
if (!canEditObject(item, "transform")) return;
this.drag = {
item, pointerID: event.pointerId, corner: !item.items || target === item ? event.target.dataset.corner || "" : "",
item, pointerID: event.pointerId, corner: !item.items || target === item ? resizeCorner(node, event) : "",
rect: item.surface.getBoundingClientRect(), rotation: this.options.rotation(),
clientX: event.clientX, clientY: event.clientY,
};
@@ -568,10 +594,12 @@ export class CanvasEditor {
});
return;
}
this.cancel();
if (drag.change && (drag.change.x || drag.change.y || drag.change.scale !== 1)) {
this.options.onTransform(drag.item, drag.change);
}
this.drag = null;
drag.guides?.preview.remove();
this.viewer.releasePointerCapture(drag.pointerID);
Promise.resolve(this.options.onTransform(drag.item, drag.change)).finally(() => this.place(drag.item));
} else this.cancel();
}
cancel() {
@@ -796,34 +824,47 @@ export class CanvasEditor {
this.closeText();
this.select(item);
document.fonts.add(face);
const input = document.createElement("textarea");
const input = document.createElement("div");
input.className = "edit-text";
input.setAttribute("aria-label", "编辑文字");
input.wrap = item.wrap ? "soft" : "off";
input.setAttribute("role", "textbox");
input.setAttribute("aria-multiline", "true");
input.contentEditable = "true";
input.spellcheck = false;
input.value = item.text;
for (const text of item.text.split("\n")) {
const paragraph = document.createElement("div");
if (text) paragraph.textContent = text;
else paragraph.append(document.createElement("br"));
input.append(paragraph);
}
const left = item.leftIndent || 0, right = item.rightIndent || 0;
const frame = item.textFrame || { width: item.width, height: item.height, matrix: [1, 0, 0, 1, item.x, item.y] };
const [a, b, c, d, e, f] = frame.matrix;
Object.assign(input.style, {
left: `${item.x * PX_PER_MM}px`, top: `${item.y * PX_PER_MM}px`,
width: `${(item.wrap ? item.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`,
left: "0px", top: "0px",
width: `${(Math.max(frame.width, item.wrap ? frame.width : 50) - left - right) * PX_PER_MM + 4}px`,
minHeight: `${frame.height * PX_PER_MM + 4}px`,
transform: `matrix(${a},${b},${c},${d},${(e + a * left) * PX_PER_MM},${(f + b * left) * PX_PER_MM})`,
whiteSpace: item.wrap ? "pre-wrap" : "pre",
fontFamily: `"${face.family}"`, fontSize: `${item.size * PX_PER_MM}px`,
lineHeight: item.paragraphHeight || item.lineHeight ? `${(item.paragraphHeight || item.lineHeight) * PX_PER_MM}px` : "normal", color: item.color,
textAlign: item.align || "left",
letterSpacing: `${(item.letterSpacing || 0) * PX_PER_MM}px`,
paddingLeft: `${2 + Math.max(0, item.leftIndent || 0) * PX_PER_MM}px`,
paddingRight: `${2 + Math.max(0, item.rightIndent || 0) * PX_PER_MM}px`,
textIndent: `${(item.firstLineIndent || 0) * PX_PER_MM}px`,
});
if (item.textFrame) {
const { width, height, matrix: [a, b, c, d, e, f] } = item.textFrame;
Object.assign(input.style, { left: "0px", top: "0px", width: `${Math.max(width, item.wrap ? width : 50) * PX_PER_MM + 4}px`,
minHeight: `${height * PX_PER_MM + 4}px`, transform: `matrix(${a},${b},${c},${d},${e * PX_PER_MM},${f * PX_PER_MM})` });
}
this.input = { input, item, face, fontChoice: item.fontChoice };
item.surface.append(input);
input.addEventListener("input", () => {
this.resizeText();
this.options.onTextChange();
input.addEventListener("input", () => this.options.onTextChange());
input.addEventListener("paste", event => {
event.preventDefault();
document.execCommand("insertText", false, event.clipboardData.getData("text/plain"));
});
input.addEventListener("beforeinput", event => {
if (event.inputType.startsWith("format")) event.preventDefault();
if (event.inputType === "insertLineBreak") {
event.preventDefault();
document.execCommand("insertParagraph");
}
});
input.addEventListener("blur", (event) => {
if (!this.options.fontControls?.includes(event.relatedTarget)) this.commitText();
@@ -831,7 +872,7 @@ export class CanvasEditor {
input.addEventListener("keydown", (event) => {
if ((event.ctrlKey || event.metaKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "s") return;
event.stopPropagation();
if (event.isComposing || input.readOnly) {
if (event.isComposing || this.input?.saving) {
return;
}
if (event.key === "Escape") {
@@ -843,15 +884,8 @@ export class CanvasEditor {
this.commitText();
}
});
this.resizeText();
input.focus({ preventScroll: true });
if (!item.draft) input.select();
}
resizeText() {
const { input } = this.input;
input.style.height = "0px";
input.style.height = `${input.scrollHeight + 2}px`;
if (!item.draft) document.getSelection().selectAllChildren(input);
}
setTextFont(face, data) {
@@ -861,7 +895,6 @@ export class CanvasEditor {
editing.face = face;
editing.fontData = data;
editing.input.style.fontFamily = `"${face.family}"`;
this.resizeText();
this.options.onTextChange();
editing.input.focus({ preventScroll: true });
}
@@ -869,20 +902,23 @@ export class CanvasEditor {
async commitText() {
const editing = this.input;
if (!editing) return true;
if (editing.input.readOnly) return false;
if (editing.input.value === editing.item.text && !editing.fontData || editing.item.draft && !editing.input.value.trim()) {
if (editing.saving) return false;
const value = editTextValue(editing.input);
if (value === editing.item.text && !editing.fontData || editing.item.draft && !value.trim()) {
this.closeText();
return true;
}
editing.input.readOnly = true;
const saved = await this.options.onCommitText(editing.item, editing.input.value, editing.fontData);
editing.saving = true;
editing.input.contentEditable = "false";
const saved = await this.options.onCommitText(editing.item, value, editing.fontData);
if (this.input !== editing) {
return Boolean(saved);
}
if (saved) {
this.closeText();
} else {
editing.input.readOnly = false;
editing.saving = false;
editing.input.contentEditable = "true";
editing.input.focus({ preventScroll: true });
}
return Boolean(saved);
+1
View File
@@ -44,6 +44,7 @@ export class FontPicker {
restore() {
const font = this.fonts[Number(this.value)];
this.input.value = font?.fullName || font?.name || "";
this.input.scrollLeft = 0;
this.input.title = font ? [this.input.value, this.sourceLabel(font)].filter(Boolean).join(" · ") : "";
this.input.setAttribute("aria-expanded", String(this.open));
this.input.removeAttribute("aria-activedescendant");