diff --git a/assets/webui/index.html b/assets/webui/index.html
index 2a53083..2d803e9 100644
--- a/assets/webui/index.html
+++ b/assets/webui/index.html
@@ -46,6 +46,7 @@
+
diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js
index 7875fb2..051bbd4 100644
--- a/assets/webui/ofdgo.js
+++ b/assets/webui/ofdgo.js
@@ -7,6 +7,7 @@ const COMPACT_LAYOUT = window.matchMedia("(max-width: 900px)");
const DEFAULT_IMAGE_DPI = 300;
const PAGE_CACHE_LIMIT = 16;
const PAGE_CACHE_BYTES = 32 * 1024 * 1024;
+const PAGE_LAYOUT_GAP = 16;
const SCROLL_SEGMENT_SIZE = 1000000;
const OBJECT_CLIPBOARD_TYPE = "application/x-ofdgo-objects";
const STATUS = {
@@ -121,6 +122,7 @@ const state = {
pan: null,
fitMode: "width",
continuous: false,
+ pageLayout: 1,
renderAnnotations: true,
renderBackend: "canvas",
renderMode: "svg",
@@ -474,6 +476,7 @@ const el = {
rotateButton: document.querySelector("#rotateButton"),
panButton: document.querySelector("#panButton"),
continuousButton: document.querySelector("#continuousButton"),
+ pageLayoutButton: document.querySelector("#pageLayoutButton"),
annotationButton: document.querySelector("#annotationButton"),
imageDPI: document.querySelector("#imageDPI"),
outputCompression: document.querySelector("#outputCompression"),
@@ -618,7 +621,11 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, {
busy: () => document.body.hasAttribute("data-busy"),
rotation: () => state.rotation,
canInsert: (index) => pageCan("insert", index),
+ onPage: (index) => {
+ if (index !== state.pageIndex) setCurrentPage(index);
+ },
onSelect: (item, previous) => {
+ if (item && item.index !== state.pageIndex) setCurrentPage(item.index);
updateObjectControls(item);
const reason = objectEditReason(item), previousReason = objectEditReason(previous);
if (state.editing && state.doc && !document.body.hasAttribute("data-busy")
@@ -1609,11 +1616,11 @@ el.localFontButton.addEventListener("click", loadLocalFonts);
el.ofdInput.addEventListener("change", () => openOFD(el.ofdInput.files[0]));
el.fontInput.addEventListener("change", openSelectedFonts);
el.fontDirectoryInput.addEventListener("change", openSelectedFonts);
-el.prevButton.addEventListener("click", () => renderPage(state.pageIndex - 1));
+el.prevButton.addEventListener("click", () => renderPage(pageGroupStart() - state.pageLayout));
el.imageDPI.addEventListener("change", () => changeDisplayMode());
el.renderVectorButton.addEventListener("click", () => changeDisplayMode("svg"));
el.renderRasterButton.addEventListener("click", () => changeDisplayMode("raster"));
-el.nextButton.addEventListener("click", () => renderPage(state.pageIndex + 1));
+el.nextButton.addEventListener("click", () => renderPage(pageGroupStart() + state.pageLayout));
el.zoomOutButton.addEventListener("click", () => setScale(state.scale - 0.1));
el.zoomInButton.addEventListener("click", () => setScale(state.scale + 0.1));
el.fitButton.addEventListener("click", fitWidth);
@@ -1621,6 +1628,7 @@ el.fitHeightButton.addEventListener("click", fitHeight);
el.rotateButton.addEventListener("click", rotatePages);
el.panButton.addEventListener("click", togglePan);
el.continuousButton.addEventListener("click", toggleContinuous);
+el.pageLayoutButton.addEventListener("click", togglePageLayout);
el.annotationButton.addEventListener("click", toggleAnnotations);
document.addEventListener("copy", copySelection);
document.addEventListener("cut", copySelection);
@@ -1704,6 +1712,11 @@ for (const [viewport, current] of [[el.viewerPanel, () => state.pageWindow], [el
});
}
el.viewerPanel.addEventListener("pointerdown", startPan);
+el.viewerPanel.addEventListener("pointerdown", event => {
+ if (event.button !== 0 || state.editing || document.body.hasAttribute("data-busy")) return;
+ const shell = event.target.closest(".page-shell");
+ if (shell && state.doc) setCurrentPage(Number(shell.dataset.pageIndex));
+});
el.viewerPanel.addEventListener("pointermove", movePan);
el.viewerPanel.addEventListener("pointerup", endPan);
el.viewerPanel.addEventListener("pointercancel", endPan);
@@ -5435,7 +5448,7 @@ async function openDocument(options = {}) {
state.rotation = 0;
setContinuous(window.matchMedia("(max-width: 640px)").matches);
}
- state.fitMode = options.fitMode || (!state.continuous && pageCount === 1 ? "height" : "width");
+ state.fitMode = options.fitMode || (state.pageLayout > 1 || !state.continuous && pageCount === 1 ? "height" : "width");
if (!options.skipAutoFonts && await autoLoadDocumentLocalFonts(openSeq)) {
if (openSeq !== state.openSeq) {
return;
@@ -5594,6 +5607,9 @@ async function renderPage(index, options = {}) {
if (index < 0 || index >= pageCount) {
return null;
}
+ if (!options.keepBusy && index !== state.pageIndex) {
+ if (!await canvasEditor.commitNudge() || !await canvasEditor.commitText() || !await canvasEditor.commitCrop() || openSeq !== state.openSeq) return null;
+ }
const pageSeq = ++state.pageSeq;
if (!state.exporting) {
if (options.keepBusy) {
@@ -8851,8 +8867,12 @@ function syncPageWindow(view, target = null) {
view.scrollTop = viewport.scrollTop;
view.logicalTop = logical;
view.origin = logical - viewport.scrollTop + inset;
- const first = virtualIndex(offsets, Math.max(view.origin, logical - view.overscan));
- const last = virtualIndex(offsets, Math.min(view.origin + extent, logical + height + view.overscan));
+ let first = virtualIndex(offsets, Math.max(view.origin, logical - view.overscan));
+ let last = virtualIndex(offsets, Math.min(view.origin + extent, logical + height + view.overscan));
+ if (view.columns) {
+ first -= first % view.columns;
+ last = Math.min(offsets.length - 2, last + view.columns - 1 - last % view.columns);
+ }
const selection = host === el.svgHost && state.documentSelection ? document.getSelection() : null;
const selected = selection?.rangeCount && isDocumentSelection(selection.getRangeAt(0));
let changed = false;
@@ -8875,7 +8895,11 @@ function syncPageWindow(view, target = null) {
}
for (const [index, node] of nodes) {
const top = offsets[index] - view.origin;
- const itemHeight = offsets[index + 1] - offsets[index] - view.gap;
+ const itemHeight = view.heights?.[index] ?? offsets[index + 1] - offsets[index] - view.gap;
+ if (view.lefts) {
+ node.style.left = `${view.lefts[index]}px`;
+ node.style.transform = "none";
+ }
node.style.top = `${Math.max(-itemHeight, Math.min(extent, top))}px`;
node.style.visibility = top + itemHeight < 0 || top >= extent ? "hidden" : "";
}
@@ -8890,7 +8914,7 @@ function pageWindowAnchor(view) {
}
function releaseFlowShell(shell, index) {
- if (shell.contains(document.activeElement) || canvasEditor.drag?.item?.index === index) return false;
+ if (shell.contains(document.activeElement) || canvasEditor.drag?.item?.index === index || canvasEditor.drag?.page?.index === index) return false;
unmountPage(index);
if (state.selectedPages.has(index)) return false;
state.pageObserver?.unobserve(shell);
@@ -9825,7 +9849,8 @@ function markFlowPageError(index, err) {
}
function queueNearbyPages(index, openSeq = state.openSeq) {
- for (let i = Math.max(0, index - 1); i <= Math.min((state.doc?.pageCount || 1) - 1, index + 2); i += 1) {
+ const start = pageGroupStart(index);
+ for (let i = Math.max(0, start - 1); i <= Math.min((state.doc?.pageCount || 1) - 1, start + state.pageLayout + 1); i += 1) {
renderFlowPage(i, { openSeq, priority: 2 });
}
}
@@ -9835,6 +9860,21 @@ function pageShell(index) {
}
function scrollToPage(index) {
+ if (state.pageLayout > 1 && state.pageWindow) {
+ const view = state.pageWindow, start = pageGroupStart(index);
+ const size = pageGroupSize(start), gap = PAGE_LAYOUT_GAP;
+ const height = size.height * MM_TO_PX * state.scale + (size.rows - 1) * gap;
+ const whole = height <= el.viewerPanel.clientHeight - pageBlockSpace() * 2 + 1;
+ const space = state.fitMode === "height" && !state.continuous
+ ? Math.max(pageBlockSpace(), (el.viewerPanel.clientHeight - height) / 2) : pageBlockSpace();
+ syncPageWindow(view, view.offsets[whole ? start : index] - space);
+ el.viewerPanel.scrollLeft = Math.max(0, (el.viewerPanel.scrollWidth - el.viewerPanel.clientWidth) / 2);
+ if (size.width * MM_TO_PX * state.scale + gap > el.viewerPanel.clientWidth) {
+ const shell = pageShell(index);
+ if (shell) centerPageInline(shell);
+ }
+ return;
+ }
if (state.pageWindow) syncPageWindow(state.pageWindow, state.pageWindow.offsets[index]);
const shell = pageShell(index);
if (shell) {
@@ -9875,6 +9915,7 @@ function schedulePageSync() {
function syncCurrentPageFromScroll() {
if (state.openActionTask?.navigating) return;
syncPageWindow(state.pageWindow);
+ if (canvasEditor.input || canvasEditor.drag || canvasEditor.crop) return;
const shell = pageShellFromView();
if (!shell) {
return;
@@ -9900,6 +9941,19 @@ function prunePageRenderQueue() {
function pageShellFromView() {
const rect = el.viewerPanel.getBoundingClientRect();
+ if (state.pageLayout > 1) {
+ let best = null, area = 0;
+ const current = pageShell(state.pageIndex);
+ for (const shell of [current, ...(state.pageWindow?.nodes.values() || [])]) {
+ if (!shell || shell.style.visibility === "hidden") continue;
+ const bounds = shell.getBoundingClientRect();
+ const visible = Math.max(0, Math.min(rect.right, bounds.right) - Math.max(rect.left, bounds.left))
+ * Math.max(0, Math.min(rect.bottom, bounds.bottom) - Math.max(rect.top, bounds.top));
+ const ratio = visible / Math.max(1, bounds.width * bounds.height);
+ if (ratio > area + 0.01) { best = shell; area = ratio; }
+ }
+ return best;
+ }
const x = rect.left + rect.width / 2;
if (el.viewerPanel.scrollTop + el.viewerPanel.clientHeight >= el.viewerPanel.scrollHeight - 1) {
const shell = pageShell(state.pageIndex);
@@ -9924,7 +9978,7 @@ function setCurrentPage(index) {
if (state.composite && state.composite.index !== index) resetCompositeScope();
state.pageIndex = index;
if (state.editing) state.openActionPage = state.doc?.pages[index]?.id;
- if (state.fitMode === "width") {
+ if (state.fitMode === "width" && state.pageLayout === 1) {
state.scale = fitWidthScale(currentPageInfo());
el.zoomLabel.textContent = `${Math.round(state.scale * 100)}%`;
}
@@ -9971,6 +10025,37 @@ function setPageItemCurrent(item, current) {
item.removeAttribute("data-current");
}
+function pageGroupStart(index = state.pageIndex) {
+ return Math.floor(index / state.pageLayout) * state.pageLayout;
+}
+
+function pageGroupSize(index = state.pageIndex) {
+ const start = pageGroupStart(index), rows = state.pageLayout === 4 ? 2 : 1;
+ const widths = [0, 0], heights = Array(rows).fill(0);
+ for (let offset = 0; offset < state.pageLayout; offset++) {
+ const page = state.doc.pages[start + offset];
+ if (!page) continue;
+ const size = pageViewSize(page);
+ widths[offset % 2] = Math.max(widths[offset % 2], size.width);
+ heights[Math.floor(offset / 2)] = Math.max(heights[Math.floor(offset / 2)], size.height);
+ }
+ if (!widths[1]) widths[1] = widths[0];
+ for (let row = 0; row < rows; row++) if (!heights[row]) heights[row] = heights[0];
+ return { widths, heights, rows, width: widths[0] + widths[1], height: heights.reduce((sum, height) => sum + height, 0) };
+}
+
+async function togglePageLayout() {
+ if (!state.doc || document.body.hasAttribute("data-busy")) return;
+ const openSeq = state.openSeq;
+ if (!await canvasEditor.commitNudge() || !await canvasEditor.commitText() || !await canvasEditor.commitCrop() || openSeq !== state.openSeq) return;
+ state.pageLayout = state.pageLayout === 1 ? 2 : state.pageLayout === 2 ? 4 : 1;
+ el.pageLayoutButton.textContent = { 1: "一页", 2: "两页", 4: "四页" }[state.pageLayout];
+ el.pageLayoutButton.dataset.short = { 1: "一", 2: "两", 4: "四" }[state.pageLayout];
+ layoutPages();
+ fitHeight();
+ queueNearbyPages(state.pageIndex);
+}
+
function layoutPages() {
if (!state.doc) {
return;
@@ -9979,7 +10064,53 @@ function layoutPages() {
const view = state.pageWindow;
if (!view) return;
const anchor = view.offsets.length > 1 ? pageWindowAnchor(view) : { index: state.pageIndex, offset: 0 };
- const gap = Number.parseFloat(getComputedStyle(el.svgHost).rowGap) || 0;
+ const gap = Math.max(state.pageLayout > 1 ? PAGE_LAYOUT_GAP : 0, Number.parseFloat(getComputedStyle(el.svgHost).rowGap) || 0);
+ if (state.pageLayout > 1) {
+ const count = state.doc.pages.length, spacing = PAGE_LAYOUT_GAP;
+ view.columns = 2;
+ view.offsets = new Float64Array(count + 1);
+ view.heights = new Float64Array(count);
+ view.lefts = new Float64Array(count);
+ let top = 0, width = 0;
+ const groups = [];
+ for (let start = 0; start < count; start += state.pageLayout) {
+ const size = pageGroupSize(start), factor = MM_TO_PX * state.scale;
+ const groupWidth = size.width * factor + spacing;
+ groups.push({ start, size, width: groupWidth });
+ width = Math.max(width, groupWidth);
+ for (let row = 0; row < size.rows; row++) {
+ for (let column = 0; column < 2; column++) {
+ const index = start + row * 2 + column;
+ if (index >= count) break;
+ view.offsets[index] = top;
+ view.heights[index] = pageViewSize(state.doc.pages[index]).height * factor;
+ }
+ top += size.heights[row] * factor + (row + 1 === size.rows ? gap : spacing);
+ }
+ }
+ for (const group of groups) {
+ for (let index = group.start; index < Math.min(count, group.start + state.pageLayout); index++) {
+ const column = (index - group.start) % 2, factor = MM_TO_PX * state.scale;
+ view.lefts[index] = (width - group.width) / 2
+ + (column ? group.size.widths[0] * factor + spacing : 0)
+ + (group.size.widths[column] - pageViewSize(state.doc.pages[index]).width) * factor / 2;
+ }
+ }
+ view.offsets[count] = top;
+ view.gap = gap;
+ view.total = Math.max(0, top - gap);
+ view.host.style.width = `${width}px`;
+ for (const [index, shell] of view.nodes) layoutPageShell(shell, state.doc.pages[index]);
+ syncPageWindow(view, view.offsets[Math.min(anchor.index, count - 1)] + anchor.offset);
+ return;
+ }
+ view.columns = 0;
+ view.heights = null;
+ view.lefts = null;
+ for (const shell of view.nodes.values()) {
+ shell.style.removeProperty("left");
+ shell.style.removeProperty("transform");
+ }
const offsets = new Float64Array(state.doc.pages.length + 1);
const fittedWidth = state.scale * pageViewSize(currentPageInfo()).width;
let width = 1;
@@ -10003,7 +10134,7 @@ function layoutPageShell(shell, page) {
const size = pageViewSize(page);
const viewWidth = Math.max(1, size.width * MM_TO_PX);
const viewHeight = Math.max(1, size.height * MM_TO_PX);
- const scale = state.fitMode === "width" ? state.scale * pageViewSize(currentPageInfo()).width / size.width : state.scale;
+ const scale = state.fitMode === "width" && state.pageLayout === 1 ? state.scale * pageViewSize(currentPageInfo()).width / size.width : state.scale;
shell.style.width = `${viewWidth * scale}px`;
shell.style.height = `${viewHeight * scale}px`;
const surface = shell.querySelector(".page-surface");
@@ -11917,6 +12048,10 @@ function fitWidth(updateStatus = true) {
}
function fitWidthScale(page) {
+ if (state.pageLayout > 1) {
+ const size = pageGroupSize();
+ return Math.min(4, Math.max(1, el.viewerPanel.clientWidth - pageSpace() * 2 - PAGE_LAYOUT_GAP - scrollbarWidth()) / (size.width * MM_TO_PX));
+ }
page = pageViewSize(page);
const space = pageSpace();
const width = Math.max(1, page.width * MM_TO_PX);
@@ -11945,6 +12080,14 @@ function fitHeight(updateStatus = true) {
if (!page) {
return;
}
+ if (state.pageLayout > 1) {
+ const size = pageGroupSize(), space = pageSpace(), gap = PAGE_LAYOUT_GAP;
+ const width = Math.max(1, el.viewerPanel.clientWidth - space * 2 - gap - scrollbarWidth());
+ const height = Math.max(1, el.viewerPanel.clientHeight - space * 2 - (size.rows - 1) * gap);
+ setScale(Math.min(width / (size.width * MM_TO_PX), height / (size.height * MM_TO_PX)), updateStatus, "height");
+ if (updateStatus) scrollToPage(state.pageIndex);
+ return;
+ }
const space = pageSpace();
let availableWidth = Math.max(1, el.viewerPanel.clientWidth - space * 2);
let availableHeight = Math.max(1, el.viewerPanel.clientHeight - space * 2);
@@ -12001,7 +12144,8 @@ function setScale(nextScale, updateStatus = true, fitMode = "free") {
layoutPages();
}
updateFitSpace();
- if (state.pageWindow && state.pageWindow.gap !== (Number.parseFloat(getComputedStyle(el.svgHost).rowGap) || 0)) layoutPages();
+ const gap = Math.max(state.pageLayout > 1 ? PAGE_LAYOUT_GAP : 0, Number.parseFloat(getComputedStyle(el.svgHost).rowGap) || 0);
+ if (state.pageWindow && state.pageWindow.gap !== gap) layoutPages();
if (layoutChanged) {
restoreScaleAnchor(anchor);
}
@@ -12037,7 +12181,7 @@ function restoreScaleAnchor(anchor) {
}
if (state.pageWindow) {
const view = state.pageWindow;
- const height = view.offsets[anchor.index + 1] - view.offsets[anchor.index] - view.gap;
+ const height = view.heights?.[anchor.index] ?? view.offsets[anchor.index + 1] - view.offsets[anchor.index] - view.gap;
syncPageWindow(view, view.offsets[anchor.index] + height * anchor.y - el.viewerPanel.clientHeight * anchor.viewY);
}
const shell = pageShell(anchor.index);
@@ -12091,7 +12235,9 @@ function updateFitSpace() {
return;
}
const shell = pageShell(state.pageIndex);
- const height = shell ? shell.getBoundingClientRect().height : Math.max(1, pageViewSize(page).height * MM_TO_PX * state.scale);
+ const group = state.pageLayout > 1 ? pageGroupSize() : null;
+ const height = group ? group.height * MM_TO_PX * state.scale + (group.rows - 1) * PAGE_LAYOUT_GAP
+ : shell ? shell.getBoundingClientRect().height : Math.max(1, pageViewSize(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 = state.continuous ? 0 : Math.max(base, (el.viewerPanel.clientHeight - height) / 2);
@@ -12153,8 +12299,8 @@ function updateControls() {
updateDisplayControls();
updateEditorTools();
const pageCount = state.doc ? state.doc.pageCount : 0;
- el.prevButton.disabled = !hasDoc || state.pageIndex <= 0;
- el.nextButton.disabled = !hasDoc || state.pageIndex >= pageCount - 1;
+ el.prevButton.disabled = !hasDoc || pageGroupStart() === 0;
+ el.nextButton.disabled = !hasDoc || pageGroupStart() + state.pageLayout >= pageCount;
el.pageInput.disabled = !hasDoc;
el.pageInput.max = String(pageCount || 1);
el.pageInput.value = String(hasDoc ? state.pageIndex + 1 : 0);
@@ -12166,6 +12312,7 @@ function updateControls() {
el.rotateButton.disabled = !hasDoc;
el.panButton.disabled = !hasDoc;
el.continuousButton.disabled = !hasDoc;
+ el.pageLayoutButton.disabled = !hasDoc || document.body.hasAttribute("data-busy");
el.fitButton.toggleAttribute("data-pressed", hasDoc && state.fitMode === "width");
el.fitHeightButton.toggleAttribute("data-pressed", hasDoc && state.fitMode === "height");
updateAnnotationButton();
@@ -12373,6 +12520,7 @@ function showError(err, empty = !state.doc) {
function setBusy(busy, text = "", percent = 0, status = "") {
document.body.toggleAttribute("data-busy", busy);
+ el.pageLayoutButton.disabled = busy || !state.doc;
el.closeButton.disabled = busy || !state.doc || state.exporting || state.saving;
el.refreshAppButton.disabled = busy;
updateStructureControls();
diff --git a/assets/webui/ofdgo_edit.js b/assets/webui/ofdgo_edit.js
index 5bc4e62..3e3ad1d 100644
--- a/assets/webui/ofdgo_edit.js
+++ b/assets/webui/ofdgo_edit.js
@@ -838,7 +838,7 @@ export class CanvasEditor {
if (this.input.composing) this.commitText();
else {
event.preventDefault();
- this.selectAfterText(this.targetAt(event));
+ this.selectAfterText(this.targetAt(event), this.pages.get(event.target.closest(".page-surface"))?.index);
}
}
return;
@@ -860,6 +860,8 @@ export class CanvasEditor {
return;
}
if (this.tool) {
+ const page = this.pages.get(event.target.closest(".page-surface"));
+ if (page) this.options.onPage?.(page.index);
if (this.tool.startsWith("erase-")) this.startErase(event);
else if (this.tool.startsWith("pen-")) this.startInk(event);
else this.startShape(event);
@@ -870,6 +872,8 @@ export class CanvasEditor {
return;
}
const target = this.targetAt(event), node = target?.node;
+ const page = this.pages.get(event.target.closest(".page-surface"));
+ if (page) this.options.onPage?.(page.index);
if (event.target.closest(".edit-object, .edit-selection") || target) event.preventDefault();
this.focus();
const additive = event.shiftKey || event.ctrlKey || event.metaKey || this.multiple;
@@ -939,12 +943,13 @@ export class CanvasEditor {
: this.contains(item, point, tolerance));
}
- async selectAfterText(target) {
+ async selectAfterText(target, index) {
if (!await this.commitText() || this.input || !this.enabled || this.options.busy() || this.tool) return;
+ if (index !== undefined) this.options.onPage?.(index);
if (target) {
const item = this.pageItems(target.index).find(item => item.id === target.id);
if (item) this.select(item);
- }
+ } else if (index !== undefined) this.select(null);
this.focus();
}