feat(编辑能力): 完善画布保留排版与字体替换
build.yaml / build (push) Successful in 45s

This commit is contained in:
xiaoqidun committed 2026-10-16 20:08:45 +08:00
1 parent 8a1caf7952
commit 0aed2a5609
8 files changed
+246 -156

No files matched your search

+1 -8
View File
@@ -132,6 +132,7 @@
</select>
<label class="editor-field"><span>行距</span><span class="editor-number"><input id="textLineHeight" type="number" min="0" step="any" placeholder="自动" aria-label="行距" title="行距,0为自动" disabled><span>pt</span></span></label>
<button id="textWrap" class="button" type="button" aria-label="自动折行" aria-pressed="false" disabled>折行</button>
<button id="textReflow" class="button" type="button" title="重新排版" hidden>重排</button>
<label class="editor-field"><span>字距</span><span class="editor-number"><input id="textSpacing" type="number" step="any" placeholder="0" aria-label="字距" title="字距" disabled><span>pt</span></span></label>
<button id="paragraphButton" class="editor-group-action" type="button" title="段落缩进" aria-label="段落缩进" aria-haspopup="dialog" aria-controls="paragraphPanel" disabled>段落</button>
</div>
@@ -482,14 +483,6 @@
<dialog id="objectPicker" class="form-dialog" aria-label="选取对象">
<form method="dialog"><div id="objectPickerList" class="object-picker-list"></div><div class="form-actions"><button class="button" type="submit">关闭</button></div></form>
</dialog>
<dialog id="sourceTextPanel" class="form-dialog" aria-label="保留排版">
<form id="sourceTextForm">
<h2 class="form-title">保留排版</h2>
<div class="form-row"><label for="sourceTextValue">内容</label><textarea id="sourceTextValue" rows="3" dir="auto" aria-label="文字内容" required></textarea></div>
<div id="sourceTextPreview" aria-label="文字预览"></div>
<div class="form-actions"><span id="sourceTextStatus" class="form-status" role="status"></span><button id="sourceTextCancel" class="button" type="button">取消</button><button class="button primary" type="submit">确定</button></div>
</form>
</dialog>
<dialog id="annotationNote" class="form-dialog" aria-label="注解">
<form id="annotationForm" method="dialog">
<div id="annotationDetail" class="annotation-detail"></div>
+15 -20
View File
@@ -1092,26 +1092,6 @@ button:not(:disabled):hover {
}
}
#sourceTextPanel {
width: 440px;
}
#sourceTextValue {
min-height: 80px;
}
#sourceTextPreview {
height: 160px;
margin-top: 12px;
background: #fff;
overflow: hidden;
}
#sourceTextPreview svg {
display: block;
width: 100%;
height: 100%;
}
.seal-position-options,
.seal-position-options label {
@@ -2871,6 +2851,21 @@ button.outline-link:hover {
text-indent: inherit;
}
.edit-text span[data-glyph] {
position: relative;
-webkit-text-fill-color: transparent;
}
.edit-text span[data-glyph]::after {
content: attr(data-glyph);
position: absolute;
left: 0;
top: 0;
-webkit-text-fill-color: currentColor;
pointer-events: none;
user-select: none;
}
.edit-text:focus-visible {
outline: none;
}
+27 -62
View File
@@ -356,12 +356,6 @@ const el = {
objectHeight: document.querySelector("#objectHeight"),
objectAspect: document.querySelector("#objectAspect"),
objectStylePanel: document.querySelector("#objectStylePanel"),
sourceTextPanel: document.querySelector("#sourceTextPanel"),
sourceTextForm: document.querySelector("#sourceTextForm"),
sourceTextValue: document.querySelector("#sourceTextValue"),
sourceTextPreview: document.querySelector("#sourceTextPreview"),
sourceTextStatus: document.querySelector("#sourceTextStatus"),
sourceTextCancel: document.querySelector("#sourceTextCancel"),
objectStyleForm: document.querySelector("#objectStyleForm"),
groupObjectsButton: document.querySelector("#groupObjectsButton"),
ungroupObjectButton: document.querySelector("#ungroupObjectButton"),
@@ -603,7 +597,7 @@ const annotationFields = Object.fromEntries(["CreateForm", "CreateCancel", "Cont
el.editorTools.addEventListener("scroll", () => fontPicker.position());
const canvasEditor = new CanvasEditor(el.viewerPanel, {
textControls: [el.textFont, el.textFontToggle, el.textFontAdd, el.textFontList, el.textSize, el.textColor, textBackground, ...textFormatButtons, el.undoButton, el.redoButton, el.saveButton, el.fontInput],
textControls: [el.textFont, el.textFontToggle, el.textFontAdd, el.textFontList, el.textSize, el.textColor, textBackground, ...textFormatButtons, el.undoButton, el.redoButton, el.saveButton, el.fontInput, document.querySelector("#textReflow")],
keepTextFocus: () => fontPicker.open || canvasEditor.input?.fontDialog,
textClipboardScope: () => `${state.wasmSeq}:${state.fileName}:${state.doc?.documents?.[state.doc.documentIndex || 0]?.root}:${state.doc?.documentIndex || 0}:${state.doc?.documentVersion || ""}`,
busy: () => document.body.hasAttribute("aria-busy"),
@@ -673,20 +667,21 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, {
onDrawText: beginCanvasText,
onDrawAnnotation: placeAnnotation,
onDrawInk: (index, points, style, pressure) => changeDocument("ofdgoInsertInk", null, index, JSON.stringify(points), style.lineWidth, pressure, style.strokeColor),
onPreviewText: (item, value, font, size) => callWASM("ofdgoPreviewText", item.index, item.id, value, font || null, size),
onPreviewText: (item, value, font, size, reflow) => callWASM("ofdgoPreviewText", item.index, item.id, value, font || null, size, !!reflow),
onCommitRichText: saveRichText,
onPreviewRichText: validateRichText,
onTextError: (message, previous) => {
if (message) setStatus(message);
else if (el.statusText.textContent === previous) setStatus(state.doc ? pageStatus(state.pageIndex, state.doc.pageCount) : STATUS.ready);
},
onCommitText: (item, value, fontData, color = null) => item.draft
onCommitText: (item, value, fontData, color = null, reflow = false) => item.draft
? changeDocument("ofdgoInsertText", null, item.index, value, fontData || item.fontData, item.x, item.y, item.size, item.color, item.width, item.wrap, item.align, item.paragraphHeight, item.letterSpacing, ...textIndents(item))
: changeDocument("ofdgoUpdateText", item, value, fontData || null, item.size, color),
: changeDocument("ofdgoUpdateText", item, value, fontData || null, item.size, color, reflow),
onTextChange: () => {
const editing = canvasEditor.input;
updatePendingChanges();
if (editing?.rich) updateObjectControls(canvasEditor.selected);
if (editing?.rich || editing?.source) updateObjectControls(canvasEditor.selected);
if (editing?.source) updateTextFonts(canvasEditor.selected, true);
if (!editing) {
updateObjectControls(canvasEditor.selected);
updateTextFonts(canvasEditor.selected, true);
@@ -1120,7 +1115,7 @@ el.resetCropButton.addEventListener("click", () => {
});
for (const [button, direction, command] of [[el.undoButton, -1, "ofdgoUndo"], [el.redoButton, 1, "ofdgoRedo"]]) {
button.addEventListener("click", async () => {
if (canvasEditor.input?.rich) { canvasEditor.textUndo(direction); return; }
if (canvasEditor.input?.rich || canvasEditor.input?.source) { canvasEditor.textUndo(direction); return; }
if (!await canvasEditor.commitNudge() || !await canvasEditor.commitText() || !await canvasEditor.commitCrop()) return;
await changeDocument(command);
});
@@ -1279,22 +1274,7 @@ el.objectBoundsForm.addEventListener("submit", async event => {
if (saved) el.objectBoundsPanel.close();
});
el.objectStyleCancel.addEventListener("click", () => el.objectStylePanel.close());
el.sourceTextCancel.addEventListener("click", () => el.sourceTextPanel.close());
el.sourceTextValue.addEventListener("input", () => {
clearTimeout(state.sourceTextTimer);
state.sourceTextTimer = setTimeout(previewPositionedText, 150);
});
el.sourceTextPanel.addEventListener("close", () => {
clearTimeout(state.sourceTextTimer);
state.sourceText = null;
el.sourceTextPreview.replaceChildren();
});
el.sourceTextForm.addEventListener("submit", async event => {
event.preventDefault();
const item = state.sourceText?.item;
if (!item) return;
if (await changeDocument("ofdgoUpdateText", item, el.sourceTextValue.value, null, item.size, null)) el.sourceTextPanel.close();
});
document.querySelector("#textReflow").addEventListener("click", reflowCanvasText);
el.objectLinkKind.addEventListener("change", updateObjectLinkFields);
el.gradientKind.addEventListener("change", () => {
el.gradientControls.hidden = el.gradientKind.value === "keep";
@@ -1831,7 +1811,7 @@ function handleKeyDown(event) {
function formDialogOpen() {
return Boolean(sealPreview || resourceManager.pick) || settingsElements.Panel.open || fontBindingElements.Panel.open || el.signPanel.open || el.verifyPanel.open || el.credentialsPanel.open || el.encryptionPanel.open || batchElements.Panel.open || el.exportPanel.open || el.createPanel.open || el.insertPanel.open || el.pagePanel.open || el.paragraphPanel.open || el.importPanel.open || el.infoPanel.open || el.documentPanel.open
|| resourceElements.Panel.open || el.structureDialog.open || el.versionPanel.open || el.batchPagesPanel.open || el.objectStylePanel.open || el.sourceTextPanel.open || el.outlinePanel.open || el.objectBoundsPanel.open || el.annotationNote.open || el.annotationCreate.open || el.objectPicker.open;
|| resourceElements.Panel.open || el.structureDialog.open || el.versionPanel.open || el.batchPagesPanel.open || el.objectStylePanel.open || el.outlinePanel.open || el.objectBoundsPanel.open || el.annotationNote.open || el.annotationCreate.open || el.objectPicker.open;
}
function setObjectDimension(input, value) {
@@ -2908,18 +2888,7 @@ async function editCanvasObject(item) {
return;
}
if (item.type === "TextObject") {
if (!canEditObject(item, "textContent")) {
if (canEditObject(item, "rewriteText")) {
state.sourceText = { item, openSeq: state.openSeq };
el.sourceTextValue.value = item.text;
el.sourceTextStatus.textContent = "";
el.sourceTextPreview.replaceChildren();
el.sourceTextPanel.showModal();
el.sourceTextValue.focus();
await previewPositionedText();
}
return;
}
if (!canEditObject(item, "textContent") && !canEditObject(item, "rewriteText")) return;
} else if (!canEditObject(item, item.type === "ImageObject" ? "replaceImage" : "update")) return;
if (item.type === "PathObject") {
el.shapeWidth.focus();
@@ -2931,7 +2900,7 @@ async function editCanvasObject(item) {
const openSeq = state.openSeq;
setBusy(true);
try {
const data = await callWASM("ofdgoEditorFont", item.font);
const data = await callWASM("ofdgoEditorFont", item.font, !canEditObject(item, "layoutKnown"));
const face = new FontFace(`ofdgo-edit-${item.font}`, data.bytes, fontFaceStyle(data.bytes));
await face.load();
let rich = null;
@@ -2954,22 +2923,6 @@ async function editCanvasObject(item) {
}
}
async function previewPositionedText() {
const request = state.sourceText, value = el.sourceTextValue.value;
if (!request || !el.sourceTextPanel.open) return;
try {
const svg = await callWASM("ofdgoPreviewPositionedText", request.item.index, request.item.id, value);
if (state.sourceText !== request || request.openSeq !== state.openSeq || el.sourceTextValue.value !== value) return;
el.sourceTextPreview.replaceChildren(parseSVG(svg));
el.sourceTextStatus.textContent = "";
} catch (error) {
if (state.sourceText === request && request.openSeq === state.openSeq && el.sourceTextValue.value === value) {
el.sourceTextPreview.replaceChildren();
el.sourceTextStatus.textContent = error.message;
}
}
}
function mountEditorObjects(index, page, surface) {
const scope = state.composite?.index === index ? state.composite : null;
canvasEditor.mount(index, { ...page, objects: scope ? scope.objects : page.objects.filter(item => state.renderAnnotations || item.type !== "Annotation") }, surface);
@@ -3108,6 +3061,16 @@ async function ensureRichInput() {
return canvasEditor.promoteText(rich);
}
async function reflowCanvasText() {
const editing = canvasEditor.input;
if (document.body.hasAttribute("aria-busy") || !editing?.source || editing.composing || editing.saving || !canEditObject(editing.item, "reflow")) return;
if (!window.confirm("将重新排版原文,是否继续?")) return;
editing.reflow = true;
canvasEditor.recordSourceText();
await canvasEditor.previewSourceText();
updateObjectControls(canvasEditor.selected, true);
}
async function changeRangeFormat(key, value) {
if (!state.ready || document.body.hasAttribute("aria-busy") || canvasEditor.input?.composing) return;
try {
@@ -3480,6 +3443,7 @@ async function changeTextStyle(color) {
try { if (!await ensureRichInput()) return; } catch (error) { showError(error, false); return; }
}
if (canvasEditor.input) {
if (!color && canvasEditor.input.source && !canvasEditor.input.reflow && size !== item.size && !confirmTextReflow(item)) return;
canvasEditor.setTextStyle(color ? { color: selectedColor } : { size });
updateObjectControls(canvasEditor.selected, true);
return;
@@ -3782,8 +3746,6 @@ async function changeDocument(name, item, ...args) {
el.batchPagesStatus.textContent = err.message;
} else if (el.objectStylePanel.open) {
el.objectStyleStatus.textContent = err.message;
} else if (el.sourceTextPanel.open) {
el.sourceTextStatus.textContent = err.message;
} else if (el.objectBoundsPanel.open) {
el.objectBoundsStatus.textContent = err.message;
} else if (el.outlinePanel.open) {
@@ -4583,7 +4545,6 @@ async function openOFD(file, remote = null) {
el.importPanel.close();
el.batchPagesPanel.close();
el.objectStylePanel.close();
el.sourceTextPanel.close();
el.objectBoundsPanel.close();
el.outlinePanel.close();
el.structureDialog.close();
@@ -11799,12 +11760,16 @@ function updateObjectControls(item, reset = false) {
textBackground.classList.toggle("mixed-color", Boolean((canvasEditor.input?.rich || item?.richText) && text.background === undefined));
if (text.background) textBackground.value = text.background;
document.querySelector("#textHighlight").style.setProperty("--highlight-color", textBackground.value);
const richHistory = canvasEditor.input?.rich;
const richHistory = canvasEditor.input?.rich || canvasEditor.input?.history;
if (richHistory) {
el.undoButton.disabled = textDisabled || richHistory.index === 0;
el.redoButton.disabled = textDisabled || richHistory.index === richHistory.entries.length - 1;
}
el.textAlign.disabled = el.textWrap.disabled = el.textLineHeight.disabled = el.textSpacing.disabled = el.textSize.disabled || Boolean(item?.items || item?.draft) || Boolean(canvasEditor.input);
const reflow = document.querySelector("#textReflow"), positioned = canvasEditor.input?.source && !canEditObject(item, "layoutKnown");
reflow.hidden = !positioned;
el.textWrap.hidden = !!positioned;
reflow.disabled = textDisabled || !canEditObject(item, "reflow") || !!canvasEditor.input?.reflow;
el.paragraphButton.disabled = el.textAlign.disabled;
const layoutKnown = text === state.textDefaults || Boolean(item?.draft) || canEditObject(item, "layoutKnown");
el.textAlign.value = layoutKnown ? text.align || "left" : "";
+38 -16
View File
@@ -1536,7 +1536,7 @@ export class CanvasEditor {
const editing = this.input;
if (rich) editing.rich = new RichTextSession(this, rich.model, rich.faces, rich.fonts);
if (rich && !rich.model.layout.lineHeight) input.style.lineHeight = "normal";
if (source) editing.history = { entries: [{ value: item.text }], index: 0 };
if (source) editing.history = { entries: [{ value: item.text, face, fontChoice: item.fontChoice }], index: 0 };
item.artwork?.classList.add("edit-text-source");
item.node.classList.add("edit-text-source");
item.surface.append(input);
@@ -1631,7 +1631,8 @@ export class CanvasEditor {
if (editing.rich) editing.rich.render();
input.focus({ preventScroll: true });
if (!item.draft) document.getSelection().selectAllChildren(input);
if (editing.rich) { editing.rich.selection(); this.options.onTextChange(); }
if (editing.rich) editing.rich.selection();
this.options.onTextChange();
}
textUndo(direction) {
@@ -1715,8 +1716,10 @@ export class CanvasEditor {
const editing = this.input;
if (!editing?.history || editing.composing) return;
const { history, input } = editing, value = editTextValue(input);
if (history.entries[history.index].value === value) return;
history.entries.splice(++history.index, Infinity, { value, caret: editTextSelection(input) });
const previous = history.entries[history.index];
if (previous.value === value && previous.fontData === editing.fontData && previous.style === editing.style && !!previous.reflow === !!editing.reflow) return;
history.entries.splice(++history.index, Infinity, { value, caret: editTextSelection(input), face: editing.face,
fontData: editing.fontData, fontChoice: editing.fontChoice, style: editing.style, reflow: editing.reflow });
}
restoreSourceText(direction) {
@@ -1726,6 +1729,14 @@ export class CanvasEditor {
if (index < 0 || index >= history.entries.length) return;
history.index = index;
const entry = history.entries[index], fragment = document.createDocumentFragment(), positions = [];
if (entry.face) {
document.fonts.delete(editing.face);
document.fonts.add(entry.face);
Object.assign(editing, { face: entry.face, fontData: entry.fontData, fontChoice: entry.fontChoice, style: entry.style, reflow: entry.reflow });
editing.input.style.fontFamily = `"${entry.face.family}"`;
editing.input.style.fontSize = `${(entry.style?.size || editing.item.size) * PX_PER_MM}px`;
editing.input.style.color = entry.style?.color || editing.item.color;
}
let position = 0;
for (const text of entry.value.split("\n")) {
const paragraph = document.createElement("div");
@@ -1745,11 +1756,11 @@ export class CanvasEditor {
async previewSourceText() {
const editing = this.input;
if (!editing?.source || editing.composing || editing.saving) return;
const value = editTextValue(editing.input), font = editing.fontData, size = editing.style?.size || editing.item.size;
const value = editTextValue(editing.input), font = editing.fontData, size = editing.style?.size || editing.item.size, reflow = !!editing.reflow;
const current = () => this.input === editing && !editing.rich && !editing.composing && !editing.saving && editing.fontData === font
&& (editing.style?.size || editing.item.size) === size && editTextValue(editing.input) === value;
&& (editing.style?.size || editing.item.size) === size && !!editing.reflow === reflow && editTextValue(editing.input) === value;
try {
const source = await this.options.onPreviewText(editing.item, value, font, size);
const source = await this.options.onPreviewText(editing.item, value, font, size, reflow);
if (!current()) return;
this.setTextError(null);
this.paintSourceText(source);
@@ -1768,7 +1779,8 @@ export class CanvasEditor {
const metrics = context.measureText("Mg"), baseline = (size + metrics.fontBoundingBoxAscent - metrics.fontBoundingBoxDescent) / 2;
Object.assign(input.style, { lineHeight: `${size}px`, fontWeight: String(source.weight || 400), fontStyle: source.italic ? "italic" : "normal" });
const fragment = document.createDocumentFragment(), positions = [];
let line, lineIndex = -1, naturalX = 0, position = 0;
let line, lineIndex = -1, naturalX = 0, position = 0, glyphIndex = 0;
const angle = (source.rotation || 0) * Math.PI / 180, cos = Math.cos(angle), sin = Math.sin(angle);
let left = Infinity, top = Infinity, right = -Infinity, bottom = -Infinity;
for (const run of source.runs) {
if (!line || run.line) {
@@ -1790,15 +1802,23 @@ export class CanvasEditor {
y = run.positions[index][1] * PX_PER_MM;
const span = document.createElement("span"), node = document.createTextNode(char);
span.append(node);
Object.assign(span.style, { display: "inline-block", opacity: String((item.alpha ?? 255) / 255), transformOrigin: "left top", transform: `translate(${x - naturalX}px,${y - baseline - lineIndex * size}px) scaleX(${source.scale})` });
const glyph = source.glyphs?.[glyphIndex++];
if (glyph) span.dataset.glyph = glyph;
Object.assign(span.style, { display: "inline-block", opacity: String((item.alpha ?? 255) / 255), transformOrigin: `left ${baseline}px`, transform: `translate(${x - naturalX}px,${y - baseline - lineIndex * size}px) rotate(${source.rotation || 0}deg) scale(${source.scale},${source.vscale || 1})` });
line.append(span);
positions.push({ node, start: position, end: position + char.length });
position += char.length;
const advance = context.measureText(char).width;
left = Math.min(left, x);
top = Math.min(top, y - baseline);
right = Math.max(right, x + advance * source.scale);
bottom = Math.max(bottom, y + size - baseline);
const advance = context.measureText(glyph || char).width;
if (glyph) span.style.width = `${advance}px`;
for (const dx of [0, advance * source.scale]) {
for (const dy of [-baseline * (source.vscale || 1), (size - baseline) * (source.vscale || 1)]) {
const px = x + dx * cos - dy * sin, py = y + dx * sin + dy * cos;
left = Math.min(left, px);
top = Math.min(top, py);
right = Math.max(right, px);
bottom = Math.max(bottom, py);
}
}
naturalX += advance;
index++;
}
@@ -1827,6 +1847,7 @@ export class CanvasEditor {
editing.face = face;
editing.fontData = data;
editing.input.style.fontFamily = `"${face.family}"`;
this.recordSourceText();
this.options.onTextChange();
editing.input.focus({ preventScroll: true });
this.previewSourceText();
@@ -1841,6 +1862,7 @@ export class CanvasEditor {
const item = { ...editing.item, ...editing.style };
editing.input.style.fontSize = `${item.size * PX_PER_MM}px`;
editing.input.style.color = item.color;
this.recordSourceText();
this.fitTextInput();
this.options.onTextChange();
if ("size" in values) this.previewSourceText();
@@ -1852,7 +1874,7 @@ export class CanvasEditor {
if (editing.rich) return Boolean(editing.refreshPending) || (!editing.item.draft || Boolean(editing.rich.text.trim())) && editing.rich.changed;
const value = editTextValue(editing.input);
return (!editing.item.draft || Boolean(value.trim()))
&& (value !== editing.item.text || Boolean(editing.fontData || editing.style));
&& (value !== editing.item.text || Boolean(editing.fontData || editing.style || editing.reflow));
}
async commitText() {
@@ -1874,7 +1896,7 @@ export class CanvasEditor {
let saved = false;
try {
saved = editing.rich ? await this.options.onCommitRichText(editing.item, editing.rich.model, editing.rich.fonts)
: await this.options.onCommitText(editing.style ? { ...editing.item, ...editing.style } : editing.item, value, editing.fontData, editing.style?.color ?? null);
: await this.options.onCommitText(editing.style ? { ...editing.item, ...editing.style } : editing.item, value, editing.fontData, editing.style?.color ?? null, !!editing.reflow);
} catch (error) {
if (this.input === editing) this.setTextError(error);
}