feat(编辑能力): 支持复合对象内部编辑,优化提示文案与操作稳定性
build.yaml / build (push) Successful in 36s

This commit is contained in:
xiaoqidun committed 2026-09-17 15:13:30 +08:00
1 parent 094f67e333
commit 896b00148a
14 files changed
+963 -60

No files matched your search

+1
View File
@@ -67,6 +67,7 @@
<button id="multiSelectButton" class="button" type="button" aria-pressed="false" title="多选对象">多选</button>
<button id="editObjectButton" class="button" type="button" aria-label="修改对象" disabled>修改</button>
<button id="copyObjectButton" class="button" type="button" aria-label="复制对象" disabled>复制</button>
<button id="compositeBackButton" class="button" type="button" title="返回上层" hidden>返回</button>
<button id="deleteObjectButton" class="button" type="button" disabled>删除</button>
<select id="objectOrder" class="order-select" aria-label="对象层级" disabled>
<option value="" hidden>层级</option>
+1
View File
@@ -1287,6 +1287,7 @@ button.outline-link:hover {
}
.empty-state[hidden],
.editor-group > [hidden],
.panel-row > [hidden],
.meta-list > [hidden],
.navigation-tabs[hidden],
+98 -22
View File
@@ -25,6 +25,7 @@ let wasmRecoveryTimer = 0;
let textMeasure = null;
const state = {
composite: null,
ready: false,
wasmExited: false,
wasmSeq: 0,
@@ -107,6 +108,7 @@ const el = {
selectObjectButton: document.querySelector("#selectObjectButton"),
deleteObjectButton: document.querySelector("#deleteObjectButton"),
editObjectButton: document.querySelector("#editObjectButton"),
compositeBackButton: document.querySelector("#compositeBackButton"),
copyObjectButton: document.querySelector("#copyObjectButton"),
objectOrder: document.querySelector("#objectOrder"),
objectAlign: document.querySelector("#objectAlign"),
@@ -382,6 +384,11 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, {
: changeDocument("ofdgoDeleteObjects", item);
},
onEdit: editCanvasObject,
onExitScope: () => {
if (!state.composite) return false;
exitCompositeScope();
return true;
},
drawStyle: shapeStyle,
onTool: updateDrawingControls,
onDraw: (index, shape, box, style) => changeDocument("ofdgoInsertShape", null, index, shape,
@@ -439,6 +446,7 @@ editorClick(el.multiSelectButton, () => {
canvasEditor.multiple = !canvasEditor.multiple;
el.multiSelectButton.setAttribute("aria-pressed", String(canvasEditor.multiple));
});
editorClick(el.compositeBackButton, exitCompositeScope);
editorClick(el.editObjectButton, () => {
if (canvasEditor.selected) {
editCanvasObject(canvasEditor.selected);
@@ -981,7 +989,7 @@ function openObjectBounds() {
state.boundsOriginal = { x: box.x, y: box.y, width: Math.abs(box.width), height: Math.abs(box.height) };
for (const [input, key] of [[el.objectX,"x"], [el.objectY,"y"], [el.objectWidth,"width"], [el.objectHeight,"height"]]) setObjectDimension(input, state.boundsOriginal[key]);
el.objectAspect.checked = true;
el.objectAspect.disabled = !item.shape && (item.items || [item]).some(member => member.type !== "ImageObject" || member.imageBorder) || lineShape(item.shape) && (!box.width || !box.height);
el.objectAspect.disabled = Boolean(state.composite) || !item.shape && (item.items || [item]).some(member => member.type !== "ImageObject" || member.imageBorder) || lineShape(item.shape) && (!box.width || !box.height);
el.objectWidth.disabled = lineShape(item.shape) && !box.width;
el.objectHeight.disabled = lineShape(item.shape) && !box.height;
el.objectBoundsStatus.textContent = "";
@@ -1189,6 +1197,7 @@ function setEditorInfo(doc) {
}
function pageCan(capability, index = state.pageIndex) {
if (state.composite && capability === "insert") return false;
const bit = { insert: 1, copy: 2, delete: 4, move: 8, resize: 16 }[capability];
return Boolean(state.editorInfo?.pageCapabilities?.[index] & bit);
}
@@ -1198,6 +1207,7 @@ async function toggleEditor() {
if (state.editorInfo) {
if (!await canvasEditor.commitText() || !await canvasEditor.commitCrop()) return;
state.editing = !state.editing;
resetCompositeScope();
canvasEditor.clear();
canvasEditor.setTool("");
if (state.editing) {
@@ -1220,6 +1230,7 @@ async function toggleEditor() {
state.savedRevision = doc.revision;
setEditorInfo(doc);
state.editing = true;
state.composite = null;
state.selectObjects = true;
state.ofdBytes = null;
state.objectClipboard = null;
@@ -1270,6 +1281,7 @@ async function createDocument(event) {
return;
}
state.editing = true;
state.composite = null;
state.objectClipboard = null;
state.styleClipboard = null;
state.fontRenderPending = false;
@@ -1298,6 +1310,7 @@ async function createDocument(event) {
async function editCanvasObject(item) {
if (!state.editing || document.body.hasAttribute("aria-busy") || canvasEditor.input || item.items) return;
if (canEditObject(item, "enter")) return enterCompositeScope(item.index, item.id);
if (item.type === "TextObject") {
if (!confirmTextReflow(item)) return;
} else if (!canEditObject(item, "update")) return;
@@ -1328,6 +1341,51 @@ async function editCanvasObject(item) {
}
}
function mountEditorObjects(index, page, surface) {
const scope = state.composite?.index === index ? state.composite : null;
canvasEditor.mount(index, scope ? { ...page, objects: scope.objects } : page, surface);
}
function resetCompositeScope() {
const scope = state.composite;
if (!scope) return;
state.composite = null;
canvasEditor.clear();
const page = state.pageCache.get(scope.index), surface = pageShell(scope.index)?.querySelector(".page-surface");
if (page && surface) mountEditorObjects(scope.index, page, surface);
}
async function enterCompositeScope(index, key) {
const openSeq = state.openSeq;
setBusy(true);
try {
const objects = await callWASM("ofdgoCompositeObjects", index, key);
if (openSeq !== state.openSeq) return;
canvasEditor.clear();
canvasEditor.setTool("");
state.composite = { index, key, objects };
const page = state.pageCache.get(index), surface = pageShell(index)?.querySelector(".page-surface");
if (page && surface) mountEditorObjects(index, page, surface);
updateControls();
setStatus(pageStatus(index, state.doc.pageCount));
el.viewerPanel.focus({ preventScroll: true });
} catch (err) {
if (openSeq === state.openSeq) showError(err, false);
} finally {
if (openSeq === state.openSeq) setBusy(false);
}
}
async function exitCompositeScope() {
const scope = state.composite;
if (!scope) return;
const split = scope.key.lastIndexOf("/");
if (split >= 0) return enterCompositeScope(scope.index, scope.key.slice(0, split));
resetCompositeScope();
updateControls();
el.viewerPanel.focus({ preventScroll: true });
}
async function startImageCrop(item) {
const openSeq = state.openSeq;
const urls = [];
@@ -1738,7 +1796,7 @@ function updateDrawingControls() {
button.disabled = disabled || !pageCan("insert");
button.setAttribute("aria-pressed", String(tool === name));
}
el.eraseButton.disabled = el.eraseMode.disabled = disabled;
el.eraseButton.disabled = el.eraseMode.disabled = disabled || Boolean(state.composite);
el.eraseButton.setAttribute("aria-pressed", String(tool.startsWith("erase-")));
el.selectObjectButton.setAttribute("aria-pressed", String(canvasEditor.enabled && !tool));
const styleDisabled = disabled || (canvasEditor.selected ? !path || !canEditObject(path, "paint") : !pageCan("insert"));
@@ -1764,7 +1822,15 @@ async function changeDocument(name, item, ...args) {
if (openSeq !== state.openSeq) {
return;
}
const doc = await callWASM(name, ...(item ? [item.index, item.id] : []), ...args);
const scope = state.composite;
const operation = { ofdgoTransformObject: "transform", ofdgoTransformObjects: "transform", ofdgoRotateObjects: "rotate", ofdgoFlipObjects: "flip", ofdgoResizeObjects: "resize" }[name];
const members = item?.items || (item ? [item] : []);
const scoped = scope && members.length && members.every(member => member.scoped);
if (!item) resetCompositeScope();
if (scoped && !operation) throw new Error("内部对象暂不支持此操作");
const doc = scoped
? await callWASM("ofdgoChangeCompositeObjects", item.index, scope.key, Array.isArray(item.id) ? item.id : members.map(member => member.id), operation, ...args)
: await callWASM(name, ...(item ? [item.index, item.id] : []), ...args);
if (openSeq !== state.openSeq) {
return;
}
@@ -2355,6 +2421,7 @@ async function openOFD(file) {
state.ofdBytes = bytes;
state.fileName = file.name || "ofdgo.ofd";
state.editing = false;
state.composite = null;
state.pageSelection.clear();
state.pageSelectionAnchor = null;
state.pageMultiSelect = false;
@@ -2414,7 +2481,7 @@ async function openSelectedFonts(event) {
if (changed) {
await applyFontChange();
}
setStatus(saved ? "字体保存完成" : "字体仅限本次");
setStatus(saved ? "字体已保存" : "字体未保存,仅本次可用");
} catch (err) {
showError(err, false);
} finally {
@@ -2435,7 +2502,7 @@ async function loadLocalFonts() {
await nextFrame();
const available = await fontManager.queryLocal();
if (!state.doc) {
setStatus(available.length ? `字体授权完成 ${available.length} 个` : "暂无系统字体");
setStatus(available.length ? `已授权${available.length}种字体` : "暂无系统字体");
return;
}
if (await loadDocumentLocalFonts(available)) {
@@ -2459,7 +2526,7 @@ async function requestLocalFontsBeforeOpen() {
setBusy(true, "正在请求授权", 12, "正在请求授权");
try {
const available = await fontManager.queryLocal();
setStatus(available.length ? `字体授权完成 ${available.length} 个` : "暂无系统字体");
setStatus(available.length ? `已授权${available.length}种字体` : "暂无系统字体");
} catch (err) {
if (err && err.name === "NotAllowedError") {
setStatus("字体尚未授权");
@@ -2503,7 +2570,7 @@ async function loadDocumentLocalFonts(available, openSeq = state.openSeq) {
}
if (docFonts.every((font) => font.embedded)) {
fontManager.localFonts = [];
setStatus("字体均为内嵌");
setStatus("字体均已内嵌");
updateFontSummary();
renderFontList();
return false;
@@ -2524,7 +2591,7 @@ async function loadDocumentLocalFonts(available, openSeq = state.openSeq) {
fonts.push(fontManager.record(fontManager.localName(item), data, "browser"));
}
fontManager.localFonts = fonts;
setStatus(fonts.length ? `字体加载完成 ${fonts.length} 个` : emptyStatus);
setStatus(fonts.length ? `已加载${fonts.length}种字体` : emptyStatus);
updateFontSummary();
renderFontList();
return fonts.length > 0;
@@ -2972,7 +3039,7 @@ async function downloadAttachment(attachment) {
if (result.blob) {
downloadBytes(result.blob, result.mime, attachment.fileName);
}
setStatus(`附件下载完成 ${formatBytes(result.size)}`);
setStatus(`附件已下载(${formatBytes(result.size)})`);
} catch (err) {
if (openSeq === state.openSeq) {
if (err.name === "AbortError") {
@@ -3086,7 +3153,7 @@ async function exportFile(whole, indices = null, value = el.exportFormat.value)
state.savedRevision = state.editorInfo.revision;
setDirty(false);
}
setStatus(`${result.label} ${saving ? "保存" : "导出"}完成 ${formatBytes(result.size)}`);
setStatus(`${result.label}已${saving ? "保存" : "导出"}(${formatBytes(result.size)})`);
return true;
} catch (err) {
if (openSeq === state.openSeq) {
@@ -3321,6 +3388,11 @@ async function processPageRenderQueue() {
continue;
}
const page = await callWASM("ofdgoRenderPage", task.index);
const scope = state.composite;
if (scope?.index === task.index) {
const objects = await callWASM("ofdgoCompositeObjects", task.index, scope.key);
if (scope === state.composite && task.openSeq === state.openSeq) scope.objects = objects;
}
await loadSVGFonts(page.fonts, task.openSeq);
if (task.openSeq === state.openSeq) {
for (const image of page.images) {
@@ -3436,7 +3508,7 @@ function mountPageSVG(index, page, openSeq = state.openSeq) {
surface.replaceChildren(svg);
surface.append(createTextLayer(page.text));
if (state.editorInfo) {
canvasEditor.mount(index, page, surface);
mountEditorObjects(index, page, surface);
}
for (const link of page.links) {
const anchor = document.createElement("a");
@@ -3670,7 +3742,7 @@ async function pasteEditorContent(event) {
if (token) {
const clipboard = state.objectClipboard;
if (!clipboard || token !== clipboard.token) {
setStatus("对象剪贴板失效");
setStatus("复制内容已失效,请重新复制");
return;
}
const openSeq = state.openSeq;
@@ -3700,7 +3772,7 @@ async function pasteEditorContent(event) {
return;
}
if (!/^image\/(png|jpeg)$/.test(files[0].type)) {
setStatus("图片仅支持 PNG、JPG");
setStatus("仅支持PNG、JPG图片");
return;
}
await changeDocument("ofdgoInsertImage", null, index, files[0].arrayBuffer().then(bytes => new Uint8Array(bytes)), x, y, width);
@@ -3853,6 +3925,7 @@ function pageShellAtPoint(x, y) {
}
function setCurrentPage(index) {
if (state.composite && state.composite.index !== index) resetCompositeScope();
state.pageIndex = index;
if (state.fitMode === "width") {
state.scale = fitWidthScale(currentPageInfo());
@@ -4958,7 +5031,7 @@ async function focusPageRegion(region, label) {
return;
}
highlightPageRegion(region);
setStatus(`${label}定位完成 第 ${region.page} 页`);
setStatus(`已定位${label}(第${region.page}页)`);
}
function highlightPageRegion(region) {
@@ -5453,14 +5526,17 @@ function updateEditorTools() {
}
function updateObjectControls(item, reset = false) {
el.compositeBackButton.hidden = !state.composite;
el.compositeBackButton.disabled = !state.ready || state.exporting;
el.deleteObjectButton.hidden = Boolean(state.composite);
const disabled = !item || Boolean(item.draft) || !state.ready || state.exporting;
el.deleteObjectButton.disabled = disabled || !canEditObject(item, "delete");
el.copyObjectButton.disabled = disabled || !canEditObject(item, "copy");
el.objectStyleButton.disabled = disabled || !canEditObject(item, "transform");
el.objectStyleButton.disabled = disabled || Boolean(state.composite) || !canEditObject(item, "transform");
const cropping = Boolean(canvasEditor.crop);
el.objectBoundsButton.disabled = disabled || cropping || !canEditObject(item, "transform");
el.objectAlign.disabled = disabled || cropping || !canEditObject(item, "arrange");
el.objectRotate.disabled = el.objectFlip.disabled = el.objectAlign.disabled;
el.objectRotate.disabled = el.objectFlip.disabled = disabled || cropping || !canEditObject(item, state.composite ? "transform" : "arrange");
el.cropImageButton.disabled = disabled || item.type !== "ImageObject" || !canEditObject(item, "update");
el.imageFit.disabled = el.cropImageButton.disabled || cropping;
el.cropImageButton.textContent = cropping ? "完成" : "裁剪";
@@ -5470,12 +5546,12 @@ function updateObjectControls(item, reset = false) {
el.resetCropButton.setAttribute("aria-label", cropping ? "取消裁剪" : "还原图片");
el.resetCropButton.disabled = el.cropImageButton.disabled || !cropping && (!item.imageBounds || ["x", "y", "width", "height"].every(key => Math.abs(item[key] - item.imageBounds[key]) < 1e-9));
el.objectDistribute.disabled = el.objectAlign.disabled || !item.items || item.items.length < 3;
el.editObjectButton.disabled = disabled || Boolean(item.items) || item.type === "PathObject"
|| !canEditObject(item, item.type === "TextObject" ? "reflow" : "update");
el.editObjectButton.disabled = disabled || Boolean(item.items) || !canEditObject(item, "enter") && (item.type === "PathObject"
|| !canEditObject(item, item.type === "TextObject" ? "reflow" : "update"));
el.multiSelectButton.disabled = !state.editing || !canvasEditor.enabled || !state.ready || state.exporting;
const selection = selectedText(item);
const text = selection ? currentTextStyle() : state.textDefaults;
const textDisabled = !state.editing || !state.ready || state.exporting || Boolean(item?.items && !selection);
const textDisabled = !state.editing || !state.ready || state.exporting || Boolean(state.composite) || Boolean(item?.items && !selection);
fontPicker.setDisabled(textDisabled || text !== state.textDefaults && !item.draft && !canEditObject(item, "replaceFont"));
el.textSize.disabled = textDisabled || text !== state.textDefaults && !item.draft && (!canEditObject(item, "reflow") || Boolean(item?.items) && !canEditObject(item, "layoutKnown"));
el.textColor.disabled = textDisabled || text !== state.textDefaults && !item.draft && !canEditObject(item, "paint");
@@ -5498,9 +5574,9 @@ function updateObjectControls(item, reset = false) {
el.textSize.value = text.size === undefined ? "" : displayPoints(text.size);
}
el.textSize.placeholder = text.size === undefined ? "混合" : "";
el.textSize.title = text.size === undefined ? "字号:混合" : `字号 ${displayPoints(text.size)} pt`;
el.textSize.title = text.size === undefined ? "字号不同" : `字号${displayPoints(text.size)}pt`;
el.textColor.classList.toggle("mixed-color", Boolean(selection?.items && !text.color));
el.textColor.title = selection?.items && !text.color ? "文字颜色:混合" : "文字颜色";
el.textColor.title = selection?.items && !text.color ? "文字颜色不同" : "文字颜色";
if (reset || el.textColor.disabled || document.activeElement !== el.textColor) {
el.textColor.value = text.color || "#000000";
}
@@ -5512,7 +5588,7 @@ function updateObjectControls(item, reset = false) {
for (const [input, key, label] of [[el.shapeFillColor, "fillColor", "填充颜色"], [el.shapeStrokeColor, "strokeColor", "描边颜色"]]) {
const mixed = Boolean(path?.items && path[key] === undefined);
input.classList.toggle("mixed-color", mixed);
input.title = mixed ? `${label}:混合` : label;
input.title = mixed ? `${label}不同` : label;
if (path && (reset || document.activeElement !== input)) input.value = path[key] || "#000000";
}
el.shapeWidth.placeholder = path?.items && path.lineWidth === undefined ? "混合" : "";
+18 -13
View File
@@ -174,25 +174,24 @@ export function canEditObject(item, capability) {
return Boolean(item) && (item.items || [item]).every(object => Boolean(object.capabilities?.[capability]));
}
export function missingGlyphMessage(diagnostic) {
export function missingGlyphMessage(diagnostic, action = "请更换字体") {
if (!diagnostic) return "";
const characters = Array.from(diagnostic.characters);
const sample = characters.slice(0, 8).map(char => /[\p{C}\p{Z}\p{M}]/u.test(char)
? `U+${char.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")}` : char).join(" ");
return `字体缺字:${sample}${characters.length > 8 ? ` 等 ${characters.length} 字` : ""},改选字体`;
? `U+${char.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")}` : char).join("、");
return `字体缺字(${sample}${characters.length > 8 ? `等${characters.length}字` : ""}),${action}`;
}
export function objectEditReason(item) {
const labels = { fontUnavailable: "字体不可用", unsupportedColor: "颜色样式暂不支持", unsupportedStyle: "绘制参数暂不支持",
unsupportedContainer: "容器特性暂不支持", invalidObject: "对象数据异常" };
const labels = { fontUnavailable: "字体不可用", unsupportedColor: "暂不支持此颜色", unsupportedStyle: "暂不支持此样式",
unsupportedContainer: "暂不支持此对象结构", invalidObject: "对象数据异常" };
const reasons = (item?.items || (item ? [item] : [])).map(object => {
const reason = object.capabilities?.reason || "";
if (!reason) return "";
let message = labels[object.capabilities.reasonCode] || "对象特性暂不支持";
if (object.capabilities.missingGlyphs) message = missingGlyphMessage(object.capabilities.missingGlyphs);
const available = object.capabilities.transform ? object.capabilities.paint ? "可移动、改色" : "可移动"
: object.capabilities.copy && object.capabilities.delete ? "可复制、删除" : "暂不可编辑";
return `${available}:${message}`;
const message = labels[object.capabilities.reasonCode] || "暂不支持此对象特性";
const available = object.capabilities.transform ? object.capabilities.paint ? "仍可移动、改色" : "仍可移动"
: object.capabilities.copy && object.capabilities.delete ? "仍可复制、删除" : "暂不可编辑";
return object.capabilities.missingGlyphs ? missingGlyphMessage(object.capabilities.missingGlyphs, available) : `${message},${available}`;
});
return [...new Set(reasons.filter(Boolean))].join(";");
}
@@ -257,6 +256,7 @@ export class CanvasEditor {
}
mount(index, page, surface) {
surface.querySelector(".edit-layer")?.remove();
const crop = this.crop?.item.index === index ? this.crop : null;
if (crop) this.closeCrop(true);
const pending = this.pendingSelection?.index === index;
@@ -266,7 +266,7 @@ export class CanvasEditor {
this.pages.set(surface, { index, width: page.width, height: page.height });
const layer = document.createElement("div");
layer.className = "edit-layer";
const artwork = new Map([...surface.querySelectorAll("[data-ofd-object]")].map((node, order) => [node.getAttribute("data-ofd-object"), { node, order }]));
const artwork = new Map([...surface.querySelectorAll("[data-ofd-object], [data-ofd-child]")].map((node, order) => [node.getAttribute("data-ofd-object") || node.getAttribute("data-ofd-child"), { node, order }]));
const objects = [...page.objects].sort((a, b) => (artwork.get(a.id)?.order ?? 0) - (artwork.get(b.id)?.order ?? 0));
for (const object of objects) {
const node = document.createElement("div");
@@ -281,14 +281,14 @@ export class CanvasEditor {
node.setAttribute("aria-label", { ImageObject: "图片对象", TextObject: "文字对象", PathObject: "图形对象", CompositeObject: "复合对象", CompositeGraphicUnit: "复合对象" }[object.type]);
const item = { ...object, index, page: { width: page.width, height: page.height }, node, surface };
item.artwork = artwork.get(object.id)?.node;
if (object.type === "PathObject" && (object.shape || object.outline) || (object.type === "CompositeObject" || object.type === "CompositeGraphicUnit") && object.contours?.length) {
if (object.type === "PathObject" && (object.shape || object.outline) || (object.scoped || object.type === "CompositeObject" || object.type === "CompositeGraphicUnit") && object.contours?.length) {
node.classList.add("edit-contour");
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.classList.add("edit-outline");
svg.setAttribute("aria-hidden", "true");
svg.setAttribute("preserveAspectRatio", "none");
let contour;
if (object.type === "PathObject") {
if (object.type === "PathObject" && !object.scoped) {
contour = document.createElementNS("http://www.w3.org/2000/svg", object.shape === "line" ? "line" : object.shape === "ellipse" ? "ellipse" : object.shape === "rectangle" ? "rect" : "path");
if (!object.shape) contour.setAttribute("d", object.outline);
svg.append(contour);
@@ -952,6 +952,11 @@ export class CanvasEditor {
this.setTool("");
return;
}
if (event.key === "Escape" && !this.input && this.enabled && !this.options.busy() && !this.nudge && this.options.onExitScope?.()) {
event.preventDefault();
event.stopPropagation();
return;
}
if (this.input || !this.enabled || !this.selected || this.options.busy() || this.nudgeCommit || event.ctrlKey || event.metaKey || event.altKey || event.isComposing) {
return;
}