feat(编辑能力): 接入版本管理并完善编辑隔离
build.yaml / build (push) Successful in 48s

This commit is contained in:
xiaoqidun committed 2026-10-10 21:29:51 +08:00
1 parent 5bc92dddd1
commit 89bbbba46e
4 files changed
+240 -4

No files matched your search

+21 -1
View File
@@ -348,6 +348,23 @@
<div id="documentStatus" class="form-status" role="status"></div> <div id="documentStatus" class="form-status" role="status"></div>
</form> </form>
</dialog> </dialog>
<dialog id="versionPanel" class="form-dialog document-dialog" aria-labelledby="versionTitle">
<form id="versionForm">
<h2 id="versionTitle" class="form-title">版本管理</h2>
<div id="versionNotice" class="font-permission-hint" role="status" hidden></div>
<div id="versionList" class="document-list" role="listbox" aria-label="版本列表"></div>
<label class="form-row"><span>名称</span><input id="versionName" type="text" autocomplete="off" aria-label="版本名称"></label>
<div class="document-actions" role="group" aria-label="版本操作">
<button id="versionAdd" class="button" type="button" title="新增版本" aria-label="新增版本">新增</button>
<button id="versionRename" class="button" type="button" title="修改名称" aria-label="修改名称">改名</button>
<button id="versionDefault" class="button" type="button" title="默认版本" aria-label="默认版本">默认</button>
<button id="versionDelete" class="button" type="button" title="删除版本" aria-label="删除版本">删除</button>
<button id="versionSelect" class="button" type="button" title="切换版本" aria-label="切换版本">切换</button>
<button id="versionClose" class="button" type="button">关闭</button>
</div>
<div id="versionStatus" class="form-status" role="status"></div>
</form>
</dialog>
<dialog id="infoPanel" class="form-dialog" aria-label="文档信息"> <dialog id="infoPanel" class="form-dialog" aria-label="文档信息">
<form id="infoForm"> <form id="infoForm">
<label class="form-row"><span>标题</span><input id="infoTitle" type="text" autofocus></label> <label class="form-row"><span>标题</span><input id="infoTitle" type="text" autofocus></label>
@@ -620,7 +637,10 @@
</section> </section>
<div class="panel-row"> <div class="panel-row">
<div class="panel-title compact">文档</div> <div class="panel-title compact">文档</div>
<button id="documentManageButton" class="small-button" type="button" title="文档管理" aria-label="文档管理" aria-haspopup="dialog" aria-controls="documentPanel" hidden>管理</button> <div class="panel-actions">
<button id="versionManageButton" class="small-button" type="button" title="版本管理" aria-label="版本管理" aria-haspopup="dialog" aria-controls="versionPanel" hidden>版本</button>
<button id="documentManageButton" class="small-button" type="button" title="文档管理" aria-label="文档管理" aria-haspopup="dialog" aria-controls="documentPanel" hidden>管理</button>
</div>
</div> </div>
<div id="editNotice" class="font-permission-hint" role="status" hidden></div> <div id="editNotice" class="font-permission-hint" role="status" hidden></div>
<div id="documentRow" class="document-row" hidden> <div id="documentRow" class="document-row" hidden>
+150 -2
View File
@@ -64,6 +64,7 @@ const state = {
createPackage: false, createPackage: false,
createDocumentAt: null, createDocumentAt: null,
documentManagement: null, documentManagement: null,
versionManagement: null,
infoDocumentIndex: null, infoDocumentIndex: null,
pageSelection: new Set(), pageSelection: new Set(),
pageSelectionAnchor: null, pageSelectionAnchor: null,
@@ -257,6 +258,19 @@ const el = {
documentDeleteButton: document.querySelector("#documentDeleteButton"), documentDeleteButton: document.querySelector("#documentDeleteButton"),
documentUpButton: document.querySelector("#documentUpButton"), documentUpButton: document.querySelector("#documentUpButton"),
documentDownButton: document.querySelector("#documentDownButton"), documentDownButton: document.querySelector("#documentDownButton"),
versionManageButton: document.querySelector("#versionManageButton"),
versionPanel: document.querySelector("#versionPanel"),
versionForm: document.querySelector("#versionForm"),
versionNotice: document.querySelector("#versionNotice"),
versionList: document.querySelector("#versionList"),
versionName: document.querySelector("#versionName"),
versionStatus: document.querySelector("#versionStatus"),
versionAdd: document.querySelector("#versionAdd"),
versionRename: document.querySelector("#versionRename"),
versionDefault: document.querySelector("#versionDefault"),
versionDelete: document.querySelector("#versionDelete"),
versionSelect: document.querySelector("#versionSelect"),
versionClose: document.querySelector("#versionClose"),
infoPanel: document.querySelector("#infoPanel"), infoPanel: document.querySelector("#infoPanel"),
infoForm: document.querySelector("#infoForm"), infoForm: document.querySelector("#infoForm"),
infoTitle: document.querySelector("#infoTitle"), infoTitle: document.querySelector("#infoTitle"),
@@ -696,6 +710,33 @@ editorClick(el.paragraphButton, () => {
el.paragraphCancel.addEventListener("click", () => el.paragraphPanel.close()); el.paragraphCancel.addEventListener("click", () => el.paragraphPanel.close());
editorClick(el.documentAddButton, () => openCreatePanel(false, true)); editorClick(el.documentAddButton, () => openCreatePanel(false, true));
editorClick(el.documentManageButton, openDocumentManagement); editorClick(el.documentManageButton, openDocumentManagement);
editorClick(el.versionManageButton, openVersionManagement);
el.versionClose.addEventListener("click", () => el.versionPanel.close());
el.versionPanel.addEventListener("cancel", event => { if (el.versionForm.inert) event.preventDefault(); });
el.versionPanel.addEventListener("close", () => {
if (!el.versionPanel.open) {
state.versionManagement = null;
el.versionList.replaceChildren();
el.versionName.value = el.versionStatus.textContent = "";
}
});
el.versionForm.addEventListener("submit", event => event.preventDefault());
el.versionName.addEventListener("input", updateVersionControls);
for (const [button, action] of [[el.versionAdd, "add"], [el.versionRename, "rename"], [el.versionDefault, "default"], [el.versionDelete, "delete"], [el.versionSelect, "select"]]) {
button.addEventListener("click", () => changeVersion(action));
}
el.versionList.addEventListener("keydown", event => {
const manager = state.versionManagement;
if (!manager?.entries || manager.loading || document.body.hasAttribute("aria-busy")) return;
let index = manager.entries.findIndex(entry => entry.id === manager.id);
if (event.key === "ArrowUp") index--;
else if (event.key === "ArrowDown") index++;
else if (event.key === "Home") index = 0;
else if (event.key === "End") index = manager.entries.length - 1;
else return;
event.preventDefault();
selectManagedVersion(manager.entries[Math.max(0, Math.min(index, manager.entries.length - 1))].id, true);
});
el.documentClose.addEventListener("click", () => el.documentPanel.close()); el.documentClose.addEventListener("click", () => el.documentPanel.close());
el.documentPanel.addEventListener("cancel", event => { if (el.documentForm.inert) event.preventDefault(); }); el.documentPanel.addEventListener("cancel", event => { if (el.documentForm.inert) event.preventDefault(); });
el.documentPanel.addEventListener("close", () => { el.documentPanel.addEventListener("close", () => {
@@ -1610,7 +1651,7 @@ function handleKeyDown(event) {
function formDialogOpen() { function formDialogOpen() {
return Boolean(sealPreview) || 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 return Boolean(sealPreview) || 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
|| el.batchPagesPanel.open || el.objectStylePanel.open || el.sourceTextPanel.open || el.outlinePanel.open || el.objectBoundsPanel.open || el.annotationNote.open || el.annotationCreate.open || el.objectPicker.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;
} }
function setObjectDimension(input, value) { function setObjectDimension(input, value) {
@@ -2036,6 +2077,106 @@ function setEditorInfo(doc) {
setDirty(state.packageDirty || state.encryptionDirty || doc.revision !== state.savedRevision); setDirty(state.packageDirty || state.encryptionDirty || doc.revision !== state.savedRevision);
} }
async function openVersionManagement() {
if (!state.editing || !state.ready || state.exporting || document.body.hasAttribute("aria-busy")) return;
state.versionManagement = { id: state.doc.documentVersion || "", entries: null, loading: false, failed: false };
el.versionList.replaceChildren();
el.versionName.value = "";
el.versionPanel.showModal();
await refreshVersionManagement(true);
}
async function refreshVersionManagement(focus = false) {
const manager = state.versionManagement, openSeq = state.openSeq;
if (!manager || !el.versionPanel.open) return;
manager.loading = true;
manager.failed = false;
el.versionStatus.textContent = "正在读取版本";
updateVersionControls();
try {
const entries = await callWASM("ofdgoDocumentVersions");
if (state.versionManagement !== manager || !el.versionPanel.open) return;
if (openSeq !== state.openSeq) return refreshVersionManagement(focus);
manager.entries = entries;
if (!entries.some(entry => entry.id === manager.id)) manager.id = state.doc.documentVersion || "";
el.versionList.replaceChildren(...entries.map(entry => {
const button = document.createElement("button");
button.type = "button";
button.className = "document-entry";
button.setAttribute("role", "option");
button.setAttribute("aria-current", String(entry.id === (state.doc.documentVersion || "")));
const name = document.createElement("span"), marker = document.createElement("span");
name.className = "document-name";
name.textContent = entry.id ? entry.name || `版本 ${entry.index}` : "主文档";
marker.className = "document-pages";
marker.textContent = entry.default ? "默认" : "";
button.title = name.textContent;
button.append(name, marker);
button.addEventListener("click", () => selectManagedVersion(entry.id));
return button;
}));
manager.loading = false;
el.versionStatus.textContent = "";
selectManagedVersion(manager.id, focus);
} catch (err) {
if (state.versionManagement !== manager || !el.versionPanel.open) return;
if (openSeq !== state.openSeq) return refreshVersionManagement(focus);
manager.failed = true;
el.versionStatus.textContent = err.message;
} finally {
if (state.versionManagement === manager && openSeq === state.openSeq) {
manager.loading = false;
updateVersionControls();
}
}
}
function selectManagedVersion(id, focus = false) {
const manager = state.versionManagement;
if (!manager || manager.loading || document.body.hasAttribute("aria-busy")) return;
const index = manager.entries?.findIndex(entry => entry.id === id) ?? -1;
if (index < 0) return;
manager.id = id;
el.versionName.value = manager.entries[index].name;
Array.from(el.versionList.children).forEach((button, position) => {
button.setAttribute("aria-selected", String(position === index));
button.tabIndex = position === index ? 0 : -1;
});
updateVersionControls();
if (focus) el.versionList.children[index]?.focus();
}
async function changeVersion(action) {
const manager = state.versionManagement;
if (!state.editing || !manager?.entries || manager.loading || manager.failed || document.body.hasAttribute("aria-busy")) return;
const entry = manager.entries.find(entry => entry.id === manager.id);
if (!entry) return;
if (action === "delete" && (!entry.id || !window.confirm(`删除版本“${entry.name || entry.index}”?`))) return;
el.versionStatus.textContent = "正在处理版本";
if (await changeDocument("ofdgoChangeVersion", null, action, entry.id, el.versionName.value.trim())) {
if (state.versionManagement !== manager || !el.versionPanel.open) return;
if (action === "add" || action === "select") manager.id = state.doc.documentVersion || "";
await refreshVersionManagement(true);
}
}
function updateVersionControls() {
const manager = state.versionManagement, busy = document.body.hasAttribute("aria-busy");
const disabled = !state.editing || !state.ready || state.exporting || busy;
el.versionManageButton.hidden = !state.editing;
el.versionManageButton.disabled = disabled;
el.versionNotice.textContent = state.editorInfo?.editWarnings?.join(";") || "";
el.versionNotice.hidden = !el.versionNotice.textContent;
const entry = manager?.entries?.find(entry => entry.id === manager.id);
const unavailable = disabled || !entry || Boolean(manager?.loading || manager?.failed);
el.versionList.inert = busy || Boolean(manager?.loading);
el.versionName.disabled = el.versionAdd.disabled = unavailable;
el.versionRename.disabled = unavailable || !entry?.id || el.versionName.value.trim() === entry.name;
el.versionDelete.disabled = unavailable || !entry?.id;
el.versionDefault.disabled = unavailable || Boolean(entry?.default);
el.versionSelect.disabled = unavailable || entry?.id === (state.doc?.documentVersion || "");
}
function managedDocumentIndex() { function managedDocumentIndex() {
return state.documentManagement?.index ?? state.doc?.documentIndex ?? 0; return state.documentManagement?.index ?? state.doc?.documentIndex ?? 0;
} }
@@ -3098,7 +3239,8 @@ async function changeDocument(name, item, ...args) {
const previousDocument = state.doc.documents?.[state.doc.documentIndex || 0]; const previousDocument = state.doc.documents?.[state.doc.documentIndex || 0];
const nextDocument = doc.documents?.[doc.documentIndex || 0]; const nextDocument = doc.documents?.[doc.documentIndex || 0];
const sameDocument = previousDocument?.id && previousDocument.id === nextDocument?.id && state.doc.documentIndex === doc.documentIndex; const sameDocument = previousDocument?.id && previousDocument.id === nextDocument?.id && state.doc.documentIndex === doc.documentIndex;
const documentChanged = previousDocument?.id !== nextDocument?.id || previousDocument?.root !== nextDocument?.root const documentChanged = (state.doc.documentVersion || "") !== (doc.documentVersion || "")
|| previousDocument?.id !== nextDocument?.id || previousDocument?.root !== nextDocument?.root
&& !(sameDocument && (name === "ofdgoChangeFontBinding" || restoring)); && !(sameDocument && (name === "ofdgoChangeFontBinding" || restoring));
const packageChanged = documentChanged || name === "ofdgoChangeDocument" || restoring const packageChanged = documentChanged || name === "ofdgoChangeDocument" || restoring
&& JSON.stringify(doc.documents?.map(entry => [entry.root, entry.id])) !== JSON.stringify(state.doc.documents?.map(entry => [entry.root, entry.id])); && JSON.stringify(doc.documents?.map(entry => [entry.root, entry.id])) !== JSON.stringify(state.doc.documents?.map(entry => [entry.root, entry.id]));
@@ -3227,6 +3369,8 @@ async function changeDocument(name, item, ...args) {
el.createStatus.textContent = err.message; el.createStatus.textContent = err.message;
} else if (el.importPanel.open) { } else if (el.importPanel.open) {
el.importStatus.textContent = err.message; el.importStatus.textContent = err.message;
} else if (el.versionPanel.open) {
el.versionStatus.textContent = err.message;
} else if (el.documentPanel.open) { } else if (el.documentPanel.open) {
el.documentStatus.textContent = err.message; el.documentStatus.textContent = err.message;
} else { } else {
@@ -3971,6 +4115,8 @@ async function openOFD(file, remote = null) {
el.infoPanel.close(); el.infoPanel.close();
el.documentPanel.close(); el.documentPanel.close();
state.documentManagement = null; state.documentManagement = null;
el.versionPanel.close();
state.versionManagement = null;
state.infoDocumentIndex = null; state.infoDocumentIndex = null;
el.annotationNote.close(); el.annotationNote.close();
el.annotationCreate.close(); el.annotationCreate.close();
@@ -8785,6 +8931,7 @@ function updateDocumentControls() {
el.documentDeleteButton.disabled = actionsDisabled || count <= 1; el.documentDeleteButton.disabled = actionsDisabled || count <= 1;
el.documentUpButton.disabled = actionsDisabled || index === 0; el.documentUpButton.disabled = actionsDisabled || index === 0;
el.documentDownButton.disabled = actionsDisabled || index >= count - 1; el.documentDownButton.disabled = actionsDisabled || index >= count - 1;
updateVersionControls();
} }
function updateControls() { function updateControls() {
@@ -9006,6 +9153,7 @@ function setBusy(busy, text = "", percent = 0, status = "") {
el.paragraphForm.inert = busy; el.paragraphForm.inert = busy;
el.infoForm.inert = busy; el.infoForm.inert = busy;
el.documentForm.inert = busy; el.documentForm.inert = busy;
el.versionForm.inert = busy;
el.attachmentForm.inert = busy; el.attachmentForm.inert = busy;
el.attachmentPanel.inert = busy; el.attachmentPanel.inert = busy;
el.annotationForm.inert = busy; el.annotationForm.inert = busy;
+59 -1
View File
@@ -186,6 +186,8 @@ func RunWASM() {
registerAsyncCallback("ofdgoDetectFormat", detectDocumentFormat) registerAsyncCallback("ofdgoDetectFormat", detectDocumentFormat)
registerCallback("ofdgoOpen", openDocument) registerCallback("ofdgoOpen", openDocument)
registerCallback("ofdgoSelectDocument", selectDocument) registerCallback("ofdgoSelectDocument", selectDocument)
registerCallback("ofdgoDocumentVersions", documentVersions)
registerCallback("ofdgoChangeVersion", changeDocumentVersion)
registerCallback("ofdgoChangeDocument", changePackageDocument) registerCallback("ofdgoChangeDocument", changePackageDocument)
registerCallback("ofdgoPackageDocuments", packageDocuments) registerCallback("ofdgoPackageDocuments", packageDocuments)
registerAsyncCallback("ofdgoConvertPDF", convertPDFDocument) registerAsyncCallback("ofdgoConvertPDF", convertPDFDocument)
@@ -3443,6 +3445,60 @@ func packageDocuments(args []js.Value) (any, error) {
return entries, nil return entries, nil
} }
// documentVersions 读取当前编辑文档的版本列表,不切换版本或解析页面
// 入参: args 保留参数
// 返回: any 版本列表, error 读取错误
func documentVersions(args []js.Value) (any, error) {
if currentEditor == nil {
return nil, fmt.Errorf("no document is being edited")
}
versions, err := currentEditor.DocumentVersions()
if err != nil {
return nil, err
}
entries := make([]VersionEntry, 1, len(versions)+1)
entries[0].Default = true
for _, version := range versions {
entries = append(entries, VersionEntry{ID: version.ID, Index: version.Index, Name: version.Name, Default: version.Current})
if version.Current {
entries[0].Default = false
}
}
return entries, nil
}
// changeDocumentVersion 调用库层版本操作,沿用编辑历史和预览更新流程
// 入参: args 操作、版本标识及可选名称
// 返回: any 文档信息, error 操作错误
func changeDocumentVersion(args []js.Value) (any, error) {
if len(args) != 3 {
return nil, fmt.Errorf("invalid version arguments")
}
return changeAtomicObjects(func() error {
id, name := args[1].String(), args[2].String()
switch args[0].String() {
case "add":
return currentEditor.Transaction(func(editor *ofdgo.Editor) error {
if err := editor.SelectVersion(id); err != nil {
return err
}
_, err := editor.AddVersion(name)
return err
})
case "rename":
return currentEditor.RenameVersion(id, name)
case "delete":
return currentEditor.DeleteVersion(id)
case "default":
return currentEditor.SetDefaultVersion(id)
case "select":
return currentEditor.SelectVersion(id)
default:
return fmt.Errorf("invalid version operation")
}
})
}
// changeAttachment 编辑附件,逐个读取文件并复用库层事务和历史 // changeAttachment 编辑附件,逐个读取文件并复用库层事务和历史
// 入参: args 操作、附件标识、名称列表、文件读取回调 // 入参: args 操作、附件标识、名称列表、文件读取回调
// 返回: any 编辑状态, error 错误信息 // 返回: any 编辑状态, error 错误信息
@@ -3649,7 +3705,9 @@ func previewEditor(editor *ofdgo.Editor, annotations bool) (editorInfo, error) {
if currentSession != nil { if currentSession != nil {
previous := currentSession.Reader previous := currentSession.Reader
oldBody, newBody := previous.OFD.DocBody[previous.DocumentIndex()], reader.OFD.DocBody[reader.DocumentIndex()] oldBody, newBody := previous.OFD.DocBody[previous.DocumentIndex()], reader.OFD.DocBody[reader.DocumentIndex()]
if oldBody.DocRoot != newBody.DocRoot || oldBody.DocInfo.DocID != newBody.DocInfo.DocID { oldVersion, _ := previous.DocumentVersionID()
newVersion, _ := reader.DocumentVersionID()
if oldBody.DocRoot != newBody.DocRoot || oldBody.DocInfo.DocID != newBody.DocInfo.DocID || oldVersion != newVersion {
copiedObjects, copiedStyle = nil, nil copiedObjects, copiedStyle = nil, nil
clearImport() clearImport()
} }
+10
View File
@@ -73,6 +73,7 @@ type pageBoxInfo struct {
// DocumentInfo 文档信息 // DocumentInfo 文档信息
type DocumentInfo struct { type DocumentInfo struct {
DocumentIndex int `json:"documentIndex"` DocumentIndex int `json:"documentIndex"`
DocumentVersion string `json:"documentVersion"`
Documents []DocumentEntry `json:"documents,omitempty"` Documents []DocumentEntry `json:"documents,omitempty"`
Encryption EncryptionInfo `json:"encryption"` Encryption EncryptionInfo `json:"encryption"`
Version string `json:"version"` Version string `json:"version"`
@@ -98,6 +99,14 @@ type DocumentInfo struct {
DetailsPending bool `json:"detailsPending,omitempty"` DetailsPending bool `json:"detailsPending,omitempty"`
} }
// VersionEntry 版本管理列表中的入口,空ID表示主文档
type VersionEntry struct {
ID string `json:"id"`
Index int32 `json:"index"`
Name string `json:"name"`
Default bool `json:"default"`
}
// DocumentEntry 包内文档入口,管理列表按需补充元数据和页数 // DocumentEntry 包内文档入口,管理列表按需补充元数据和页数
type DocumentEntry struct { type DocumentEntry struct {
Index int `json:"index"` Index int `json:"index"`
@@ -441,6 +450,7 @@ func (s *Session) Summary() DocumentInfo {
Outlines: s.doc.OutlineInfos(), Outlines: s.doc.OutlineInfos(),
DetailsPending: true, DetailsPending: true,
} }
info.DocumentVersion, _ = s.Reader.DocumentVersionID()
for index, body := range s.Reader.OFD.DocBody { for index, body := range s.Reader.OFD.DocBody {
info.Documents = append(info.Documents, DocumentEntry{Index: index, Title: body.DocInfo.Title, Root: body.DocRoot, ID: body.DocInfo.DocID}) info.Documents = append(info.Documents, DocumentEntry{Index: index, Title: body.DocInfo.Title, Root: body.DocRoot, ID: body.DocInfo.DocID})
} }