diff --git a/assets/webui/index.html b/assets/webui/index.html
index 99d1361..f80c3eb 100644
--- a/assets/webui/index.html
+++ b/assets/webui/index.html
@@ -33,6 +33,7 @@
+
diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css
index 50e94db..07b4266 100644
--- a/assets/webui/ofdgo.css
+++ b/assets/webui/ofdgo.css
@@ -513,6 +513,11 @@ body[data-hide-meta] .meta-panel {
padding-block: var(--fit-space, var(--page-space));
}
+.page-frame.continuous {
+ --page-space: 0px;
+ align-items: start;
+}
+
.svg-host {
display: grid;
justify-items: center;
@@ -529,6 +534,11 @@ body[data-hide-meta] .meta-panel {
background: var(--page);
}
+.page-frame.continuous .page-shell {
+ border: 0;
+ box-shadow: none;
+}
+
.page-shell.error {
background: #fff8f7;
}
@@ -910,6 +920,7 @@ body[data-hide-meta] .meta-panel {
}
.app-footer {
+ z-index: 12;
justify-content: space-between;
overflow: hidden;
border-top: 1px solid var(--line);
diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js
index e5c1339..a99d0c6 100644
--- a/assets/webui/ofdgo.js
+++ b/assets/webui/ofdgo.js
@@ -62,6 +62,7 @@ const state = {
pageIndex: 0,
scale: 1,
fitMode: "width",
+ continuous: false,
renderAnnotations: true,
pageCache: new Map(),
pageInFlight: new Map(),
@@ -98,6 +99,7 @@ const el = {
zoomLabel: document.querySelector("#zoomLabel"),
fitButton: document.querySelector("#fitButton"),
fitHeightButton: document.querySelector("#fitHeightButton"),
+ continuousButton: document.querySelector("#continuousButton"),
annotationButton: document.querySelector("#annotationButton"),
imageDPI: document.querySelector("#imageDPI"),
pageExportFormat: document.querySelector("#pageExportFormat"),
@@ -145,6 +147,7 @@ el.zoomOutButton.addEventListener("click", () => setScale(state.scale - 0.1));
el.zoomInButton.addEventListener("click", () => setScale(state.scale + 0.1));
el.fitButton.addEventListener("click", fitWidth);
el.fitHeightButton.addEventListener("click", fitHeight);
+el.continuousButton.addEventListener("click", toggleContinuous);
el.annotationButton.addEventListener("click", toggleAnnotations);
el.pageExportFormat.addEventListener("change", () => updateDPIControl());
el.exportPageButton.addEventListener("click", exportCurrentPage);
@@ -1121,6 +1124,7 @@ async function exportCurrentPage() {
function renderPageFlow() {
el.svgHost.replaceChildren();
+ el.viewerPanel.classList.remove("single-page-fits-height");
if (!state.doc) {
return;
}
@@ -1374,7 +1378,7 @@ function pageShell(index) {
function scrollToPage(index) {
const shell = pageShell(index);
if (shell) {
- if (state.fitMode === "height") {
+ if (state.fitMode === "height" && !state.continuous) {
shell.scrollIntoView({ block: "center", inline: "nearest" });
} else {
const viewerRect = el.viewerPanel.getBoundingClientRect();
@@ -1418,6 +1422,16 @@ function syncCurrentPageFromScroll() {
function pageShellFromView() {
const rect = el.viewerPanel.getBoundingClientRect();
const x = rect.left + rect.width / 2;
+ if (state.continuous) {
+ if (el.viewerPanel.scrollTop + el.viewerPanel.clientHeight >= el.viewerPanel.scrollHeight - 1) {
+ // 末尾无法顶对齐时保留完整可见的当前页
+ const shell = pageShell(state.pageIndex);
+ const bounds = shell?.getBoundingClientRect();
+ return bounds && bounds.top >= rect.top - 1 && bounds.bottom <= rect.bottom + 1
+ ? shell : el.svgHost.lastElementChild;
+ }
+ return pageShellAtPoint(x, rect.top + 1);
+ }
return pageShellAtPoint(x, rect.top + rect.height * 0.45)
|| pageShellAtPoint(x, rect.top + rect.height * 0.25)
|| pageShellAtPoint(x, rect.top + rect.height * 0.65);
@@ -2069,6 +2083,20 @@ function fontMeta(font) {
return parts.join(" · ");
}
+function toggleContinuous() {
+ const anchor = state.fitMode === "free" ? scaleAnchor() : null;
+ state.continuous = !state.continuous;
+ el.pageFrame.classList.toggle("continuous", state.continuous);
+ el.continuousButton.setAttribute("aria-pressed", String(state.continuous));
+ clearStampHighlights();
+ if (state.fitMode === "free") {
+ restoreScaleAnchor(anchor);
+ } else {
+ applyFit(false);
+ scrollToPage(state.pageIndex);
+ }
+}
+
function fitWidth(updateStatus = true) {
const page = currentPageInfo();
if (!page) {
@@ -2083,10 +2111,14 @@ function fitWidth(updateStatus = true) {
function fitWidthScale(page) {
const space = pageSpace();
const width = Math.max(1, page.width * MM_TO_PX);
- const height = Math.max(1, page.height * MM_TO_PX);
let available = Math.max(1, el.viewerPanel.clientWidth - space * 2);
- if (!viewerHasVerticalScrollbar() && height * (available / width) > Math.max(1, el.viewerPanel.clientHeight - space * 2)) {
- available = Math.max(1, available - scrollbarWidth());
+ if (!viewerHasVerticalScrollbar()) {
+ const height = state.continuous
+ ? state.doc.pages.reduce((total, item) => total + available * item.height / item.width, 0)
+ : Math.max(1, page.height * MM_TO_PX) * (available / width);
+ if (height > Math.max(1, el.viewerPanel.clientHeight - space * 2)) {
+ available = Math.max(1, available - scrollbarWidth());
+ }
}
return Math.min(4, Math.max(0.2, available / width));
}
@@ -2097,10 +2129,32 @@ function fitHeight(updateStatus = true) {
return;
}
const space = pageSpace();
- const availableWidth = Math.max(1, el.viewerPanel.clientWidth - space * 2);
- const availableHeight = Math.max(1, el.viewerPanel.clientHeight - space * 2);
+ let availableWidth = Math.max(1, el.viewerPanel.clientWidth - space * 2);
+ let availableHeight = Math.max(1, el.viewerPanel.clientHeight - space * 2);
const width = Math.max(1, page.width * MM_TO_PX);
const height = Math.max(1, page.height * MM_TO_PX);
+ if (state.continuous) {
+ availableWidth = Math.max(1, el.viewerPanel.offsetWidth);
+ availableHeight = Math.max(1, el.viewerPanel.offsetHeight);
+ const contentWidth = state.doc.pages.reduce((max, item) => Math.max(max, item.width), 0) * MM_TO_PX;
+ const contentHeight = state.doc.pages.reduce((total, item) => total + item.height, 0) * MM_TO_PX;
+ const scale = Math.min(availableWidth / width, availableHeight / height);
+ const vertical = scale > availableHeight / contentHeight;
+ const horizontal = scale > availableWidth / contentWidth;
+ if (vertical || horizontal) {
+ const scrollbar = scrollbarWidth();
+ availableWidth = Math.max(1, availableWidth - (vertical ? scrollbar : 0));
+ availableHeight = Math.max(1, availableHeight - (horizontal ? scrollbar : 0));
+ // 一侧滚动条占用空间后,另一侧也可能需要滚动条
+ const nextScale = Math.min(availableWidth / width, availableHeight / height);
+ if (!vertical && nextScale > availableHeight / contentHeight) {
+ availableWidth = Math.max(1, availableWidth - scrollbar);
+ }
+ if (!horizontal && nextScale > availableWidth / contentWidth) {
+ availableHeight = Math.max(1, availableHeight - scrollbar);
+ }
+ }
+ }
setScale(Math.min(availableWidth / width, availableHeight / height), updateStatus, "height");
if (updateStatus) {
scrollToPage(state.pageIndex);
@@ -2120,8 +2174,7 @@ function applyFit(updateStatus = true) {
function setScale(nextScale, updateStatus = true, fitMode = "free") {
const anchor = updateStatus && fitMode === "free" ? scaleAnchor() : null;
const scale = Math.min(4, Math.max(0.2, nextScale));
- const scaleChanged = Math.abs(scale - state.scale) > 0.001;
- const layoutChanged = scaleChanged || fitMode !== state.fitMode;
+ const layoutChanged = scale !== state.scale || fitMode !== state.fitMode;
state.fitMode = fitMode;
state.scale = scale;
if (layoutChanged) {
@@ -2208,7 +2261,7 @@ function updateFitSpace() {
const height = shell ? shell.getBoundingClientRect().height : Math.max(1, page.height * MM_TO_PX * state.scale);
const base = pageSpace();
el.viewerPanel.classList.toggle("single-page-fits-height", state.doc.pageCount === 1 && height <= el.viewerPanel.clientHeight - base * 2);
- const space = Math.max(base, (el.viewerPanel.clientHeight - height) / 2);
+ const space = state.continuous ? 0 : Math.max(base, (el.viewerPanel.clientHeight - height) / 2);
const gap = space > base ? space + 1 : space;
el.pageFrame.style.setProperty("--fit-space", `${space}px`);
el.pageFrame.style.setProperty("--fit-gap", `${gap}px`);
@@ -2226,6 +2279,7 @@ function updateControls() {
el.zoomInButton.disabled = !hasDoc || state.scale >= 4;
el.fitButton.disabled = !hasDoc;
el.fitHeightButton.disabled = !hasDoc;
+ el.continuousButton.disabled = !hasDoc;
el.fitButton.toggleAttribute("aria-pressed", hasDoc && state.fitMode === "width");
el.fitHeightButton.toggleAttribute("aria-pressed", hasDoc && state.fitMode === "height");
updateAnnotationButton();