feat(创建写入): 完善画布编辑状态保护,优化离屏页面回收
build.yaml / build (push) Successful in 38s

This commit is contained in:
xiaoqidun committed 2026-09-15 18:19:21 +08:00
1 parent e9d9ba0add
commit cfcf1939d1
9 files changed
+503 -99

No files matched your search

+11 -2
View File
@@ -57,7 +57,7 @@
<div id="editorTools" class="editor-toolbar" role="toolbar" aria-label="创作工具" hidden>
<div class="editor-group">
<button id="selectObjectButton" class="button" type="button" aria-pressed="true">选择</button>
<button id="editTextButton" class="button" type="button" aria-label="修改文字" disabled>修改</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="deleteObjectButton" class="button" type="button" disabled>删除</button>
<select id="objectOrder" class="order-select" aria-label="对象层级" disabled>
@@ -67,6 +67,15 @@
<option value="top">置顶</option>
<option value="bottom">置底</option>
</select>
<select id="objectAlign" class="order-select" aria-label="页面对齐" disabled>
<option value="" hidden>对齐</option>
<option value="left">左侧</option>
<option value="center">水平居中</option>
<option value="right">右侧</option>
<option value="top">顶部</option>
<option value="middle">垂直居中</option>
<option value="bottom">底部</option>
</select>
<span>对象</span>
</div>
<div class="editor-group">
@@ -149,7 +158,7 @@
<form id="insertForm">
<div id="insertTextRow" class="form-row">
<label for="insertText">内容</label>
<input id="insertText" type="text" required autofocus>
<textarea id="insertText" rows="3" required autofocus></textarea>
</div>
<div id="insertFontRow" class="form-row">
<label for="insertFont">字体</label>
+38 -2
View File
@@ -42,6 +42,7 @@ body {
button,
input,
textarea,
select {
font: inherit;
}
@@ -56,6 +57,7 @@ button {
button:disabled,
input:disabled,
textarea:disabled,
select:disabled {
cursor: not-allowed;
opacity: 0.48;
@@ -63,6 +65,7 @@ select:disabled {
button:focus-visible,
input:focus-visible,
textarea:focus-visible,
select:focus-visible,
a:focus-visible {
outline: 2px solid var(--focus);
@@ -105,7 +108,7 @@ a:focus-visible {
}
.editor-group:has(#selectObjectButton) {
grid-template-columns: repeat(5, max-content);
grid-template-columns: repeat(6, max-content);
}
.editor-group:has(#insertTextButton) {
@@ -430,6 +433,7 @@ button:not(:disabled):hover {
.form-row input[type="number"],
.form-row input[type="file"],
.form-row select,
.form-row textarea,
.text-size input {
width: 100%;
min-width: 0;
@@ -441,6 +445,14 @@ button:not(:disabled):hover {
color: #1f2428;
}
.form-row textarea {
height: auto;
min-height: var(--control-height);
padding: 6px;
line-height: 1.5;
resize: vertical;
}
.form-unit.text-size {
grid-template-columns: calc(2em + 22px) auto;
gap: 6px;
@@ -1120,6 +1132,28 @@ button.outline-link:hover {
pointer-events: none;
}
.edit-text {
position: absolute;
overflow: hidden;
z-index: 1;
margin: -2px;
padding: 1px;
border: 1px solid var(--accent);
border-radius: 0;
background: #fff;
outline: none;
resize: none;
font-kerning: none;
font-variant-ligatures: none;
letter-spacing: 0;
cursor: text;
touch-action: auto;
}
.edit-text:focus-visible {
outline: none;
}
.editing-select .edit-layer {
display: block;
}
@@ -1831,11 +1865,13 @@ button.outline-link:hover {
}
@media (max-width: 360px) {
.editor-group:has(#selectObjectButton),
.editor-group:has(#addPageButton) {
grid-template-columns: repeat(3, max-content);
}
.editor-group:has(#addPageButton) > button {
.editor-group:has(#addPageButton) > button,
.editor-group:has(#selectObjectButton) > :not(span) {
grid-row: auto;
}
}
+134 -55
View File
@@ -36,7 +36,7 @@ const state = {
dirty: false,
editorInfo: null,
savedRevision: null,
textObject: null,
insertObject: null,
insertFonts: [],
textFonts: [],
ofdBytes: null,
@@ -87,9 +87,10 @@ const el = {
editorTools: document.querySelector("#editorTools"),
selectObjectButton: document.querySelector("#selectObjectButton"),
deleteObjectButton: document.querySelector("#deleteObjectButton"),
editTextButton: document.querySelector("#editTextButton"),
editObjectButton: document.querySelector("#editObjectButton"),
copyObjectButton: document.querySelector("#copyObjectButton"),
objectOrder: document.querySelector("#objectOrder"),
objectAlign: document.querySelector("#objectAlign"),
textFont: document.querySelector("#textFont"),
textSize: document.querySelector("#textSize"),
textColor: document.querySelector("#textColor"),
@@ -229,7 +230,16 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, {
onTransform: (item, change) => changeDocument("ofdgoTransformObject", item,
change.x + item.x * (1 - change.scale), change.y + item.y * (1 - change.scale), change.scale),
onDelete: (item) => changeDocument("ofdgoDeleteObject", item),
onEdit: (item) => openInsertPanel(true, item),
onEdit: editCanvasObject,
onCommitText: (item, value) => changeDocument("ofdgoUpdateText", item, value, null, item.size, null),
onTextChange: () => {
const editing = canvasEditor.input;
setDirty(state.editing && (state.editorInfo?.revision !== state.savedRevision
|| Boolean(editing && editing.input.value !== editing.item.text)));
if (!editing) {
syncSelection();
}
},
});
el.selectObjectButton.addEventListener("click", () => {
@@ -247,9 +257,9 @@ el.deleteObjectButton.addEventListener("click", () => {
changeDocument("ofdgoDeleteObject", canvasEditor.selected);
}
});
el.editTextButton.addEventListener("click", () => {
if (canvasEditor.selected && !canvasEditor.selected.image) {
openInsertPanel(true, canvasEditor.selected);
el.editObjectButton.addEventListener("click", () => {
if (canvasEditor.selected) {
openInsertPanel(!canvasEditor.selected.image, canvasEditor.selected);
}
});
el.textSize.addEventListener("change", () => changeTextStyle(false));
@@ -291,6 +301,13 @@ el.objectOrder.addEventListener("change", () => {
const targets = { up: item.order + 1, down: item.order - 1, top: item.count - 1, bottom: 0 };
return changeDocument("ofdgoMoveObject", item, targets[action]);
});
el.objectAlign.addEventListener("change", () => {
const alignment = el.objectAlign.value;
el.objectAlign.value = "";
if (canvasEditor.selected && alignment) {
return changeDocument("ofdgoAlignObject", canvasEditor.selected, alignment);
}
});
el.undoButton.addEventListener("click", () => changeDocument("ofdgoUndo"));
el.redoButton.addEventListener("click", () => changeDocument("ofdgoRedo"));
el.addPageButton.addEventListener("click", () => {
@@ -325,7 +342,7 @@ el.createForm.addEventListener("submit", createDocument);
el.insertTextButton.addEventListener("click", () => openInsertPanel(true));
el.insertImageButton.addEventListener("click", () => openInsertPanel(false));
el.insertCancel.addEventListener("click", () => el.insertPanel.close());
el.insertPanel.addEventListener("close", () => { state.textObject = null; });
el.insertPanel.addEventListener("close", () => { state.insertObject = null; });
el.insertForm.addEventListener("submit", insertObject);
el.insertFontAdd.addEventListener("click", () => openFontFile(el.fontInput));
el.saveButton.addEventListener("click", () => exportFile(true, null, "ofd"));
@@ -633,6 +650,33 @@ async function createDocument(event) {
}
}
async function editCanvasObject(item) {
if (item.image) {
return openInsertPanel(false, item);
}
if (!state.editing || document.body.hasAttribute("aria-busy") || canvasEditor.input) {
return;
}
const openSeq = state.openSeq;
setBusy(true);
try {
const data = await callWASM("ofdgoEditorFont", item.font);
const face = new FontFace(`ofdgo-edit-${item.font}`, data.bytes);
await face.load();
if (openSeq === state.openSeq && item.node.isConnected) {
canvasEditor.editText(item, face);
}
} catch (err) {
if (openSeq === state.openSeq) {
showError(err, false);
}
} finally {
if (openSeq === state.openSeq) {
setBusy(false);
}
}
}
async function openInsertPanel(text, item = null) {
if (!state.editing || document.body.hasAttribute("aria-busy")) {
return;
@@ -645,13 +689,13 @@ async function openInsertPanel(text, item = null) {
return;
}
el.insertForm.reset();
state.textObject = item;
if (item) {
state.insertObject = item;
if (item && text) {
state.insertFonts = [];
el.insertText.value = item.text;
}
el.insertPanel.setAttribute("aria-label", item ? "修改文字" : text ? "添加文字" : "添加图片");
el.insertSubmit.textContent = item ? "修改" : "添加";
el.insertPanel.setAttribute("aria-label", item ? text ? "修改文字" : "替换图片" : text ? "添加文字" : "添加图片");
el.insertSubmit.textContent = item ? text ? "修改" : "替换" : "添加";
for (const [row, input] of [[el.insertTextRow, el.insertText], [el.insertFontRow, el.insertFont]]) {
row.hidden = !text;
input.disabled = !text;
@@ -665,7 +709,7 @@ async function openInsertPanel(text, item = null) {
function updateInsertFonts() {
const selected = state.insertFonts[Number(el.insertFont.value)];
state.insertFonts = editorFonts(state.textObject);
state.insertFonts = editorFonts(state.insertObject?.image ? null : state.insertObject);
setFontOptions(el.insertFont, state.insertFonts, selected);
el.insertSubmit.disabled = !el.insertTextRow.hidden && !state.insertFonts.length;
el.insertStatus.textContent = el.insertSubmit.disabled ? "尚未添加字体" : "";
@@ -707,7 +751,7 @@ async function insertObject(event) {
}
let openSeq = state.openSeq;
const text = !el.insertTextRow.hidden;
const item = state.textObject;
const item = state.insertObject;
const index = item?.index ?? state.pageIndex;
const page = state.doc.pages[index];
const x = Math.min(20, page.width / 10);
@@ -729,7 +773,8 @@ async function insertObject(event) {
}
const value = item && data === null && el.insertText.value === item.text ? null : el.insertText.value;
const doc = item
? await callWASM("ofdgoUpdateText", item.index, item.id, value, data, item.size, null)
? text ? await callWASM("ofdgoUpdateText", item.index, item.id, value, data, item.size, null)
: await callWASM("ofdgoReplaceImage", item.index, item.id, data)
: text ? await callWASM("ofdgoInsertText", index, el.insertText.value, data, x, y, 12 * 25.4 / 72, "#000000")
: await callWASM("ofdgoInsertImage", index, data, x, y, page.width - x * 2);
if (openSeq !== state.openSeq) {
@@ -822,15 +867,8 @@ async function changeDocument(name, item, ...args) {
const page = doc.pages[pageIndex];
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 });
if (openSeq === state.openSeq) {
if (keepScroll) {
el.viewerPanel.scrollLeft = scrollLeft;
el.viewerPanel.scrollTop = scrollTop;
} else {
scrollToPage(pageIndex);
}
}
await openDocument({ doc, openSeq, skipAutoFonts: true, pageIndex, fitMode: state.fitMode, scale: state.scale,
keepPreview: true, previewScroll: keepScroll ? { scrollLeft, scrollTop } : null });
}
if (openSeq === state.openSeq) {
el.viewerPanel.focus({ preventScroll: true });
@@ -877,19 +915,7 @@ async function refreshEditorPage(doc, index, openSeq) {
throw err;
} finally {
if (openSeq === state.openSeq) {
const pages = [...state.pageCache.values()];
for (const [name, image] of state.svgImages) {
if (!pages.some((page) => page.imageNames.includes(name))) {
URL.revokeObjectURL(image.url);
state.svgImages.delete(name);
}
}
for (const [name, font] of state.svgFonts) {
if (!pages.some((page) => page.fonts.some((item) => item.name === name))) {
document.fonts.delete(font);
state.svgFonts.delete(name);
}
}
releasePageResources();
renderMeta();
updateControls();
loadDocumentDetails(openSeq);
@@ -902,6 +928,22 @@ async function refreshEditorPage(doc, index, openSeq) {
}
}
function releasePageResources() {
const pages = [...state.pageCache.values()];
for (const [name, image] of state.svgImages) {
if (!pages.some((page) => page.imageNames.includes(name))) {
URL.revokeObjectURL(image.url);
state.svgImages.delete(name);
}
}
for (const [name, font] of state.svgFonts) {
if (!pages.some((page) => page.fonts.some((item) => item.name === name))) {
document.fonts.delete(font);
state.svgFonts.delete(name);
}
}
}
async function openOFDFile() {
if (document.body.hasAttribute("aria-busy")) {
return;
@@ -1703,6 +1745,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) : [];
if (!state.ready || state.wasmExited) {
await ensureWASM();
}
@@ -1716,10 +1759,12 @@ async function openDocument(options = {}) {
updateFontSummary();
renderFontList();
}
setBusy(true, "正在打开文档", 20, STATUS.opening);
options.keepPreview ? setBusy(true) : setBusy(true, "正在打开文档", 20, STATUS.opening);
try {
setProgress("正在解析文档", 52);
await nextFrame();
if (!options.keepPreview) {
setProgress("正在解析文档", 52);
await nextFrame();
}
if (openSeq !== state.openSeq) {
return;
}
@@ -1762,13 +1807,30 @@ async function openDocument(options = {}) {
if (openSeq !== state.openSeq) {
return;
}
resetPageFlow();
if (options.keepPreview) {
resetPageLoading();
state.pageCache.clear();
const indices = new Set([pageIndex, ...doc.pages.filter((page) => previewIDs.includes(page.id)).map((page) => page.index)]);
for (const index of indices) {
await loadPageData(index, { openSeq, priority: 0, refresh: true });
if (openSeq !== state.openSeq) {
return;
}
}
}
resetPageFlow(options.keepPreview);
renderPageList();
if (options.resetScroll || el.outlineList.childElementCount === 0) {
renderOutlines();
}
renderMeta();
renderPageFlow();
if (options.keepPreview) {
for (const [index, page] of state.pageCache) {
mountPageSVG(index, page, openSeq);
}
releasePageResources();
}
if (options.resetScroll) {
el.viewerPanel.scrollLeft = 0;
el.viewerPanel.scrollTop = 0;
@@ -1778,8 +1840,20 @@ async function openDocument(options = {}) {
el.metaPanel.scrollTop = 0;
}
applyFit(false);
if (options.keepPreview) {
if (options.previewScroll) {
Object.assign(el.viewerPanel, options.previewScroll);
} else {
scrollToPage(pageIndex);
}
setStatus(pageStatus(pageIndex, pageCount));
}
await nextFrame();
const page = await renderPage(pageIndex, { keepBusy: true, scroll: false, openSeq });
if (openSeq !== state.openSeq) {
return;
}
const page = options.keepPreview ? state.pageCache.get(pageIndex)
: await renderPage(pageIndex, { keepBusy: true, scroll: false, openSeq });
if (page && options.resetScroll) {
el.viewerPanel.focus({ preventScroll: true });
}
@@ -2051,16 +2125,11 @@ function renderPageFlow() {
el.pageFrame.hidden = false;
}
function resetPageFlow() {
for (const font of state.svgFonts.values()) {
document.fonts.delete(font);
function resetPageFlow(keepCache = false) {
if (!keepCache) {
state.pageCache.clear();
releasePageResources();
}
state.svgFonts.clear();
for (const image of state.svgImages.values()) {
URL.revokeObjectURL(image.url);
}
state.svgImages.clear();
state.pageCache.clear();
state.selectedPages.clear();
state.documentSelection = null;
state.visiblePages.clear();
@@ -2124,8 +2193,9 @@ function flowPageObserver() {
function unmountPage(index) {
const shell = pageShell(index);
const selection = document.getSelection();
const range = selection.rangeCount ? selection.getRangeAt(0) : null;
if (range && (!state.documentSelection || !isDocumentSelection(range)) && range.intersectsNode(shell)) {
const range = !selection.isCollapsed && selection.rangeCount ? selection.getRangeAt(0) : null;
if (canvasEditor.input?.item.index === index
|| range && (!state.documentSelection || !isDocumentSelection(range)) && range.intersectsNode(shell)) {
state.selectedPages.add(index);
return;
}
@@ -2365,7 +2435,12 @@ function createTextLayer(text) {
const line = document.createElement("span");
line.className = "text-run";
const chars = Array.from(run.text);
let end = 0;
for (const item of run.spans || []) {
const breaks = chars.slice(end, item.start).filter((char) => char === "\n").join("");
if (breaks) {
line.append(document.createTextNode(breaks));
}
const span = document.createElement("span");
span.textContent = chars.slice(item.start, item.end).join("");
let width = widths.get(span.textContent);
@@ -2376,6 +2451,11 @@ function createTextLayer(text) {
const [a, b, c, d, e, f] = item.matrix.map((value) => value * MM_TO_PX);
span.style.transform = `matrix(${a / width}, ${b / width}, ${c / 100}, ${d / 100}, ${e}, ${f})`;
line.append(span);
end = item.end;
}
const breaks = chars.slice(end).filter((char) => char === "\n").join("");
if (breaks) {
line.append(document.createTextNode(breaks));
}
layer.append(line);
}
@@ -3866,9 +3946,8 @@ function updateEditorTools() {
function updateObjectControls(item, reset = false) {
const disabled = !item || !state.ready || state.exporting;
el.deleteObjectButton.disabled = el.copyObjectButton.disabled = disabled;
el.editTextButton.disabled = !item || item.image || !state.ready || state.exporting;
el.textFont.disabled = el.textSize.disabled = el.textColor.disabled = el.editTextButton.disabled;
el.deleteObjectButton.disabled = el.copyObjectButton.disabled = el.editObjectButton.disabled = el.objectAlign.disabled = disabled;
el.textFont.disabled = el.textSize.disabled = el.textColor.disabled = disabled || item.image;
updateTextFonts(item);
if (reset || el.textSize.disabled || document.activeElement !== el.textSize) {
el.textSize.value = item && !item.image ? textPoints(item.size) : "";
+87 -3
View File
@@ -44,7 +44,7 @@ export class CanvasEditor {
viewer.addEventListener("keydown", (event) => this.keyDown(event));
viewer.addEventListener("dblclick", () => {
const item = this.selected;
if (this.enabled && item && !item.image && !this.options.busy()) {
if (this.enabled && item && !this.input && !this.options.busy()) {
this.options.onEdit(item);
}
});
@@ -107,12 +107,16 @@ export class CanvasEditor {
}
clear() {
this.closeText();
this.cancel();
this.selectLast = false;
this.select(null);
}
start(event) {
if (this.input) {
return;
}
if (!this.enabled || this.options.busy() || event.button !== 0 || this.drag) {
return;
}
@@ -173,11 +177,11 @@ export class CanvasEditor {
}
keyDown(event) {
if (!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;
}
const directions = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
const edit = (event.key === "Enter" || event.key === "F2") && !this.selected.image;
const edit = event.key === "Enter" || event.key === "F2";
if (event.key !== "Escape" && event.key !== "Delete" && !edit && !directions[event.key]) {
return;
}
@@ -200,4 +204,84 @@ export class CanvasEditor {
}
}
}
editText(item, face) {
this.cancel();
this.closeText();
this.select(item);
document.fonts.add(face);
const input = document.createElement("textarea");
input.className = "edit-text";
input.setAttribute("aria-label", "编辑文字");
input.wrap = "off";
input.spellcheck = false;
input.value = item.text;
Object.assign(input.style, {
left: `${item.x * PX_PER_MM}px`, top: `${item.y * PX_PER_MM}px`,
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`,
fontFamily: `"${face.family}"`, fontSize: `${item.size * PX_PER_MM}px`,
lineHeight: item.lineHeight ? `${item.lineHeight * PX_PER_MM}px` : "normal", color: item.color,
});
this.input = { input, item, face };
item.surface.append(input);
const resize = () => {
input.style.height = "0px";
input.style.height = `${input.scrollHeight + 2}px`;
};
input.addEventListener("input", () => {
resize();
this.options.onTextChange();
});
input.addEventListener("blur", () => this.commitText());
input.addEventListener("keydown", (event) => {
event.stopPropagation();
if (event.isComposing || input.readOnly) {
return;
}
if (event.key === "Escape") {
event.preventDefault();
this.closeText();
this.viewer.focus({ preventScroll: true });
} else if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
this.commitText();
}
});
resize();
input.focus({ preventScroll: true });
input.select();
}
async commitText() {
const editing = this.input;
if (!editing || editing.input.readOnly) {
return;
}
if (editing.input.value === editing.item.text) {
this.closeText();
return;
}
editing.input.readOnly = true;
const saved = await this.options.onCommitText(editing.item, editing.input.value);
if (this.input !== editing) {
return;
}
if (saved) {
this.closeText();
} else {
editing.input.readOnly = false;
editing.input.focus({ preventScroll: true });
}
}
closeText() {
const editing = this.input;
this.input = null;
if (editing) {
editing.input.remove();
document.fonts.delete(editing.face);
this.options.onTextChange();
}
}
}