feat(创建编辑): 支持多页管理与页面设置
build.yaml / build (push) Successful in 38s

This commit is contained in:
xiaoqidun committed 2026-09-15 15:08:34 +08:00
1 parent aec10e93a4
commit bff3949027
4 files changed
+301 -39

No files matched your search

+44 -7
View File
@@ -66,6 +66,15 @@
<button id="insertImageButton" class="button" type="button" aria-label="添加图片">图片</button>
<span>插入</span>
</div>
<div class="editor-group">
<button id="addPageButton" class="button" type="button" aria-label="新增页面">新增</button>
<button id="copyPageButton" class="button" type="button" aria-label="复制页面">复制</button>
<button id="deletePageButton" class="button" type="button" aria-label="删除页面">删除</button>
<button id="movePagePrevButton" class="button" type="button" aria-label="页面前移">前移</button>
<button id="movePageNextButton" class="button" type="button" aria-label="页面后移">后移</button>
<button id="pageSettingsButton" class="button" type="button" aria-label="页面设置" aria-haspopup="dialog" aria-controls="pagePanel">设置</button>
<span>页面</span>
</div>
<div class="editor-group">
<button id="saveButton" class="button" type="button" aria-label="保存OFD">保存</button>
<button id="undoButton" class="button" type="button" disabled>撤销</button>
@@ -79,13 +88,15 @@
<label for="createName">名称</label>
<input id="createName" type="text" value="未命名" required autofocus>
</div>
<div class="form-row">
<label for="createWidth">宽度</label>
<div class="form-unit"><input id="createWidth" type="number" value="210" min="1" step="any" required><span>mm</span></div>
</div>
<div class="form-row">
<label for="createHeight">高度</label>
<div class="form-unit"><input id="createHeight" type="number" value="297" min="1" step="any" required><span>mm</span></div>
<div class="form-pair">
<div class="form-row">
<label for="createWidth">宽度</label>
<div class="form-unit"><input id="createWidth" type="number" value="210" min="1" step="any" required><span>mm</span></div>
</div>
<div class="form-row">
<label for="createHeight">高度</label>
<div class="form-unit"><input id="createHeight" type="number" value="297" min="1" step="any" required><span>mm</span></div>
</div>
</div>
<div class="form-actions">
<div id="createStatus" class="form-status" role="status"></div>
@@ -94,6 +105,32 @@
</div>
</form>
</dialog>
<dialog id="pagePanel" class="form-dialog" aria-label="页面设置">
<form id="pageForm">
<div class="form-row">
<span id="pageDirectionLabel">方向</span>
<div class="form-options" role="radiogroup" aria-labelledby="pageDirectionLabel">
<label><input id="pagePortrait" type="radio" name="direction" value="portrait" autofocus><span>竖向</span></label>
<label><input id="pageLandscape" type="radio" name="direction" value="landscape"><span>横向</span></label>
</div>
</div>
<div class="form-pair">
<div class="form-row">
<label for="pageWidth">宽度</label>
<div class="form-unit"><input id="pageWidth" type="number" min="1" step="any" required><span>mm</span></div>
</div>
<div class="form-row">
<label for="pageHeight">高度</label>
<div class="form-unit"><input id="pageHeight" type="number" min="1" step="any" required><span>mm</span></div>
</div>
</div>
<div class="form-actions">
<div id="pageStatus" class="form-status" role="status"></div>
<button id="pageCancel" class="button" type="button">取消</button>
<button class="button" type="submit">确定</button>
</div>
</form>
</dialog>
<dialog id="insertPanel" class="form-dialog" aria-label="添加文字">
<form id="insertForm">
<div id="insertTextRow" class="form-row">
+30 -1
View File
@@ -97,13 +97,21 @@ a:focus-visible {
.editor-group {
display: grid;
grid-auto-columns: max-content;
grid-template-columns: repeat(3, max-content);
column-gap: 6px;
row-gap: 4px;
padding-right: 12px;
border-right: 1px solid var(--line);
}
.editor-group:has(#insertTextButton) {
grid-template-columns: repeat(2, max-content);
}
.editor-group:has(#addPageButton) {
grid-template-columns: repeat(6, max-content);
}
.editor-group:last-child {
border-right: 0;
}
@@ -351,6 +359,17 @@ button:not(:disabled):hover {
margin-bottom: 12px;
}
.form-pair {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.form-pair .form-unit {
grid-template-columns: minmax(0, 1fr) auto;
gap: 4px;
}
.form-options {
display: flex;
}
@@ -1744,3 +1763,13 @@ button.outline-link:hover {
width: 48px;
}
}
@media (max-width: 360px) {
.editor-group:has(#addPageButton) {
grid-template-columns: repeat(3, max-content);
}
.editor-group:has(#addPageButton) > button {
grid-row: auto;
}
}
+173 -27
View File
@@ -91,6 +91,20 @@ const el = {
redoButton: document.querySelector("#redoButton"),
insertTextButton: document.querySelector("#insertTextButton"),
insertImageButton: document.querySelector("#insertImageButton"),
addPageButton: document.querySelector("#addPageButton"),
copyPageButton: document.querySelector("#copyPageButton"),
deletePageButton: document.querySelector("#deletePageButton"),
movePagePrevButton: document.querySelector("#movePagePrevButton"),
movePageNextButton: document.querySelector("#movePageNextButton"),
pageSettingsButton: document.querySelector("#pageSettingsButton"),
pagePanel: document.querySelector("#pagePanel"),
pageForm: document.querySelector("#pageForm"),
pagePortrait: document.querySelector("#pagePortrait"),
pageLandscape: document.querySelector("#pageLandscape"),
pageWidth: document.querySelector("#pageWidth"),
pageHeight: document.querySelector("#pageHeight"),
pageStatus: document.querySelector("#pageStatus"),
pageCancel: document.querySelector("#pageCancel"),
saveButton: document.querySelector("#saveButton"),
createPanel: document.querySelector("#createPanel"),
createForm: document.querySelector("#createForm"),
@@ -235,6 +249,31 @@ el.editTextButton.addEventListener("click", () => {
});
el.undoButton.addEventListener("click", () => changeDocument("ofdgoUndo"));
el.redoButton.addEventListener("click", () => changeDocument("ofdgoRedo"));
el.addPageButton.addEventListener("click", () => {
const page = currentPageInfo();
return changeDocument("ofdgoChangePage", null, "add", state.pageIndex, page.width, page.height);
});
el.copyPageButton.addEventListener("click", () => changeDocument("ofdgoChangePage", null, "copy", state.pageIndex));
el.deletePageButton.addEventListener("click", () => changeDocument("ofdgoChangePage", null, "delete", state.pageIndex));
el.movePagePrevButton.addEventListener("click", () => changeDocument("ofdgoChangePage", null, "move", state.pageIndex, state.pageIndex - 1));
el.movePageNextButton.addEventListener("click", () => changeDocument("ofdgoChangePage", null, "move", state.pageIndex, state.pageIndex + 1));
el.pageSettingsButton.addEventListener("click", openPagePanel);
el.pageCancel.addEventListener("click", () => el.pagePanel.close());
el.pageWidth.addEventListener("input", updatePageDirection);
el.pageHeight.addEventListener("input", updatePageDirection);
for (const radio of [el.pagePortrait, el.pageLandscape]) {
radio.addEventListener("change", () => {
[el.pageWidth.value, el.pageHeight.value] = [el.pageHeight.value, el.pageWidth.value];
updatePageDirection();
});
}
el.pageForm.addEventListener("submit", async (event) => {
event.preventDefault();
el.pageStatus.textContent = "";
if (await changeDocument("ofdgoChangePage", null, "resize", state.pageIndex, Number(el.pageWidth.value), Number(el.pageHeight.value))) {
el.pagePanel.close();
}
});
el.ofdButton.addEventListener("click", openOFDFile);
el.newButton.addEventListener("click", openCreatePanel);
el.createCancel.addEventListener("click", () => el.createPanel.close());
@@ -455,7 +494,25 @@ function handleKeyDown(event) {
}
function formDialogOpen() {
return el.exportPanel.open || el.createPanel.open || el.insertPanel.open;
return el.exportPanel.open || el.createPanel.open || el.insertPanel.open || el.pagePanel.open;
}
function openPagePanel() {
if (!state.editing || document.body.hasAttribute("aria-busy")) {
return;
}
const page = currentPageInfo();
el.pageWidth.value = String(page.width);
el.pageHeight.value = String(page.height);
el.pageStatus.textContent = "";
updatePageDirection();
el.pagePanel.showModal();
}
function updatePageDirection() {
const landscape = Number(el.pageWidth.value) > Number(el.pageHeight.value);
el.pageLandscape.checked = landscape;
el.pagePortrait.checked = !landscape;
}
function warnUnsaved(event) {
@@ -590,12 +647,12 @@ async function insertObject(event) {
let openSeq = state.openSeq;
const text = !el.insertTextRow.hidden;
const item = state.textObject;
const { scrollLeft, scrollTop } = el.viewerPanel;
const page = state.doc.pages[state.pageIndex];
const index = item?.index ?? state.pageIndex;
const page = state.doc.pages[index];
const x = Math.min(20, page.width / 10);
const y = Math.min(20, page.height / 10);
el.insertStatus.textContent = "";
setBusy(true, item ? "正在修改文字" : text ? "正在添加文字" : "正在添加图片", null);
setBusy(true);
try {
let data = null;
if (text) {
@@ -613,8 +670,8 @@ async function insertObject(event) {
const size = item && points === Number((item.size * 72 / 25.4).toPrecision(12)) ? item.size : points * 25.4 / 72;
const doc = item
? await callWASM("ofdgoUpdateText", item.index, item.id, el.insertText.value, data, size)
: text ? await callWASM("ofdgoInsertText", state.pageIndex, el.insertText.value, data, x, y, size)
: await callWASM("ofdgoInsertImage", state.pageIndex, data, x, y, page.width - x * 2);
: text ? await callWASM("ofdgoInsertText", index, el.insertText.value, data, x, y, size)
: await callWASM("ofdgoInsertImage", index, data, x, y, page.width - x * 2);
if (openSeq !== state.openSeq) {
return;
}
@@ -624,15 +681,17 @@ async function insertObject(event) {
state.selectObjects = true;
setPan(false);
openSeq = ++state.openSeq;
await openDocument({ doc, openSeq, skipAutoFonts: true, pageIndex: item?.index ?? state.pageIndex, fitMode: state.fitMode, scale: state.scale });
if (item && openSeq === state.openSeq) {
el.viewerPanel.scrollLeft = scrollLeft;
el.viewerPanel.scrollTop = scrollTop;
await refreshEditorPage(doc, index, openSeq);
if (openSeq === state.openSeq) {
el.viewerPanel.focus({ preventScroll: true });
}
} catch (err) {
if (openSeq === state.openSeq) {
el.insertStatus.textContent = err.message;
if (el.insertPanel.open) {
el.insertStatus.textContent = err.message;
} else {
showError(err, false);
}
}
} finally {
if (openSeq === state.openSeq) {
@@ -646,27 +705,51 @@ async function changeDocument(name, item, ...args) {
return;
}
let openSeq = state.openSeq;
const previous = currentPageInfo();
const { scrollLeft, scrollTop } = el.viewerPanel;
setBusy(true, "正在更新文档", null);
setBusy(true);
try {
const doc = await callWASM(name, ...(item ? [item.index, item.id, ...args] : []));
const doc = await callWASM(name, ...(item ? [item.index, item.id] : []), ...args);
if (openSeq !== state.openSeq) {
return;
}
if (doc.revision === state.editorInfo?.revision) {
return true;
}
setEditorInfo(doc);
if (!item || name === "ofdgoDeleteObject") {
canvasEditor.clear();
}
openSeq = ++state.openSeq;
await openDocument({ doc, openSeq, skipAutoFonts: true, pageIndex: item?.index ?? state.pageIndex, fitMode: state.fitMode, scale: state.scale });
if (item) {
await refreshEditorPage(doc, item.index, openSeq);
} 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);
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);
}
}
}
if (openSeq === state.openSeq) {
el.viewerPanel.scrollLeft = scrollLeft;
el.viewerPanel.scrollTop = scrollTop;
el.viewerPanel.focus({ preventScroll: true });
return true;
}
} catch (err) {
if (openSeq === state.openSeq) {
showError(err, false);
if (el.pagePanel.open) {
el.pageStatus.textContent = err.message;
} else {
showError(err, false);
}
}
} finally {
if (openSeq === state.openSeq) {
@@ -675,6 +758,57 @@ async function changeDocument(name, item, ...args) {
}
}
async function refreshEditorPage(doc, index, openSeq) {
state.doc = doc;
resetSearch();
state.documentSelection = null;
resetPageLoading();
try {
const page = await loadPageData(index, { openSeq, priority: 0, refresh: true });
if (openSeq !== state.openSeq) {
return;
}
mountPageSVG(index, page, openSeq);
updateThumbnail(index, openSeq);
setStatus(pageStatus(state.pageIndex, doc.pageCount));
} catch (err) {
if (openSeq === state.openSeq) {
state.pageCache.delete(index);
canvasEditor.clear();
const shell = pageShell(index);
shell.querySelector(".page-surface").replaceChildren();
shell.classList.remove("rendered");
markFlowPageError(index, err);
markThumbnailError(index);
}
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);
}
}
renderMeta();
updateControls();
loadDocumentDetails(openSeq);
for (const page of doc.pages) {
observeFlowPage(pageShell(page.index), page.index);
const button = el.pageList.querySelector(`[data-page-index="${page.index}"]`);
observeThumbnail(button, page.index, openSeq);
}
}
}
}
async function openOFDFile() {
if (document.body.hasAttribute("aria-busy")) {
return;
@@ -1116,6 +1250,7 @@ async function openOFD(file) {
setDirty(false);
el.createPanel.close();
el.insertPanel.close();
el.pagePanel.close();
updateControls();
await openDocument({ pageIndex: 0, resetScroll: true, openSeq });
} catch (err) {
@@ -1587,7 +1722,7 @@ async function loadDocumentDetails(openSeq) {
}
async function renderPage(index, options = {}) {
if (!state.doc) {
if (!state.doc || (state.editing && document.body.hasAttribute("aria-busy") && !options.keepBusy)) {
return null;
}
const openSeq = options.openSeq || state.openSeq;
@@ -1835,6 +1970,10 @@ function resetPageFlow() {
state.selectedPages.clear();
state.documentSelection = null;
state.visiblePages.clear();
resetPageLoading();
}
function resetPageLoading() {
for (const task of state.pageInFlight.values()) {
task.resolve(null);
}
@@ -1936,12 +2075,6 @@ function loadPageData(index, options = {}) {
if (openSeq !== state.openSeq) {
return Promise.resolve(null);
}
if (state.pageCache.has(index)) {
const page = state.pageCache.get(index);
state.pageCache.delete(index);
state.pageCache.set(index, page);
return Promise.resolve(page);
}
const key = `${openSeq}:${index}`;
const priority = options.priority ?? 3;
const current = state.pageInFlight.get(key);
@@ -1949,13 +2082,19 @@ function loadPageData(index, options = {}) {
current.priority = Math.min(current.priority, priority);
return current.promise;
}
if (!options.refresh && state.pageCache.has(index)) {
const page = state.pageCache.get(index);
state.pageCache.delete(index);
state.pageCache.set(index, page);
return Promise.resolve(page);
}
let resolve;
let reject;
const promise = new Promise((done, fail) => {
resolve = done;
reject = fail;
});
const task = { key, index, openSeq, priority, resolve, reject, promise };
const task = { key, index, openSeq, priority, refresh: options.refresh, resolve, reject, promise };
state.pageInFlight.set(key, task);
state.pageRenderQueue.push(task);
schedulePageRender();
@@ -1981,7 +2120,7 @@ async function processPageRenderQueue() {
task.resolve(null);
continue;
}
if (state.pageCache.has(task.index)) {
if (!task.refresh && state.pageCache.has(task.index)) {
task.resolve(state.pageCache.get(task.index));
continue;
}
@@ -2892,6 +3031,7 @@ function updateThumbnail(index, openSeq = state.openSeq) {
function markThumbnailError(index) {
const thumb = el.pageList.querySelector(`[data-page-index="${index}"] .thumb-paper`);
if (thumb) {
delete thumb.dataset.renderKey;
thumb.classList.add("error");
thumb.textContent = String(index + 1);
}
@@ -3617,6 +3757,11 @@ function updateControls() {
function updateEditorTools() {
el.editorTools.hidden = !state.editing;
el.insertTextButton.disabled = el.insertImageButton.disabled = el.saveButton.disabled = !state.ready || state.exporting;
const pagesDisabled = !state.editing || !state.ready || state.exporting;
el.addPageButton.disabled = el.copyPageButton.disabled = el.pageSettingsButton.disabled = pagesDisabled;
el.deletePageButton.disabled = pagesDisabled || state.doc?.pageCount <= 1;
el.movePagePrevButton.disabled = pagesDisabled || state.pageIndex === 0;
el.movePageNextButton.disabled = pagesDisabled || state.pageIndex === state.doc?.pageCount - 1;
const enabled = state.editing && state.selectObjects && !state.panMode;
canvasEditor.setEnabled(enabled);
el.selectObjectButton.setAttribute("aria-pressed", String(enabled));
@@ -3678,6 +3823,7 @@ function setBusy(busy, text = "", percent = 0, status = "") {
document.body.toggleAttribute("aria-busy", busy);
el.createForm.inert = busy;
el.insertForm.inert = busy;
el.pageForm.inert = busy;
el.editorTools.inert = busy;
el.fontList.inert = busy;
if (!busy) {
@@ -3689,7 +3835,7 @@ function setBusy(busy, text = "", percent = 0, status = "") {
}
el.exportPanel.close();
endPan();
el.progressPanel.hidden = false;
el.progressPanel.hidden = !text;
setProgress(text, percent, status);
}
+54 -4
View File
@@ -96,6 +96,7 @@ func RunWASM() {
registerExportCallback("ofdgoExportAttachment", exportAttachment)
registerCallback("ofdgoMatchFontFiles", matchFontFiles)
registerCallback("ofdgoCreateDocument", createDocument)
registerCallback("ofdgoChangePage", changePage)
registerCallback("ofdgoInsertText", insertText)
registerCallback("ofdgoUpdateText", updateText)
registerCallback("ofdgoInsertImage", insertImage)
@@ -521,6 +522,12 @@ type editorInfo struct {
CanRedo bool `json:"canRedo"`
}
// editorPageInfo 页面操作结果与目标页面
type editorPageInfo struct {
editorInfo
PageIndex int `json:"pageIndex"`
}
// editorSummary 获取当前创作文档状态
// 返回: editorInfo 文档信息
func editorSummary() editorInfo {
@@ -660,6 +667,49 @@ func deleteObject(args []js.Value) (any, error) {
return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations)
}
// changePage 管理创作文档的页面
// 入参: args 操作、页面索引及尺寸或目标索引
// 返回: any 文档信息和目标页面, error 错误信息
func changePage(args []js.Value) (any, error) {
if currentEditor == nil {
return nil, fmt.Errorf("no document is being created")
}
index := args[1].Int()
revision := currentEditor.Revision()
var err error
switch action := args[0].String(); action {
case "add":
index, err = currentEditor.AddPage(args[2].Float(), args[3].Float())
case "copy":
index, err = currentEditor.CopyPage(index)
case "delete":
if currentEditor.PageCount() == 1 {
return nil, fmt.Errorf("the document must contain at least one page")
}
err = currentEditor.DeletePage(index)
index = min(index, currentEditor.PageCount()-1)
case "move":
target := args[2].Int()
err = currentEditor.MovePage(index, target)
index = target
case "resize":
err = currentEditor.ResizePage(index, args[2].Float(), args[3].Float())
default:
return nil, fmt.Errorf("unsupported page action %q", action)
}
if err != nil {
return nil, err
}
if currentEditor.Revision() == revision {
return editorPageInfo{editorSummary(), index}, nil
}
info, err := previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations)
if err != nil {
return nil, err
}
return editorPageInfo{info, index}, nil
}
// createDocument 新建单页文档
// 入参: args 标题、纸张宽高和注解设置
// 返回: any 文档信息, error 错误信息
@@ -675,15 +725,15 @@ func createDocument(args []js.Value) (any, error) {
// previewEditor 通过内存快照更新预览,不生成中间压缩包
// 入参: editor 新建文档, annotations 是否显示注解
// 返回: any 文档信息, error 错误信息
func previewEditor(editor *ofdgo.Editor, annotations bool) (any, error) {
// 返回: editorInfo 文档信息, error 错误信息
func previewEditor(editor *ofdgo.Editor, annotations bool) (editorInfo, error) {
reader, err := editor.Reader()
if err != nil {
return nil, err
return editorInfo{}, err
}
session, err := newSession(reader, OpenOptions{RenderAnnotations: annotations})
if err != nil {
return nil, err
return editorInfo{}, err
}
if currentSession != nil {
_ = currentSession.Close()