feat(目录支持): 新增目录导航,支持层级展开与页码跳转
build.yaml / build (push) Successful in 52s

This commit is contained in:
xiaoqidun committed 2026-09-12 18:41:44 +08:00
1 parent 90bc7b93dc
commit c2899deff1
7 files changed
+238 -17

No files matched your search

+7 -2
View File
@@ -54,8 +54,13 @@
<main class="workspace">
<aside class="page-list-panel">
<div class="panel-title">页面</div>
<div id="pageList" class="page-list"></div>
<div id="pageListTitle" class="panel-title">页面</div>
<div id="navigationTabs" class="navigation-tabs" role="tablist" aria-label="文档导航" hidden>
<button id="pagesTab" type="button" role="tab" aria-selected="true" aria-controls="pageList">页面</button>
<button id="outlinesTab" type="button" role="tab" aria-selected="false" aria-controls="outlineList" tabindex="-1">目录</button>
</div>
<div id="pageList" class="page-list" role="tabpanel" aria-label="页面"></div>
<div id="outlineList" role="tabpanel" aria-label="目录" hidden></div>
</aside>
<section class="viewer-panel" tabindex="0">
+80
View File
@@ -371,6 +371,84 @@ body[data-hide-meta] .meta-panel {
line-height: 1;
}
.navigation-tabs {
display: flex;
gap: 16px;
margin-bottom: 10px;
border-bottom: 1px solid var(--line);
}
.navigation-tabs button {
height: 28px;
padding: 0 0 6px;
border: 0;
border-bottom: 2px solid transparent;
background: transparent;
color: var(--text-muted);
font-size: 12px;
font-weight: 700;
}
.navigation-tabs button[aria-selected="true"] {
border-bottom-color: var(--accent);
color: var(--accent-strong);
}
.outline-list {
margin: 0;
padding: 0;
list-style: none;
}
.outline-list .outline-list {
padding-left: 14px;
}
.outline-list summary {
margin-left: 14px;
color: var(--text-muted);
cursor: pointer;
list-style-position: outside;
}
.outline-leaf {
padding-left: 14px;
}
.outline-link {
display: inline-flex;
align-items: baseline;
gap: 8px;
width: 100%;
min-height: 30px;
height: auto;
padding: 5px 4px;
border: 0;
background: transparent;
font-size: 13px;
line-height: 1.5;
text-align: left;
vertical-align: top;
}
.outline-link > :first-child {
flex: 1;
min-width: 0;
overflow-wrap: anywhere;
}
.outline-page {
flex-shrink: 0;
color: var(--text-muted);
font-size: 12px;
font-variant-numeric: tabular-nums;
}
button.outline-link:hover {
background: #e4f3ef;
color: var(--accent-strong);
}
.page-list {
display: grid;
gap: 8px;
@@ -470,6 +548,8 @@ body[data-hide-meta] .meta-panel {
}
.empty-state[hidden],
.navigation-tabs[hidden],
.page-list[hidden],
.page-frame[hidden],
.progress-panel[hidden] {
display: none;
+80
View File
@@ -93,7 +93,12 @@ const el = {
viewerPanel: document.querySelector(".viewer-panel"),
svgHost: document.querySelector("#svgHost"),
pageListPanel: document.querySelector(".page-list-panel"),
pageListTitle: document.querySelector("#pageListTitle"),
navigationTabs: document.querySelector("#navigationTabs"),
pagesTab: document.querySelector("#pagesTab"),
outlinesTab: document.querySelector("#outlinesTab"),
pageList: document.querySelector("#pageList"),
outlineList: document.querySelector("#outlineList"),
metaPanel: document.querySelector(".meta-panel"),
appPanel: document.querySelector("#appPanel"),
offlineStatus: document.querySelector("#offlineStatus"),
@@ -119,6 +124,16 @@ const el = {
el.ofdButton.addEventListener("click", openOFDFile);
el.togglePagesButton.addEventListener("click", () => toggleSidebar("pages"));
el.toggleMetaButton.addEventListener("click", () => toggleSidebar("meta"));
el.pagesTab.addEventListener("click", () => showOutlines(false));
el.outlinesTab.addEventListener("click", () => showOutlines(true));
el.navigationTabs.addEventListener("keydown", (event) => {
if (["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) {
event.preventDefault();
const outlines = event.key === "End" || (event.key !== "Home" && el.outlineList.hidden);
showOutlines(outlines);
(outlines ? el.outlinesTab : el.pagesTab).focus();
}
});
el.fontAddButton.addEventListener("click", () => openFontFile(el.fontInput));
el.fontDirectoryButton.addEventListener("click", () => openFontFile(el.fontDirectoryInput));
el.localFontButton.addEventListener("click", loadLocalFonts);
@@ -1178,6 +1193,9 @@ async function openDocument(options = {}) {
}
resetPageFlow();
renderPageList();
if (options.resetScroll || el.outlineList.childElementCount === 0) {
renderOutlines();
}
renderMeta();
renderPageFlow();
if (options.resetScroll) {
@@ -1748,6 +1766,68 @@ function prefixSVGIds(svg, prefix) {
}
}
function showOutlines(show) {
el.pageList.hidden = show;
el.outlineList.hidden = !show;
el.pagesTab.setAttribute("aria-selected", String(!show));
el.outlinesTab.setAttribute("aria-selected", String(show));
el.pagesTab.tabIndex = show ? -1 : 0;
el.outlinesTab.tabIndex = show ? 0 : -1;
el.pageListPanel.scrollTop = 0;
}
function renderOutlines() {
const outlines = state.doc.outlines || [];
el.pageListTitle.hidden = outlines.length > 0;
el.navigationTabs.hidden = outlines.length === 0;
el.outlineList.replaceChildren();
if (outlines.length > 0) {
el.outlineList.append(createOutlineList(outlines));
}
showOutlines(false);
}
function createOutlineList(outlines) {
const list = document.createElement("ul");
list.className = "outline-list";
for (const outline of outlines) {
const item = document.createElement("li");
const hasChildren = outline.children?.length > 0;
const row = document.createElement(hasChildren ? "summary" : "div");
if (!hasChildren) {
row.className = "outline-leaf";
}
const link = document.createElement(outline.page ? "button" : "span");
link.className = "outline-link";
const title = document.createElement("span");
title.textContent = outline.title;
link.append(title);
if (outline.page) {
link.type = "button";
const page = document.createElement("span");
page.className = "outline-page";
page.textContent = String(outline.page);
page.setAttribute("aria-label", `第 ${outline.page} 页`);
link.append(page);
link.addEventListener("click", (event) => {
event.preventDefault();
renderPage(outline.page - 1);
});
}
row.append(link);
if (hasChildren) {
const details = document.createElement("details");
details.open = outline.expanded;
details.append(row, createOutlineList(outline.children));
item.append(details);
} else {
item.append(row);
}
list.append(item);
}
return list;
}
function renderPageList() {
el.pageList.replaceChildren();
if (!state.doc) {
+6
View File
@@ -127,9 +127,13 @@ type DocumentInfo struct {
Fonts []FontInfo `json:"fonts"`
Signatures []SignatureInfo `json:"signatures"`
Pages []PageInfo `json:"pages"`
Outlines []OutlineInfo `json:"outlines,omitempty"`
DetailsPending bool `json:"detailsPending,omitempty"`
}
// OutlineInfo 目录节点信息
type OutlineInfo = ofdgo.OutlineInfo
// PageInfo 页面信息
type PageInfo struct {
Index int `json:"index"`
@@ -257,6 +261,7 @@ func (s *Session) Summary() DocumentInfo {
DocType: s.Reader.DocType(),
PageCount: len(s.doc.Pages.Page),
Pages: make([]PageInfo, 0, len(s.doc.Pages.Page)),
Outlines: s.doc.OutlineInfos(),
DetailsPending: true,
}
if docInfo, err := s.Reader.DocInfo(); err == nil && docInfo != nil {
@@ -300,6 +305,7 @@ func (s *Session) Info() DocumentInfo {
DocType: s.Reader.DocType(),
PageCount: len(s.doc.Pages.Page),
Pages: make([]PageInfo, 0, len(s.doc.Pages.Page)),
Outlines: s.doc.OutlineInfos(),
}
if signatures, err := s.signatureInfos(); err == nil {
info.Signatures = signatures
+15
View File
@@ -102,6 +102,21 @@ type RegionCommand struct {
EndPoint string
}
// gotoDest 获取文档内跳转目标
// 入参: action 跳转动作, bookmarks 书签
// 返回: *Dest 跳转目标
func gotoDest(action *Goto, bookmarks map[string]Dest) *Dest {
if action.Dest != nil {
return action.Dest
}
if action.Bookmark != nil {
if dest, ok := bookmarks[action.Bookmark.Name]; ok {
return &dest
}
}
return nil
}
// UnmarshalXML 解析跳转目标
// 入参: d XML解码器, start 起始节点
// 返回: error 错误信息
+50
View File
@@ -161,6 +161,14 @@ type OutlineElem struct {
OutlineElem []OutlineElem `xml:"OutlineElem"`
}
// OutlineInfo 目录节点信息,Page从1开始,0表示没有有效的文档内目标页
type OutlineInfo struct {
Title string `json:"title"`
Expanded bool `json:"expanded"`
Page int `json:"page,omitempty"`
Children []OutlineInfo `json:"children,omitempty"`
}
// Permissions 权限声明
type Permissions struct {
Edit bool `xml:"Edit"`
@@ -182,6 +190,48 @@ type ValidPeriod struct {
EndDate string `xml:"EndDate,attr"`
}
// OutlineInfos 获取目录层级和目标页码
// 返回: []OutlineInfo 目录节点信息
func (doc *Document) OutlineInfos() []OutlineInfo {
if len(doc.Outlines.OutlineElem) == 0 {
return nil
}
bookmarks := make(map[string]Dest, len(doc.Bookmarks.Bookmark))
for _, bookmark := range doc.Bookmarks.Bookmark {
bookmarks[bookmark.Name] = bookmark.Dest
}
pages := make(map[string]int, len(doc.Pages.Page))
for index, page := range doc.Pages.Page {
pages[page.ID] = index + 1
}
return outlineInfos(doc.Outlines.OutlineElem, bookmarks, pages)
}
// outlineInfos 转换目录节点信息
// 入参: outlines 大纲节点, bookmarks 书签, pages 页码索引
// 返回: []OutlineInfo 目录节点信息
func outlineInfos(outlines []OutlineElem, bookmarks map[string]Dest, pages map[string]int) []OutlineInfo {
infos := make([]OutlineInfo, 0, len(outlines))
for _, outline := range outlines {
info := OutlineInfo{Title: outline.Title, Expanded: outline.Expanded}
for _, action := range outline.Actions {
if action.Goto != nil {
if dest := gotoDest(action.Goto, bookmarks); dest != nil {
info.Page = pages[dest.PageID]
if info.Page != 0 {
break
}
}
}
}
if len(outline.OutlineElem) > 0 {
info.Children = outlineInfos(outline.OutlineElem, bookmarks, pages)
}
infos = append(infos, info)
}
return infos
}
// UnmarshalXML 解析文档并应用权限默认值
// 入参: d XML解码器, start 起始节点
// 返回: error 错误信息
-15
View File
@@ -235,21 +235,6 @@ func appendGraphicActionSources(sources []pdfActionSource, object GraphicObject,
return sources
}
// gotoDest 获取文档内跳转目标
// 入参: action 跳转动作, bookmarks 书签
// 返回: *Dest 跳转目标
func gotoDest(action *Goto, bookmarks map[string]Dest) *Dest {
if action.Dest != nil {
return action.Dest
}
if action.Bookmark != nil {
if dest, ok := bookmarks[action.Bookmark.Name]; ok {
return &dest
}
}
return nil
}
// outlineDest 获取大纲跳转目标
// 入参: outline 大纲节点, bookmarks 书签
// 返回: *Dest 跳转目标