mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-30 01:27:26 +08:00
This commit is contained in:
1 parent
e3908c1149
commit
46e6d5b622
9 files changed
+582
-123
No files matched your search
+40
-2
@@ -568,7 +568,7 @@ button:not(:disabled):hover {
|
|||||||
|
|
||||||
.page-control {
|
.page-control {
|
||||||
display: inline-grid;
|
display: inline-grid;
|
||||||
grid-template-columns: 4ch auto 4ch;
|
grid-template-columns: max(4ch, var(--page-width, 4ch)) auto max(4ch, var(--page-width, 4ch));
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
height: var(--control-height);
|
height: var(--control-height);
|
||||||
@@ -1303,6 +1303,39 @@ button.outline-link:hover {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.virtual-pages {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
overflow-y: clip;
|
||||||
|
overflow-anchor: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-list.virtual-pages > .page-list-item,
|
||||||
|
.svg-host.virtual-pages > .page-shell {
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svg-host.virtual-pages > .page-shell {
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-list.virtual-pages > .page-list-item {
|
||||||
|
grid-template-rows: minmax(0, 1fr) 20px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-list.virtual-pages .thumb-paper {
|
||||||
|
aspect-ratio: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-list.virtual-pages .thumb-label {
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-list.virtual-pages .thumb-size {
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
.page-list-item {
|
.page-list-item {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
@@ -1398,6 +1431,7 @@ button.outline-link:hover {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
overflow-anchor: none;
|
||||||
background:
|
background:
|
||||||
linear-gradient(90deg, rgba(31, 36, 40, 0.05) 1px, transparent 1px),
|
linear-gradient(90deg, rgba(31, 36, 40, 0.05) 1px, transparent 1px),
|
||||||
linear-gradient(0deg, rgba(31, 36, 40, 0.05) 1px, transparent 1px),
|
linear-gradient(0deg, rgba(31, 36, 40, 0.05) 1px, transparent 1px),
|
||||||
@@ -1555,6 +1589,10 @@ button.outline-link:hover {
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.svg-host.virtual-pages {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
.page-shell {
|
.page-shell {
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -2613,7 +2651,7 @@ button.outline-link:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.page-control {
|
.page-control {
|
||||||
grid-template-columns: 3ch auto 3ch;
|
grid-template-columns: max(3ch, var(--page-width, 3ch)) auto max(3ch, var(--page-width, 3ch));
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding: 0 6px;
|
padding: 0 6px;
|
||||||
}
|
}
|
||||||
|
|||||||
+363
-66
@@ -6,6 +6,7 @@ const COMPACT_LAYOUT = window.matchMedia("(max-width: 900px)");
|
|||||||
const DEFAULT_IMAGE_DPI = 300;
|
const DEFAULT_IMAGE_DPI = 300;
|
||||||
const PAGE_CACHE_LIMIT = 16;
|
const PAGE_CACHE_LIMIT = 16;
|
||||||
const PAGE_CACHE_BYTES = 32 * 1024 * 1024;
|
const PAGE_CACHE_BYTES = 32 * 1024 * 1024;
|
||||||
|
const SCROLL_SEGMENT_SIZE = 1000000;
|
||||||
const OBJECT_CLIPBOARD_TYPE = "application/x-ofdgo-objects";
|
const OBJECT_CLIPBOARD_TYPE = "application/x-ofdgo-objects";
|
||||||
const STATUS = {
|
const STATUS = {
|
||||||
ready: "选择 OFD 文件",
|
ready: "选择 OFD 文件",
|
||||||
@@ -100,6 +101,11 @@ const state = {
|
|||||||
pageObserver: null,
|
pageObserver: null,
|
||||||
visiblePages: new Set(),
|
visiblePages: new Set(),
|
||||||
scrollFrame: 0,
|
scrollFrame: 0,
|
||||||
|
pageWindow: null,
|
||||||
|
thumbnailWindow: null,
|
||||||
|
thumbnailFrame: 0,
|
||||||
|
pageDragIndex: null,
|
||||||
|
pageListCurrent: null,
|
||||||
thumbnailInFlight: new Set(),
|
thumbnailInFlight: new Set(),
|
||||||
thumbnailObserver: null,
|
thumbnailObserver: null,
|
||||||
visibleThumbnails: new Set(),
|
visibleThumbnails: new Set(),
|
||||||
@@ -334,6 +340,7 @@ const el = {
|
|||||||
localFontButton: document.querySelector("#localFontButton"),
|
localFontButton: document.querySelector("#localFontButton"),
|
||||||
prevButton: document.querySelector("#prevButton"),
|
prevButton: document.querySelector("#prevButton"),
|
||||||
nextButton: document.querySelector("#nextButton"),
|
nextButton: document.querySelector("#nextButton"),
|
||||||
|
pageControl: document.querySelector(".page-control"),
|
||||||
pageInput: document.querySelector("#pageInput"),
|
pageInput: document.querySelector("#pageInput"),
|
||||||
pageTotal: document.querySelector("#pageTotal"),
|
pageTotal: document.querySelector("#pageTotal"),
|
||||||
zoomOutButton: document.querySelector("#zoomOutButton"),
|
zoomOutButton: document.querySelector("#zoomOutButton"),
|
||||||
@@ -1172,6 +1179,28 @@ COMPACT_LAYOUT.addEventListener("change", syncLayoutMode);
|
|||||||
el.viewerPanel.addEventListener("scroll", () => {
|
el.viewerPanel.addEventListener("scroll", () => {
|
||||||
schedulePageSync();
|
schedulePageSync();
|
||||||
});
|
});
|
||||||
|
el.navigationContent.addEventListener("scroll", () => {
|
||||||
|
if (state.thumbnailFrame) return;
|
||||||
|
state.thumbnailFrame = requestAnimationFrame(() => {
|
||||||
|
state.thumbnailFrame = 0;
|
||||||
|
syncThumbnailWindow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
for (const [viewport, current] of [[el.viewerPanel, () => state.pageWindow], [el.navigationContent, () => el.pageList.hidden ? null : state.thumbnailWindow]]) {
|
||||||
|
viewport.addEventListener("wheel", event => {
|
||||||
|
const view = current();
|
||||||
|
if (!view || view.total <= SCROLL_SEGMENT_SIZE || event.ctrlKey || event.shiftKey || !event.deltaY) return;
|
||||||
|
event.preventDefault();
|
||||||
|
view.relativeScroll = false;
|
||||||
|
const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? viewport.clientHeight : 1;
|
||||||
|
scrollPageWindowBy(view, event.deltaY * unit);
|
||||||
|
viewport.scrollLeft += event.deltaX * unit;
|
||||||
|
}, { passive: false });
|
||||||
|
viewport.addEventListener("pointerdown", event => {
|
||||||
|
const view = current();
|
||||||
|
if (view) view.relativeScroll = event.pointerType === "touch" || state.editing && !!event.target.closest(".page-shell");
|
||||||
|
});
|
||||||
|
}
|
||||||
el.viewerPanel.addEventListener("pointerdown", startPan);
|
el.viewerPanel.addEventListener("pointerdown", startPan);
|
||||||
el.viewerPanel.addEventListener("pointermove", movePan);
|
el.viewerPanel.addEventListener("pointermove", movePan);
|
||||||
el.viewerPanel.addEventListener("pointerup", endPan);
|
el.viewerPanel.addEventListener("pointerup", endPan);
|
||||||
@@ -1229,6 +1258,24 @@ function handleKeyDown(event) {
|
|||||||
if (document.body.hasAttribute("aria-busy") || formDialogOpen()) {
|
if (document.body.hasAttribute("aria-busy") || formDialogOpen()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (state.pageWindow && (!event.shiftKey || key === " ") && el.viewerPanel.contains(target)
|
||||||
|
&& !target.closest("input, textarea, select, button, a, [contenteditable]")) {
|
||||||
|
const view = state.pageWindow;
|
||||||
|
const delta = { ArrowUp: -40, ArrowDown: 40, PageUp: -el.viewerPanel.clientHeight * 0.9, PageDown: el.viewerPanel.clientHeight * 0.9,
|
||||||
|
" ": el.viewerPanel.clientHeight * 0.9 * (event.shiftKey ? -1 : 1) }[key];
|
||||||
|
if (key === "Home" || key === "End") {
|
||||||
|
event.preventDefault();
|
||||||
|
view.relativeScroll = false;
|
||||||
|
syncPageWindow(view, key === "Home" ? -windowInset(view) : view.total);
|
||||||
|
syncCurrentPageFromScroll();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (delta !== undefined && !event.ctrlKey && !event.metaKey && view.total > SCROLL_SEGMENT_SIZE) {
|
||||||
|
event.preventDefault();
|
||||||
|
scrollPageWindowBy(view, delta);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (event.ctrlKey || event.metaKey) {
|
if (event.ctrlKey || event.metaKey) {
|
||||||
if (state.editing && !target.closest("input, textarea, select, [contenteditable]")
|
if (state.editing && !target.closest("input, textarea, select, [contenteditable]")
|
||||||
&& (key.toLowerCase() === "z" || (!event.shiftKey && key.toLowerCase() === "y"))) {
|
&& (key.toLowerCase() === "z" || (!event.shiftKey && key.toLowerCase() === "y"))) {
|
||||||
@@ -2519,11 +2566,8 @@ async function refreshEditorPage(doc, index, openSeq, clearSelection = false) {
|
|||||||
renderMeta(true);
|
renderMeta(true);
|
||||||
updateControls();
|
updateControls();
|
||||||
loadDocumentDetails(openSeq);
|
loadDocumentDetails(openSeq);
|
||||||
for (const page of doc.pages) {
|
for (const shell of el.svgHost.children) observeFlowPage(shell);
|
||||||
observeFlowPage(pageShell(page.index));
|
for (const button of el.pageList.children) observeThumbnail(button, Number(button.dataset.pageIndex), openSeq);
|
||||||
const button = el.pageList.querySelector(`[data-page-index="${page.index}"]`);
|
|
||||||
observeThumbnail(button, page.index, openSeq);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2627,6 +2671,7 @@ function syncLayoutMode(event) {
|
|||||||
|
|
||||||
function resizeViewer() {
|
function resizeViewer() {
|
||||||
if (state.doc) {
|
if (state.doc) {
|
||||||
|
syncThumbnailWindow();
|
||||||
applyFit(false);
|
applyFit(false);
|
||||||
if (state.fitMode !== "free") {
|
if (state.fitMode !== "free") {
|
||||||
scrollToPage(state.pageIndex);
|
scrollToPage(state.pageIndex);
|
||||||
@@ -3551,6 +3596,7 @@ async function openDocument(options = {}) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const openSeq = options.openSeq || (state.openSeq += 1);
|
const openSeq = options.openSeq || (state.openSeq += 1);
|
||||||
|
const previewPosition = options.previewScroll && state.pageWindow ? pageWindowAnchor(state.pageWindow) : null;
|
||||||
const previewIDs = options.keepPreview ? [...new Set([...state.visiblePages, ...state.visibleThumbnails])].map((index) => state.doc.pages[index].id) : [];
|
const previewIDs = options.keepPreview ? [...new Set([...state.visiblePages, ...state.visibleThumbnails])].map((index) => state.doc.pages[index].id) : [];
|
||||||
if (!state.ready || state.wasmExited) {
|
if (!state.ready || state.wasmExited) {
|
||||||
await ensureWASM();
|
await ensureWASM();
|
||||||
@@ -3658,6 +3704,8 @@ async function openDocument(options = {}) {
|
|||||||
restoreScaleAnchor(options.previewAnchor);
|
restoreScaleAnchor(options.previewAnchor);
|
||||||
} else if (options.previewScroll) {
|
} else if (options.previewScroll) {
|
||||||
Object.assign(el.viewerPanel, options.previewScroll);
|
Object.assign(el.viewerPanel, options.previewScroll);
|
||||||
|
if (previewPosition && state.pageWindow) syncPageWindow(state.pageWindow,
|
||||||
|
state.pageWindow.offsets[previewPosition.index] + previewPosition.offset);
|
||||||
} else {
|
} else {
|
||||||
scrollToPage(pageIndex);
|
scrollToPage(pageIndex);
|
||||||
}
|
}
|
||||||
@@ -3742,6 +3790,8 @@ async function renderPage(index, options = {}) {
|
|||||||
}
|
}
|
||||||
if (options.scroll !== false) {
|
if (options.scroll !== false) {
|
||||||
scrollToPage(index);
|
scrollToPage(index);
|
||||||
|
} else if (state.pageWindow && (!pageShell(index) || pageShell(index).style.visibility === "hidden")) {
|
||||||
|
scrollToPage(index);
|
||||||
}
|
}
|
||||||
const page = await renderFlowPage(index, { throwError: true, openSeq, priority: 0 });
|
const page = await renderFlowPage(index, { throwError: true, openSeq, priority: 0 });
|
||||||
if (openSeq !== state.openSeq || index !== state.pageIndex) {
|
if (openSeq !== state.openSeq || index !== state.pageIndex) {
|
||||||
@@ -4096,8 +4146,123 @@ function cancelExport() {
|
|||||||
wasmWorker.postMessage({ type: "cancel", id: state.exportRequestID });
|
wasmWorker.postMessage({ type: "cancel", id: state.exportRequestID });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function virtualIndex(offsets, top) {
|
||||||
|
let low = 0, high = offsets.length - 1;
|
||||||
|
while (low + 1 < high) {
|
||||||
|
const mid = (low + high) >>> 1;
|
||||||
|
if (offsets[mid] <= top) low = mid;
|
||||||
|
else high = mid;
|
||||||
|
}
|
||||||
|
return low;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPageWindow(host, viewport, overscan, create, release, observe) {
|
||||||
|
host.classList.add("virtual-pages");
|
||||||
|
return { host, viewport, overscan, create, release, observe, nodes: new Map(), offsets: new Float64Array(1), origin: 0, total: 0, gap: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function windowInset(view) {
|
||||||
|
return view.host.getBoundingClientRect().top - view.viewport.getBoundingClientRect().top
|
||||||
|
+ view.viewport.scrollTop - view.viewport.clientTop;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageWindowTop(view) {
|
||||||
|
const inset = windowInset(view);
|
||||||
|
if (view.total <= SCROLL_SEGMENT_SIZE) return view.viewport.scrollTop - inset;
|
||||||
|
const delta = view.viewport.scrollTop - view.scrollTop;
|
||||||
|
if (!delta) return view.logicalTop;
|
||||||
|
if (view.relativeScroll) return view.logicalTop + delta;
|
||||||
|
const max = view.viewport.scrollHeight - view.viewport.clientHeight;
|
||||||
|
return view.viewport.scrollTop / Math.max(1, max) * (max + view.total - SCROLL_SEGMENT_SIZE) - inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollPageWindowBy(view, delta) {
|
||||||
|
if (view) syncPageWindow(view, pageWindowTop(view) + delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollViewerBy(delta) {
|
||||||
|
if (state.pageWindow) scrollPageWindowBy(state.pageWindow, delta);
|
||||||
|
else el.viewerPanel.scrollTop += delta;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncPageWindow(view, target = null) {
|
||||||
|
if (!view || view.offsets.length < 2) return;
|
||||||
|
const { host, viewport, offsets, nodes } = view;
|
||||||
|
const extent = Math.min(SCROLL_SEGMENT_SIZE, view.total);
|
||||||
|
host.style.height = `${extent}px`;
|
||||||
|
const inset = windowInset(view), height = viewport.clientHeight;
|
||||||
|
const maxTop = Math.max(0, viewport.scrollHeight - height);
|
||||||
|
const logicalMax = maxTop + view.total - extent;
|
||||||
|
const logical = Math.max(-inset, Math.min(logicalMax - inset, target ?? pageWindowTop(view)));
|
||||||
|
viewport.scrollTop = logicalMax > 0 ? (logical + inset) * maxTop / logicalMax : 0;
|
||||||
|
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));
|
||||||
|
const selection = host === el.svgHost && state.documentSelection ? document.getSelection() : null;
|
||||||
|
const selected = selection?.rangeCount && isDocumentSelection(selection.getRangeAt(0));
|
||||||
|
let changed = false;
|
||||||
|
for (const [index, node] of nodes) {
|
||||||
|
if ((index < first || index > last) && view.release(node, index)) {
|
||||||
|
node.remove();
|
||||||
|
nodes.delete(index);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let index = first; index <= last; index++) {
|
||||||
|
if (nodes.has(index)) continue;
|
||||||
|
const node = view.create(state.doc.pages[index]);
|
||||||
|
nodes.set(index, node);
|
||||||
|
const next = [...host.children].find(child => Number(child.dataset.pageIndex) > index);
|
||||||
|
host.insertBefore(node, next || null);
|
||||||
|
changed = true;
|
||||||
|
node.style.top = `${offsets[index] - view.origin}px`;
|
||||||
|
view.observe(node, index);
|
||||||
|
}
|
||||||
|
for (const [index, node] of nodes) {
|
||||||
|
const top = offsets[index] - view.origin;
|
||||||
|
const itemHeight = offsets[index + 1] - offsets[index] - view.gap;
|
||||||
|
node.style.top = `${Math.max(-itemHeight, Math.min(extent, top))}px`;
|
||||||
|
node.style.visibility = top + itemHeight < 0 || top >= extent ? "hidden" : "";
|
||||||
|
}
|
||||||
|
if (selected && changed) selection.getRangeAt(0).selectNodeContents(host);
|
||||||
|
view.anchor = pageWindowAnchor(view);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageWindowAnchor(view) {
|
||||||
|
const top = pageWindowTop(view);
|
||||||
|
const index = virtualIndex(view.offsets, top);
|
||||||
|
return { index, offset: top - view.offsets[index] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseFlowShell(shell, index) {
|
||||||
|
if (shell.contains(document.activeElement) || canvasEditor.drag?.item?.index === index) return false;
|
||||||
|
unmountPage(index);
|
||||||
|
if (state.selectedPages.has(index)) return false;
|
||||||
|
state.pageObserver?.unobserve(shell);
|
||||||
|
state.visiblePages.delete(index);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFlowShell(page) {
|
||||||
|
const shell = document.createElement("div");
|
||||||
|
shell.className = "page-shell";
|
||||||
|
shell.dataset.pageIndex = String(page.index);
|
||||||
|
const surface = document.createElement("div");
|
||||||
|
surface.className = "page-surface";
|
||||||
|
const placeholder = document.createElement("div");
|
||||||
|
placeholder.className = "page-placeholder";
|
||||||
|
placeholder.textContent = `第 ${page.index + 1} 页`;
|
||||||
|
shell.append(surface, placeholder);
|
||||||
|
layoutPageShell(shell, page);
|
||||||
|
return shell;
|
||||||
|
}
|
||||||
|
|
||||||
function renderPageFlow() {
|
function renderPageFlow() {
|
||||||
el.svgHost.replaceChildren();
|
el.svgHost.replaceChildren();
|
||||||
|
state.pageWindow = null;
|
||||||
|
state.visiblePages.clear();
|
||||||
el.viewerPanel.classList.remove("single-page-fits-height");
|
el.viewerPanel.classList.remove("single-page-fits-height");
|
||||||
if (!state.doc) {
|
if (!state.doc) {
|
||||||
return;
|
return;
|
||||||
@@ -4105,31 +4270,10 @@ function renderPageFlow() {
|
|||||||
if (state.pageObserver) {
|
if (state.pageObserver) {
|
||||||
state.pageObserver.disconnect();
|
state.pageObserver.disconnect();
|
||||||
}
|
}
|
||||||
const fragment = document.createDocumentFragment();
|
|
||||||
const targets = [];
|
|
||||||
for (const page of state.doc.pages || []) {
|
|
||||||
const shell = document.createElement("div");
|
|
||||||
shell.className = "page-shell";
|
|
||||||
shell.dataset.pageIndex = String(page.index);
|
|
||||||
|
|
||||||
const surface = document.createElement("div");
|
|
||||||
surface.className = "page-surface";
|
|
||||||
|
|
||||||
const placeholder = document.createElement("div");
|
|
||||||
placeholder.className = "page-placeholder";
|
|
||||||
placeholder.textContent = `第 ${page.index + 1} 页`;
|
|
||||||
|
|
||||||
shell.append(surface, placeholder);
|
|
||||||
fragment.append(shell);
|
|
||||||
targets.push(shell);
|
|
||||||
}
|
|
||||||
el.svgHost.append(fragment);
|
|
||||||
layoutPages();
|
|
||||||
for (const shell of targets) {
|
|
||||||
observeFlowPage(shell);
|
|
||||||
}
|
|
||||||
el.emptyState.hidden = true;
|
el.emptyState.hidden = true;
|
||||||
el.pageFrame.hidden = false;
|
el.pageFrame.hidden = false;
|
||||||
|
state.pageWindow = createPageWindow(el.svgHost, el.viewerPanel, 600, createFlowShell, releaseFlowShell, observeFlowPage);
|
||||||
|
layoutPages();
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetPageFlow(keepCache = false) {
|
function resetPageFlow(keepCache = false) {
|
||||||
@@ -4157,9 +4301,14 @@ function resetPageLoading() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function observeFlowPage(shell) {
|
function observeFlowPage(shell) {
|
||||||
|
if (!shell) return;
|
||||||
const observer = flowPageObserver();
|
const observer = flowPageObserver();
|
||||||
if (observer) {
|
if (observer) {
|
||||||
observer.observe(shell);
|
observer.observe(shell);
|
||||||
|
} else {
|
||||||
|
const index = Number(shell.dataset.pageIndex);
|
||||||
|
state.visiblePages.add(index);
|
||||||
|
renderFlowPage(index, { priority: 4 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4175,6 +4324,7 @@ function flowPageObserver() {
|
|||||||
}
|
}
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const index = Number.parseInt(entry.target.dataset.pageIndex, 10);
|
const index = Number.parseInt(entry.target.dataset.pageIndex, 10);
|
||||||
|
if (state.pageWindow && state.pageWindow.nodes.get(index) !== entry.target) continue;
|
||||||
if (!entry.isIntersecting) {
|
if (!entry.isIntersecting) {
|
||||||
state.visiblePages.delete(index);
|
state.visiblePages.delete(index);
|
||||||
unmountPage(index);
|
unmountPage(index);
|
||||||
@@ -4195,6 +4345,7 @@ function flowPageObserver() {
|
|||||||
|
|
||||||
function unmountPage(index) {
|
function unmountPage(index) {
|
||||||
const shell = pageShell(index);
|
const shell = pageShell(index);
|
||||||
|
if (!shell) return;
|
||||||
const selection = document.getSelection();
|
const selection = document.getSelection();
|
||||||
const range = !selection.isCollapsed && selection.rangeCount ? selection.getRangeAt(0) : null;
|
const range = !selection.isCollapsed && selection.rangeCount ? selection.getRangeAt(0) : null;
|
||||||
if (canvasEditor.input?.item.index === index || canvasEditor.crop?.item.index === index
|
if (canvasEditor.input?.item.index === index || canvasEditor.crop?.item.index === index
|
||||||
@@ -4219,7 +4370,7 @@ async function renderFlowPage(index, options = {}) {
|
|||||||
}
|
}
|
||||||
if (page) {
|
if (page) {
|
||||||
const shell = pageShell(index);
|
const shell = pageShell(index);
|
||||||
if (!shell?.classList.contains("rendered") && (!state.pageObserver || state.visiblePages.has(index) || index === state.pageIndex)) {
|
if (shell && !shell.classList.contains("rendered") && (!state.pageObserver || state.visiblePages.has(index) || index === state.pageIndex)) {
|
||||||
mountPageSVG(index, page, openSeq);
|
mountPageSVG(index, page, openSeq);
|
||||||
}
|
}
|
||||||
updateThumbnail(index, openSeq);
|
updateThumbnail(index, openSeq);
|
||||||
@@ -4337,6 +4488,9 @@ async function processPageRenderQueue() {
|
|||||||
|
|
||||||
function trimPageCache() {
|
function trimPageCache() {
|
||||||
const protectedPages = new Set([state.pageIndex, ...state.visiblePages, ...state.visibleThumbnails, ...state.selectedPages]);
|
const protectedPages = new Set([state.pageIndex, ...state.visiblePages, ...state.visibleThumbnails, ...state.selectedPages]);
|
||||||
|
for (const view of [state.pageWindow, state.thumbnailWindow]) {
|
||||||
|
for (const index of view?.nodes.keys() || []) protectedPages.add(index);
|
||||||
|
}
|
||||||
for (const task of state.pageInFlight.values()) {
|
for (const task of state.pageInFlight.values()) {
|
||||||
protectedPages.add(task.index);
|
protectedPages.add(task.index);
|
||||||
}
|
}
|
||||||
@@ -4547,9 +4701,10 @@ async function navigateDestination(dest) {
|
|||||||
point.x = Math.min(point.x, bottom.x);
|
point.x = Math.min(point.x, bottom.x);
|
||||||
point.y = Math.min(point.y, bottom.y);
|
point.y = Math.min(point.y, bottom.y);
|
||||||
}
|
}
|
||||||
|
if (state.pageWindow) syncPageWindow(state.pageWindow, state.pageWindow.offsets[index]);
|
||||||
const shell = pageShell(index).getBoundingClientRect(), viewer = el.viewerPanel.getBoundingClientRect();
|
const shell = pageShell(index).getBoundingClientRect(), viewer = el.viewerPanel.getBoundingClientRect();
|
||||||
el.viewerPanel.scrollLeft += shell.left - viewer.left + point.x * MM_TO_PX * state.scale - space;
|
el.viewerPanel.scrollLeft += shell.left - viewer.left + point.x * MM_TO_PX * state.scale - space;
|
||||||
el.viewerPanel.scrollTop += shell.top - viewer.top + point.y * MM_TO_PX * state.scale - pageBlockSpace();
|
scrollViewerBy(shell.top - viewer.top + point.y * MM_TO_PX * state.scale - pageBlockSpace());
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTextLayer(text) {
|
function createTextLayer(text) {
|
||||||
@@ -4834,13 +4989,19 @@ function queueNearbyPages(index, openSeq = state.openSeq) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function pageShell(index) {
|
function pageShell(index) {
|
||||||
return el.svgHost.children.item(index);
|
return state.pageWindow?.nodes.get(index) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollToPage(index) {
|
function scrollToPage(index) {
|
||||||
|
if (state.pageWindow) syncPageWindow(state.pageWindow, state.pageWindow.offsets[index]);
|
||||||
const shell = pageShell(index);
|
const shell = pageShell(index);
|
||||||
if (shell) {
|
if (shell) {
|
||||||
if (state.fitMode === "height" && !state.continuous) {
|
if (state.pageWindow) {
|
||||||
|
const shellRect = shell.getBoundingClientRect(), viewerRect = el.viewerPanel.getBoundingClientRect();
|
||||||
|
const space = state.fitMode === "height" && !state.continuous
|
||||||
|
? (el.viewerPanel.clientHeight - shellRect.height) / 2 : pageBlockSpace();
|
||||||
|
scrollViewerBy(shellRect.top - viewerRect.top - space);
|
||||||
|
} else if (state.fitMode === "height" && !state.continuous) {
|
||||||
shell.scrollIntoView({ block: "center", inline: "nearest" });
|
shell.scrollIntoView({ block: "center", inline: "nearest" });
|
||||||
} else {
|
} else {
|
||||||
const viewerRect = el.viewerPanel.getBoundingClientRect();
|
const viewerRect = el.viewerPanel.getBoundingClientRect();
|
||||||
@@ -4870,6 +5031,7 @@ function schedulePageSync() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function syncCurrentPageFromScroll() {
|
function syncCurrentPageFromScroll() {
|
||||||
|
syncPageWindow(state.pageWindow);
|
||||||
const shell = pageShellFromView();
|
const shell = pageShellFromView();
|
||||||
if (!shell) {
|
if (!shell) {
|
||||||
return;
|
return;
|
||||||
@@ -4879,6 +5041,17 @@ function syncCurrentPageFromScroll() {
|
|||||||
setCurrentPage(nextIndex);
|
setCurrentPage(nextIndex);
|
||||||
queueNearbyPages(nextIndex);
|
queueNearbyPages(nextIndex);
|
||||||
}
|
}
|
||||||
|
prunePageRenderQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
function prunePageRenderQueue() {
|
||||||
|
state.pageRenderQueue = state.pageRenderQueue.filter(task => {
|
||||||
|
if (task.priority < 2 || Math.abs(task.index - state.pageIndex) <= 2
|
||||||
|
|| state.pageWindow?.nodes.has(task.index) || state.thumbnailWindow?.nodes.has(task.index)) return true;
|
||||||
|
state.pageInFlight.delete(task.key);
|
||||||
|
task.resolve(null);
|
||||||
|
return false;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function pageShellFromView() {
|
function pageShellFromView() {
|
||||||
@@ -4917,6 +5090,11 @@ function setCurrentPage(index) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updatePageListCurrent(force = false) {
|
function updatePageListCurrent(force = false) {
|
||||||
|
if (state.thumbnailWindow) {
|
||||||
|
if (!force && state.pageListCurrent === state.pageIndex) return;
|
||||||
|
state.pageListCurrent = state.pageIndex;
|
||||||
|
if (state.showPages && !el.pageList.hidden) revealThumbnail(state.pageIndex);
|
||||||
|
}
|
||||||
const current = el.pageList.querySelector(".page-list-item[aria-current]");
|
const current = el.pageList.querySelector(".page-list-item[aria-current]");
|
||||||
if (current) {
|
if (current) {
|
||||||
if (!force && Number.parseInt(current.dataset.pageIndex, 10) === state.pageIndex) {
|
if (!force && Number.parseInt(current.dataset.pageIndex, 10) === state.pageIndex) {
|
||||||
@@ -4927,7 +5105,7 @@ function updatePageListCurrent(force = false) {
|
|||||||
const next = el.pageList.querySelector(`.page-list-item[data-page-index="${state.pageIndex}"]`);
|
const next = el.pageList.querySelector(`.page-list-item[data-page-index="${state.pageIndex}"]`);
|
||||||
if (next) {
|
if (next) {
|
||||||
setPageItemCurrent(next, true);
|
setPageItemCurrent(next, true);
|
||||||
if (state.showPages && !el.pageList.hidden) {
|
if (state.showPages && !el.pageList.hidden && !state.thumbnailWindow) {
|
||||||
const panel = el.navigationContent.getBoundingClientRect();
|
const panel = el.navigationContent.getBoundingClientRect();
|
||||||
const item = next.getBoundingClientRect();
|
const item = next.getBoundingClientRect();
|
||||||
if (item.top < panel.top) {
|
if (item.top < panel.top) {
|
||||||
@@ -4952,12 +5130,25 @@ function layoutPages() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
canvasEditor.cancel();
|
canvasEditor.cancel();
|
||||||
for (const page of state.doc.pages || []) {
|
const view = state.pageWindow;
|
||||||
const shell = pageShell(page.index);
|
if (!view) return;
|
||||||
if (shell) {
|
const anchor = view.offsets.length > 1 ? pageWindowAnchor(view) : { index: state.pageIndex, offset: 0 };
|
||||||
layoutPageShell(shell, page);
|
const gap = Number.parseFloat(getComputedStyle(el.svgHost).rowGap) || 0;
|
||||||
}
|
const offsets = new Float64Array(state.doc.pages.length + 1);
|
||||||
|
const fittedWidth = state.scale * pageViewSize(currentPageInfo()).width;
|
||||||
|
let width = 1;
|
||||||
|
for (let index = 0; index < state.doc.pages.length; index++) {
|
||||||
|
const size = pageViewSize(state.doc.pages[index]);
|
||||||
|
const scale = state.fitMode === "width" ? fittedWidth / size.width : state.scale;
|
||||||
|
offsets[index + 1] = offsets[index] + Math.max(1, size.height * MM_TO_PX) * scale + gap;
|
||||||
|
width = Math.max(width, Math.max(1, size.width * MM_TO_PX) * scale);
|
||||||
}
|
}
|
||||||
|
view.offsets = offsets;
|
||||||
|
view.gap = gap;
|
||||||
|
view.total = Math.max(0, offsets.at(-1) - gap);
|
||||||
|
view.host.style.width = `${width}px`;
|
||||||
|
for (const [index, shell] of view.nodes) layoutPageShell(shell, state.doc.pages[index]);
|
||||||
|
syncPageWindow(view, offsets[anchor.index] + anchor.offset);
|
||||||
}
|
}
|
||||||
|
|
||||||
function layoutPageShell(shell, page) {
|
function layoutPageShell(shell, page) {
|
||||||
@@ -5057,6 +5248,7 @@ function showNavigation(selected) {
|
|||||||
}
|
}
|
||||||
el.pageSelectionTools.hidden = !state.editing || !state.pageMultiSelect || selected !== el.pagesTab;
|
el.pageSelectionTools.hidden = !state.editing || !state.pageMultiSelect || selected !== el.pagesTab;
|
||||||
el.navigationContent.scrollTop = state.navigationScroll.get(selected) || 0;
|
el.navigationContent.scrollTop = state.navigationScroll.get(selected) || 0;
|
||||||
|
if (selected === el.pagesTab) syncThumbnailWindow();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderOutlines(reset = true) {
|
function renderOutlines(reset = true) {
|
||||||
@@ -5212,7 +5404,7 @@ async function selectSearchMatch(index) {
|
|||||||
if (mark) {
|
if (mark) {
|
||||||
const box = mark.getBoundingClientRect();
|
const box = mark.getBoundingClientRect();
|
||||||
const viewer = el.viewerPanel.getBoundingClientRect();
|
const viewer = el.viewerPanel.getBoundingClientRect();
|
||||||
el.viewerPanel.scrollTop += box.top + box.height / 2 - viewer.top - el.viewerPanel.clientHeight / 2;
|
scrollViewerBy(box.top + box.height / 2 - viewer.top - el.viewerPanel.clientHeight / 2);
|
||||||
if (box.left < viewer.left) {
|
if (box.left < viewer.left) {
|
||||||
el.viewerPanel.scrollLeft += box.left - viewer.left;
|
el.viewerPanel.scrollLeft += box.left - viewer.left;
|
||||||
} else if (box.right > viewer.left + el.viewerPanel.clientWidth) {
|
} else if (box.right > viewer.left + el.viewerPanel.clientWidth) {
|
||||||
@@ -5456,6 +5648,18 @@ el.pageList.addEventListener("contextmenu", event => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
el.pageList.addEventListener("keydown", event => {
|
el.pageList.addEventListener("keydown", event => {
|
||||||
|
const button = event.target?.closest(".page-list-item");
|
||||||
|
if (state.thumbnailWindow && button && !event.altKey && !event.ctrlKey && !event.metaKey && !event.isComposing) {
|
||||||
|
const index = Number(button.dataset.pageIndex);
|
||||||
|
const next = { ArrowUp: index - 1, ArrowDown: index + 1, Home: 0, End: state.doc.pageCount - 1,
|
||||||
|
Tab: index + (event.shiftKey ? -1 : 1) }[event.key];
|
||||||
|
if (next >= 0 && next < state.doc.pageCount) {
|
||||||
|
event.preventDefault();
|
||||||
|
revealThumbnail(next);
|
||||||
|
state.thumbnailWindow.nodes.get(next)?.focus({ preventScroll: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!state.editing || event.isComposing || document.body.hasAttribute("aria-busy")) return;
|
if (!state.editing || event.isComposing || document.body.hasAttribute("aria-busy")) return;
|
||||||
if ((event.ctrlKey || event.metaKey) && !event.altKey && event.key.toLowerCase() === "a") {
|
if ((event.ctrlKey || event.metaKey) && !event.altKey && event.key.toLowerCase() === "a") {
|
||||||
state.pageSelection = new Set(state.doc.pages.map(page => page.id));
|
state.pageSelection = new Set(state.doc.pages.map(page => page.id));
|
||||||
@@ -5472,6 +5676,9 @@ el.pageList.addEventListener("keydown", event => {
|
|||||||
|
|
||||||
function renderPageList() {
|
function renderPageList() {
|
||||||
el.pageList.replaceChildren();
|
el.pageList.replaceChildren();
|
||||||
|
state.thumbnailWindow = null;
|
||||||
|
state.pageListCurrent = null;
|
||||||
|
state.visibleThumbnails.clear();
|
||||||
if (!state.doc) {
|
if (!state.doc) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -5480,14 +5687,20 @@ function renderPageList() {
|
|||||||
}
|
}
|
||||||
const ids = new Set(state.doc.pages.map(page => page.id));
|
const ids = new Set(state.doc.pages.map(page => page.id));
|
||||||
for (const id of state.pageSelection) if (!ids.has(id) || !state.editing) state.pageSelection.delete(id);
|
for (const id of state.pageSelection) if (!ids.has(id) || !state.editing) state.pageSelection.delete(id);
|
||||||
const fragment = document.createDocumentFragment();
|
state.thumbnailWindow = createPageWindow(el.pageList, el.navigationContent, 180, createThumbnail, releaseThumbnail, observeThumbnail);
|
||||||
const thumbnailTargets = [];
|
layoutThumbnails();
|
||||||
|
syncPageSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
function createThumbnail(page) {
|
||||||
const openSeq = state.openSeq;
|
const openSeq = state.openSeq;
|
||||||
for (const page of state.doc.pages || []) {
|
|
||||||
const button = document.createElement("button");
|
const button = document.createElement("button");
|
||||||
button.type = "button";
|
button.type = "button";
|
||||||
button.className = "page-list-item";
|
button.className = "page-list-item";
|
||||||
button.dataset.pageIndex = String(page.index);
|
button.dataset.pageIndex = String(page.index);
|
||||||
|
button.setAttribute("aria-posinset", String(page.index + 1));
|
||||||
|
button.setAttribute("aria-setsize", String(state.doc.pageCount));
|
||||||
|
if (state.editing) button.setAttribute("aria-pressed", String(state.pageSelection.has(page.id)));
|
||||||
button.title = `${formatSize(page.width)} x ${formatSize(page.height)} mm`;
|
button.title = `${formatSize(page.width)} x ${formatSize(page.height)} mm`;
|
||||||
setPageItemCurrent(button, page.index === state.pageIndex);
|
setPageItemCurrent(button, page.index === state.pageIndex);
|
||||||
layoutThumbnail(button, page);
|
layoutThumbnail(button, page);
|
||||||
@@ -5508,13 +5721,64 @@ function renderPageList() {
|
|||||||
button.append(thumb, label, size);
|
button.append(thumb, label, size);
|
||||||
if (state.editing && pageCan("move", page.index)) enablePageDrag(button, page.index);
|
if (state.editing && pageCan("move", page.index)) enablePageDrag(button, page.index);
|
||||||
button.addEventListener("click", event => selectThumbnailPage(event, page.index));
|
button.addEventListener("click", event => selectThumbnailPage(event, page.index));
|
||||||
fragment.append(button);
|
button.style.height = `${state.thumbnailWindow.offsets[page.index + 1] - state.thumbnailWindow.offsets[page.index] - 12}px`;
|
||||||
thumbnailTargets.push([button, page.index]);
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseThumbnail(button, index) {
|
||||||
|
if (state.pageDragIndex === index || button.contains(document.activeElement)) return false;
|
||||||
|
state.thumbnailObserver?.unobserve(button);
|
||||||
|
state.visibleThumbnails.delete(index);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function layoutThumbnails() {
|
||||||
|
const view = state.thumbnailWindow;
|
||||||
|
if (!view) return;
|
||||||
|
const visible = !el.pageList.hidden && state.showPages;
|
||||||
|
const anchor = visible && view.offsets.length > 1 ? pageWindowAnchor(view) : view.anchor || { index: state.pageIndex, offset: 0 };
|
||||||
|
const width = el.navigationContent.clientWidth || view.width || 180;
|
||||||
|
view.width = width;
|
||||||
|
view.rotation = state.rotation;
|
||||||
|
const offsets = new Float64Array(state.doc.pages.length + 1);
|
||||||
|
for (let index = 0; index < state.doc.pages.length; index++) {
|
||||||
|
const size = pageViewSize(state.doc.pages[index]);
|
||||||
|
const ratio = size.width > 0 && size.height > 0 ? size.height / size.width : 1 / 0.707;
|
||||||
|
offsets[index + 1] = offsets[index] + Math.max(1, width - 20) * ratio + 70 + 12;
|
||||||
}
|
}
|
||||||
el.pageList.append(fragment);
|
view.offsets = offsets;
|
||||||
syncPageSelection();
|
view.gap = 12;
|
||||||
for (const [button, index] of thumbnailTargets) {
|
view.total = Math.max(0, offsets.at(-1) - 12);
|
||||||
observeThumbnail(button, index, openSeq);
|
for (const [index, button] of view.nodes) {
|
||||||
|
layoutThumbnail(button, state.doc.pages[index]);
|
||||||
|
button.style.height = `${offsets[index + 1] - offsets[index] - 12}px`;
|
||||||
|
}
|
||||||
|
view.pendingTop = offsets[anchor.index] + anchor.offset;
|
||||||
|
view.host.style.height = `${Math.min(SCROLL_SEGMENT_SIZE, view.total)}px`;
|
||||||
|
if (visible) {
|
||||||
|
syncPageWindow(view, view.pendingTop);
|
||||||
|
view.pendingTop = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncThumbnailWindow() {
|
||||||
|
const view = state.thumbnailWindow;
|
||||||
|
if (!view || el.pageList.hidden || !state.showPages) return;
|
||||||
|
if (el.navigationContent.clientWidth && (view.width !== el.navigationContent.clientWidth || view.rotation !== state.rotation)) layoutThumbnails();
|
||||||
|
syncPageWindow(view, view.pendingTop ?? null);
|
||||||
|
view.pendingTop = null;
|
||||||
|
prunePageRenderQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
function revealThumbnail(index) {
|
||||||
|
syncThumbnailWindow();
|
||||||
|
const view = state.thumbnailWindow;
|
||||||
|
if (!view) return;
|
||||||
|
const top = pageWindowTop(view);
|
||||||
|
const start = view.offsets[index], end = view.offsets[index + 1] - view.gap;
|
||||||
|
if (start < top || end > top + el.navigationContent.clientHeight) {
|
||||||
|
const target = start < top ? Math.floor(start) : Math.min(start, Math.ceil(end - el.navigationContent.clientHeight));
|
||||||
|
syncPageWindow(view, target);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5555,6 +5819,7 @@ function enablePageDrag(button, index) {
|
|||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
const seq = state.openSeq, pointer = event.pointerId;
|
const seq = state.openSeq, pointer = event.pointerId;
|
||||||
const indexes = selectDragPages(index);
|
const indexes = selectDragPages(index);
|
||||||
|
state.pageDragIndex = index;
|
||||||
let x = event.clientX, y = event.clientY, position = null, marker = null, frame = 0, dragging = false;
|
let x = event.clientX, y = event.clientY, position = null, marker = null, frame = 0, dragging = false;
|
||||||
const clearMarker = () => { marker?.classList.remove("drop-before", "drop-after"); marker = null; };
|
const clearMarker = () => { marker?.classList.remove("drop-before", "drop-after"); marker = null; };
|
||||||
const locate = () => {
|
const locate = () => {
|
||||||
@@ -5575,7 +5840,11 @@ function enablePageDrag(button, index) {
|
|||||||
if (dragging) {
|
if (dragging) {
|
||||||
const rect = el.navigationContent.getBoundingClientRect();
|
const rect = el.navigationContent.getBoundingClientRect();
|
||||||
const delta = y < rect.top + 48 ? -10 : y > rect.bottom - 48 ? 10 : 0;
|
const delta = y < rect.top + 48 ? -10 : y > rect.bottom - 48 ? 10 : 0;
|
||||||
if (delta) el.navigationContent.scrollTop += delta;
|
if (delta) {
|
||||||
|
if (state.thumbnailWindow) scrollPageWindowBy(state.thumbnailWindow, delta);
|
||||||
|
else el.navigationContent.scrollTop += delta;
|
||||||
|
}
|
||||||
|
syncThumbnailWindow();
|
||||||
locate();
|
locate();
|
||||||
}
|
}
|
||||||
frame = requestAnimationFrame(tick);
|
frame = requestAnimationFrame(tick);
|
||||||
@@ -5588,6 +5857,7 @@ function enablePageDrag(button, index) {
|
|||||||
if (dragging) locate();
|
if (dragging) locate();
|
||||||
};
|
};
|
||||||
const finish = async commit => {
|
const finish = async commit => {
|
||||||
|
state.pageDragIndex = null;
|
||||||
cancelAnimationFrame(frame);
|
cancelAnimationFrame(frame);
|
||||||
grip.removeEventListener("pointermove", move);
|
grip.removeEventListener("pointermove", move);
|
||||||
grip.removeEventListener("pointerup", up);
|
grip.removeEventListener("pointerup", up);
|
||||||
@@ -5616,7 +5886,10 @@ function enablePageDrag(button, index) {
|
|||||||
const target = direction < 0 ? indexes[0] - 1 : indexes.at(-1) + 1;
|
const target = direction < 0 ? indexes[0] - 1 : indexes.at(-1) + 1;
|
||||||
if (target >= 0 && target < state.doc.pageCount && await canvasEditor.commitText() && await canvasEditor.commitCrop()) {
|
if (target >= 0 && target < state.doc.pageCount && await canvasEditor.commitText() && await canvasEditor.commitCrop()) {
|
||||||
const focus = (direction < 0 ? target : target - indexes.length + 1) + indexes.indexOf(index);
|
const focus = (direction < 0 ? target : target - indexes.length + 1) + indexes.indexOf(index);
|
||||||
if (await changeSelectedPages("move", direction)) el.pageList.querySelector(`[data-page-index="${focus}"]`)?.focus();
|
if (await changeSelectedPages("move", direction)) {
|
||||||
|
revealThumbnail(focus);
|
||||||
|
el.pageList.querySelector(`[data-page-index="${focus}"]`)?.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -5658,11 +5931,13 @@ function setThumbnailContent(container, svgText, index, openSeq = state.openSeq)
|
|||||||
}
|
}
|
||||||
|
|
||||||
function observeThumbnail(button, index, openSeq = state.openSeq) {
|
function observeThumbnail(button, index, openSeq = state.openSeq) {
|
||||||
|
if (!button) return;
|
||||||
const observer = thumbnailObserver();
|
const observer = thumbnailObserver();
|
||||||
if (observer) {
|
if (observer) {
|
||||||
observer.observe(button);
|
observer.observe(button);
|
||||||
}
|
}
|
||||||
if (index === state.pageIndex || !observer && index < 8) {
|
if (index === state.pageIndex || !observer) {
|
||||||
|
if (!observer) state.visibleThumbnails.add(index);
|
||||||
renderThumbnail(index, openSeq);
|
renderThumbnail(index, openSeq);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5679,6 +5954,7 @@ function thumbnailObserver() {
|
|||||||
}
|
}
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const index = Number.parseInt(entry.target.dataset.pageIndex, 10);
|
const index = Number.parseInt(entry.target.dataset.pageIndex, 10);
|
||||||
|
if (state.thumbnailWindow && state.thumbnailWindow.nodes.get(index) !== entry.target) continue;
|
||||||
if (!entry.isIntersecting) {
|
if (!entry.isIntersecting) {
|
||||||
state.visibleThumbnails.delete(index);
|
state.visibleThumbnails.delete(index);
|
||||||
const thumb = entry.target.querySelector(".thumb-paper");
|
const thumb = entry.target.querySelector(".thumb-paper");
|
||||||
@@ -6014,8 +6290,8 @@ function signatureReferenceStatus(signature) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatDocumentTime(value) {
|
function formatDocumentTime(value) {
|
||||||
const text = String(value || "").trim();
|
const text = String(value || "").trim().replace(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})(\.\d+)?(Z|[+-]\d{2}:?\d{2})?$/, "$1-$2-$3 $4:$5:$6$7$8").replace("T", " ");
|
||||||
return text.replace(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})(\.\d+)?(Z|[+-]\d{2}:?\d{2})?$/, "$1-$2-$3 $4:$5:$6$7$8").replace("T", " ");
|
return /^0001[-/]0?1[-/]0?1(?: 0{1,2}:00:00(?:\.0+)?(?:Z|[+-]00:?00)?)?$/.test(text) ? "" : text;
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendInfoLine(row, label, value, status = "") {
|
function appendInfoLine(row, label, value, status = "") {
|
||||||
@@ -6079,7 +6355,13 @@ function highlightPageRegion(region) {
|
|||||||
mark.style.width = `${region.width * MM_TO_PX}px`;
|
mark.style.width = `${region.width * MM_TO_PX}px`;
|
||||||
mark.style.height = `${region.height * MM_TO_PX}px`;
|
mark.style.height = `${region.height * MM_TO_PX}px`;
|
||||||
shell.querySelector(".page-surface").append(mark);
|
shell.querySelector(".page-surface").append(mark);
|
||||||
mark.scrollIntoView({ block: "nearest", inline: "nearest" });
|
if (state.pageWindow) {
|
||||||
|
const box = mark.getBoundingClientRect(), viewer = el.viewerPanel.getBoundingClientRect();
|
||||||
|
if (box.top < viewer.top) scrollViewerBy(box.top - viewer.top);
|
||||||
|
else if (box.bottom > viewer.top + el.viewerPanel.clientHeight) scrollViewerBy(Math.min(box.top - viewer.top, box.bottom - viewer.top - el.viewerPanel.clientHeight));
|
||||||
|
if (box.left < viewer.left) el.viewerPanel.scrollLeft += box.left - viewer.left;
|
||||||
|
else if (box.right > viewer.left + el.viewerPanel.clientWidth) el.viewerPanel.scrollLeft += box.right - viewer.left - el.viewerPanel.clientWidth;
|
||||||
|
} else mark.scrollIntoView({ block: "nearest", inline: "nearest" });
|
||||||
window.setTimeout(() => mark.remove(), 1800);
|
window.setTimeout(() => mark.remove(), 1800);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6249,6 +6531,10 @@ function setContinuous(continuous) {
|
|||||||
function toggleContinuous() {
|
function toggleContinuous() {
|
||||||
const anchor = state.fitMode === "free" ? scaleAnchor() : null;
|
const anchor = state.fitMode === "free" ? scaleAnchor() : null;
|
||||||
setContinuous(!state.continuous);
|
setContinuous(!state.continuous);
|
||||||
|
if (state.pageWindow) {
|
||||||
|
updateFitSpace();
|
||||||
|
layoutPages();
|
||||||
|
}
|
||||||
clearRegionHighlights();
|
clearRegionHighlights();
|
||||||
if (state.fitMode === "free") {
|
if (state.fitMode === "free") {
|
||||||
restoreScaleAnchor(anchor);
|
restoreScaleAnchor(anchor);
|
||||||
@@ -6266,9 +6552,7 @@ function rotatePages() {
|
|||||||
clearRegionHighlights();
|
clearRegionHighlights();
|
||||||
el.viewerPanel.classList.remove("single-page-fits-height");
|
el.viewerPanel.classList.remove("single-page-fits-height");
|
||||||
layoutPages();
|
layoutPages();
|
||||||
for (const page of state.doc.pages) {
|
layoutThumbnails();
|
||||||
layoutThumbnail(el.pageList.children.item(page.index), page);
|
|
||||||
}
|
|
||||||
if (state.fitMode === "free") {
|
if (state.fitMode === "free") {
|
||||||
updateFitSpace();
|
updateFitSpace();
|
||||||
} else {
|
} else {
|
||||||
@@ -6305,7 +6589,8 @@ function startPan(event) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
state.pan = { id: event.pointerId, x: event.clientX + el.viewerPanel.scrollLeft, y: event.clientY + el.viewerPanel.scrollTop };
|
state.pan = { id: event.pointerId, x: event.clientX + el.viewerPanel.scrollLeft,
|
||||||
|
y: event.clientY + (state.pageWindow ? pageWindowTop(state.pageWindow) : el.viewerPanel.scrollTop) };
|
||||||
el.viewerPanel.setPointerCapture(event.pointerId);
|
el.viewerPanel.setPointerCapture(event.pointerId);
|
||||||
el.viewerPanel.classList.add("panning");
|
el.viewerPanel.classList.add("panning");
|
||||||
canvasEditor.focus();
|
canvasEditor.focus();
|
||||||
@@ -6317,7 +6602,8 @@ function movePan(event) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
el.viewerPanel.scrollLeft = pan.x - event.clientX;
|
el.viewerPanel.scrollLeft = pan.x - event.clientX;
|
||||||
el.viewerPanel.scrollTop = pan.y - event.clientY;
|
if (state.pageWindow) syncPageWindow(state.pageWindow, pan.y - event.clientY);
|
||||||
|
else el.viewerPanel.scrollTop = pan.y - event.clientY;
|
||||||
}
|
}
|
||||||
|
|
||||||
function endPan(event) {
|
function endPan(event) {
|
||||||
@@ -6349,13 +6635,17 @@ function fitWidthScale(page) {
|
|||||||
const width = Math.max(1, page.width * MM_TO_PX);
|
const width = Math.max(1, page.width * MM_TO_PX);
|
||||||
let available = Math.max(1, el.viewerPanel.clientWidth - space * 2);
|
let available = Math.max(1, el.viewerPanel.clientWidth - space * 2);
|
||||||
if (!viewerHasVerticalScrollbar()) {
|
if (!viewerHasVerticalScrollbar()) {
|
||||||
const height = state.continuous
|
const limit = Math.max(1, el.viewerPanel.clientHeight - space * 2);
|
||||||
? state.doc.pages.reduce((total, item) => {
|
let height = Math.max(1, page.height * MM_TO_PX) * (available / width);
|
||||||
|
if (state.continuous) {
|
||||||
|
height = 0;
|
||||||
|
for (const item of state.doc.pages) {
|
||||||
const size = pageViewSize(item);
|
const size = pageViewSize(item);
|
||||||
return total + available * size.height / size.width;
|
height += available * size.height / size.width;
|
||||||
}, 0)
|
if (height > limit) break;
|
||||||
: Math.max(1, page.height * MM_TO_PX) * (available / width);
|
}
|
||||||
if (height > Math.max(1, el.viewerPanel.clientHeight - space * 2)) {
|
}
|
||||||
|
if (height > limit) {
|
||||||
available = Math.max(1, available - scrollbarWidth());
|
available = Math.max(1, available - scrollbarWidth());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6423,6 +6713,7 @@ function setScale(nextScale, updateStatus = true, fitMode = "free") {
|
|||||||
layoutPages();
|
layoutPages();
|
||||||
}
|
}
|
||||||
updateFitSpace();
|
updateFitSpace();
|
||||||
|
if (state.pageWindow && state.pageWindow.gap !== (Number.parseFloat(getComputedStyle(el.svgHost).rowGap) || 0)) layoutPages();
|
||||||
if (layoutChanged) {
|
if (layoutChanged) {
|
||||||
restoreScaleAnchor(anchor);
|
restoreScaleAnchor(anchor);
|
||||||
}
|
}
|
||||||
@@ -6456,6 +6747,11 @@ function restoreScaleAnchor(anchor) {
|
|||||||
scrollToPage(anchor.index);
|
scrollToPage(anchor.index);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (state.pageWindow) {
|
||||||
|
const view = state.pageWindow;
|
||||||
|
const height = 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);
|
const shell = pageShell(anchor.index);
|
||||||
if (!shell) {
|
if (!shell) {
|
||||||
return;
|
return;
|
||||||
@@ -6463,7 +6759,7 @@ function restoreScaleAnchor(anchor) {
|
|||||||
const viewerRect = el.viewerPanel.getBoundingClientRect();
|
const viewerRect = el.viewerPanel.getBoundingClientRect();
|
||||||
const shellRect = shell.getBoundingClientRect();
|
const shellRect = shell.getBoundingClientRect();
|
||||||
el.viewerPanel.scrollLeft += shellRect.left + shellRect.width * anchor.x - viewerRect.left - viewerRect.width / 2;
|
el.viewerPanel.scrollLeft += shellRect.left + shellRect.width * anchor.x - viewerRect.left - viewerRect.width / 2;
|
||||||
el.viewerPanel.scrollTop += shellRect.top + shellRect.height * anchor.y - viewerRect.top - viewerRect.height * anchor.viewY;
|
scrollViewerBy(shellRect.top + shellRect.height * anchor.y - viewerRect.top - viewerRect.height * anchor.viewY);
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentPageInfo() {
|
function currentPageInfo() {
|
||||||
@@ -6525,6 +6821,7 @@ function updateControls() {
|
|||||||
el.pageInput.disabled = !hasDoc;
|
el.pageInput.disabled = !hasDoc;
|
||||||
el.pageInput.max = String(pageCount || 1);
|
el.pageInput.max = String(pageCount || 1);
|
||||||
el.pageInput.value = String(hasDoc ? state.pageIndex + 1 : 0);
|
el.pageInput.value = String(hasDoc ? state.pageIndex + 1 : 0);
|
||||||
|
el.pageControl.style.setProperty("--page-width", `${String(pageCount || 1).length + 0.5}ch`);
|
||||||
el.zoomOutButton.disabled = !hasDoc || state.scale <= 0.2;
|
el.zoomOutButton.disabled = !hasDoc || state.scale <= 0.2;
|
||||||
el.zoomInButton.disabled = !hasDoc || state.scale >= 4;
|
el.zoomInButton.disabled = !hasDoc || state.scale >= 4;
|
||||||
el.fitButton.disabled = !hasDoc;
|
el.fitButton.disabled = !hasDoc;
|
||||||
|
|||||||
@@ -365,14 +365,14 @@ func configureDocument(args []js.Value) (any, error) {
|
|||||||
return currentSession.Summary(), nil
|
return currentSession.Summary(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// documentInfo 获取字体统计和验签结果,首个参数为true时逐页扫描
|
// documentInfo 获取字体统计和验签结果,首个参数为true时分批扫描
|
||||||
// 入参: args 浏览器参数
|
// 入参: args 浏览器参数
|
||||||
// 返回: any 文档信息, error 错误信息
|
// 返回: any 文档信息, error 错误信息
|
||||||
func documentInfo(args []js.Value) (any, error) {
|
func documentInfo(args []js.Value) (any, error) {
|
||||||
if currentSession == nil {
|
if currentSession == nil {
|
||||||
return nil, fmt.Errorf("ofd document is not opened")
|
return nil, fmt.Errorf("ofd document is not opened")
|
||||||
}
|
}
|
||||||
if len(args) > 0 && args[0].Bool() && !currentSession.scanFontInfo() {
|
if len(args) > 0 && args[0].Bool() && !currentSession.scanFontInfoBatch() {
|
||||||
return map[string]bool{"detailsPending": true}, nil
|
return map[string]bool{"detailsPending": true}, nil
|
||||||
}
|
}
|
||||||
return currentSession.Info(), nil
|
return currentSession.Info(), nil
|
||||||
|
|||||||
+49
-26
@@ -151,7 +151,7 @@ type Session struct {
|
|||||||
fontFS *ofdgo.FontFS
|
fontFS *ofdgo.FontFS
|
||||||
doc *ofdgo.Document
|
doc *ofdgo.Document
|
||||||
pageCache map[int]*ofdgo.PageContent
|
pageCache map[int]*ofdgo.PageContent
|
||||||
boxCache map[int]ofdgo.Box
|
boxCache []pageBoxInfo
|
||||||
textCache map[int]*ofdgo.PageText
|
textCache map[int]*ofdgo.PageText
|
||||||
svgFonts map[string][]byte
|
svgFonts map[string][]byte
|
||||||
fontScan *ofdgo.FontInfoScanner
|
fontScan *ofdgo.FontInfoScanner
|
||||||
@@ -164,6 +164,12 @@ type Session struct {
|
|||||||
editing bool
|
editing bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pageBoxInfo 按页索引缓存真实区域,valid区分未读取与零值区域
|
||||||
|
type pageBoxInfo struct {
|
||||||
|
box ofdgo.Box
|
||||||
|
valid bool
|
||||||
|
}
|
||||||
|
|
||||||
// DocumentInfo 文档信息
|
// DocumentInfo 文档信息
|
||||||
type DocumentInfo struct {
|
type DocumentInfo struct {
|
||||||
Encryption EncryptionInfo `json:"encryption"`
|
Encryption EncryptionInfo `json:"encryption"`
|
||||||
@@ -318,7 +324,7 @@ func newSession(reader *ofdgo.Reader, opts OpenOptions) (*Session, error) {
|
|||||||
fontFS: fontFS,
|
fontFS: fontFS,
|
||||||
doc: doc,
|
doc: doc,
|
||||||
pageCache: make(map[int]*ofdgo.PageContent),
|
pageCache: make(map[int]*ofdgo.PageContent),
|
||||||
boxCache: make(map[int]ofdgo.Box),
|
boxCache: make([]pageBoxInfo, len(doc.Pages.Page)),
|
||||||
textCache: make(map[int]*ofdgo.PageText),
|
textCache: make(map[int]*ofdgo.PageText),
|
||||||
svgFonts: make(map[string][]byte),
|
svgFonts: make(map[string][]byte),
|
||||||
}, nil
|
}, nil
|
||||||
@@ -388,6 +394,25 @@ func (s *Session) scanFontInfo() bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// scanFontInfoBatch 为大文档批量统计字体,限制每次处理的页数与耗时
|
||||||
|
// 返回: bool 是否完成
|
||||||
|
func (s *Session) scanFontInfoBatch() bool {
|
||||||
|
limit := 1
|
||||||
|
if len(s.doc.Pages.Page) > 256 {
|
||||||
|
limit = 64
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
for range limit {
|
||||||
|
if s.scanFontInfo() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if time.Since(start) >= 8*time.Millisecond {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// PageText 获取指定页面文字,复用当前会话的文字索引
|
// PageText 获取指定页面文字,复用当前会话的文字索引
|
||||||
// 入参: index 页面索引
|
// 入参: index 页面索引
|
||||||
// 返回: *ofdgo.PageText 页面文字, error 错误信息
|
// 返回: *ofdgo.PageText 页面文字, error 错误信息
|
||||||
@@ -448,7 +473,7 @@ func (s *Session) Summary() DocumentInfo {
|
|||||||
Version: s.Reader.Version(),
|
Version: s.Reader.Version(),
|
||||||
DocType: s.Reader.DocType(),
|
DocType: s.Reader.DocType(),
|
||||||
PageCount: len(s.doc.Pages.Page),
|
PageCount: len(s.doc.Pages.Page),
|
||||||
Pages: make([]PageInfo, 0, len(s.doc.Pages.Page)),
|
Pages: s.pageInfos(),
|
||||||
Outlines: s.doc.OutlineInfos(),
|
Outlines: s.doc.OutlineInfos(),
|
||||||
DetailsPending: true,
|
DetailsPending: true,
|
||||||
}
|
}
|
||||||
@@ -459,15 +484,6 @@ func (s *Session) Summary() DocumentInfo {
|
|||||||
info.CreationDate = docInfo.CreationDate
|
info.CreationDate = docInfo.CreationDate
|
||||||
info.ModDate = docInfo.ModDate
|
info.ModDate = docInfo.ModDate
|
||||||
}
|
}
|
||||||
for index, page := range s.doc.Pages.Page {
|
|
||||||
box, ok := s.boxCache[index]
|
|
||||||
if !ok {
|
|
||||||
if area, err := s.Reader.PageArea(page); err == nil {
|
|
||||||
box, _ = s.pageBox(index, &ofdgo.PageContent{Area: area})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
info.Pages = append(info.Pages, PageInfo{Index: index, ID: page.ID, Width: box.W, Height: box.H})
|
|
||||||
}
|
|
||||||
if fonts, err := s.Reader.Fonts(); err == nil {
|
if fonts, err := s.Reader.Fonts(); err == nil {
|
||||||
for _, font := range fonts {
|
for _, font := range fonts {
|
||||||
info.Fonts = append(info.Fonts, FontInfo{
|
info.Fonts = append(info.Fonts, FontInfo{
|
||||||
@@ -485,6 +501,23 @@ func (s *Session) Summary() DocumentInfo {
|
|||||||
return info
|
return info
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pageInfos 获取全部真实页面尺寸,首屏、配置及详情共用区域缓存
|
||||||
|
// 返回: []PageInfo 独立页面信息列表
|
||||||
|
func (s *Session) pageInfos() []PageInfo {
|
||||||
|
pages := make([]PageInfo, len(s.doc.Pages.Page))
|
||||||
|
for index, page := range s.doc.Pages.Page {
|
||||||
|
cached := s.boxCache[index]
|
||||||
|
box := cached.box
|
||||||
|
if !cached.valid {
|
||||||
|
if area, err := s.Reader.PageArea(page); err == nil {
|
||||||
|
box, _ = s.pageBox(index, &ofdgo.PageContent{Area: area})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pages[index] = PageInfo{Index: index, ID: page.ID, Width: box.W, Height: box.H}
|
||||||
|
}
|
||||||
|
return pages
|
||||||
|
}
|
||||||
|
|
||||||
// Info 获取完整文档信息
|
// Info 获取完整文档信息
|
||||||
// 返回: DocumentInfo 文档信息
|
// 返回: DocumentInfo 文档信息
|
||||||
func (s *Session) Info() DocumentInfo {
|
func (s *Session) Info() DocumentInfo {
|
||||||
@@ -493,7 +526,7 @@ func (s *Session) Info() DocumentInfo {
|
|||||||
Version: s.Reader.Version(),
|
Version: s.Reader.Version(),
|
||||||
DocType: s.Reader.DocType(),
|
DocType: s.Reader.DocType(),
|
||||||
PageCount: len(s.doc.Pages.Page),
|
PageCount: len(s.doc.Pages.Page),
|
||||||
Pages: make([]PageInfo, 0, len(s.doc.Pages.Page)),
|
Pages: s.pageInfos(),
|
||||||
Outlines: s.doc.OutlineInfos(),
|
Outlines: s.doc.OutlineInfos(),
|
||||||
}
|
}
|
||||||
if attachments, err := s.Reader.Attachments(); err == nil {
|
if attachments, err := s.Reader.Attachments(); err == nil {
|
||||||
@@ -524,16 +557,6 @@ func (s *Session) Info() DocumentInfo {
|
|||||||
info.CreationDate = docInfo.CreationDate
|
info.CreationDate = docInfo.CreationDate
|
||||||
info.ModDate = docInfo.ModDate
|
info.ModDate = docInfo.ModDate
|
||||||
}
|
}
|
||||||
for index, pageRef := range s.doc.Pages.Page {
|
|
||||||
pageInfo := PageInfo{Index: index, ID: pageRef.ID}
|
|
||||||
if area, err := s.Reader.PageArea(pageRef); err == nil {
|
|
||||||
if box, err := s.pageBox(index, &ofdgo.PageContent{Area: area}); err == nil {
|
|
||||||
pageInfo.Width = box.W
|
|
||||||
pageInfo.Height = box.H
|
|
||||||
}
|
|
||||||
}
|
|
||||||
info.Pages = append(info.Pages, pageInfo)
|
|
||||||
}
|
|
||||||
for !s.scanFontInfo() {
|
for !s.scanFontInfo() {
|
||||||
}
|
}
|
||||||
info.Fonts = s.fontInfos
|
info.Fonts = s.fontInfos
|
||||||
@@ -891,13 +914,13 @@ func (s *Session) pageContent(index int) (*ofdgo.PageContent, error) {
|
|||||||
// 入参: index 页面索引, page 页面内容
|
// 入参: index 页面索引, page 页面内容
|
||||||
// 返回: ofdgo.Box 页面物理区域, error 错误信息
|
// 返回: ofdgo.Box 页面物理区域, error 错误信息
|
||||||
func (s *Session) pageBox(index int, page *ofdgo.PageContent) (ofdgo.Box, error) {
|
func (s *Session) pageBox(index int, page *ofdgo.PageContent) (ofdgo.Box, error) {
|
||||||
if box, ok := s.boxCache[index]; ok {
|
if cached := s.boxCache[index]; cached.valid {
|
||||||
return box, nil
|
return cached.box, nil
|
||||||
}
|
}
|
||||||
box, err := s.Renderer.GetPageBox(page)
|
box, err := s.Renderer.GetPageBox(page)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ofdgo.Box{}, err
|
return ofdgo.Box{}, err
|
||||||
}
|
}
|
||||||
s.boxCache[index] = box
|
s.boxCache[index] = pageBoxInfo{box: box, valid: true}
|
||||||
return box, nil
|
return box, nil
|
||||||
}
|
}
|
||||||
@@ -313,6 +313,9 @@ func (e *Editor) appendAnnotations(index int, annotations []byte) error {
|
|||||||
// annotationFiles 获取仍被引用的注解文件,保留原文件中的未知页引用
|
// annotationFiles 获取仍被引用的注解文件,保留原文件中的未知页引用
|
||||||
// 返回: []string 文件路径
|
// 返回: []string 文件路径
|
||||||
func (e *Editor) annotationFiles() []string {
|
func (e *Editor) annotationFiles() []string {
|
||||||
|
if len(e.source.reader.annotationFiles) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
removed := make(map[string]bool)
|
removed := make(map[string]bool)
|
||||||
for id := range e.source.pages {
|
for id := range e.source.pages {
|
||||||
removed[id] = true
|
removed[id] = true
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ import (
|
|||||||
// 入参: edit 批量修改回调
|
// 入参: edit 批量修改回调
|
||||||
// 返回: error 修改错误
|
// 返回: error 修改错误
|
||||||
func (e *Editor) Transaction(edit func(*Editor) error) error {
|
func (e *Editor) Transaction(edit func(*Editor) error) error {
|
||||||
before := e.transactionSnapshot()
|
before := *e
|
||||||
|
before.Info = cloneEditorData(e.Info)
|
||||||
next := e.transactionSnapshot()
|
next := e.transactionSnapshot()
|
||||||
next.history, next.historyIndex, next.historyLimit = nil, 0, 0
|
next.history, next.historyIndex, next.historyLimit = nil, 0, 0
|
||||||
*e = next
|
*e = next
|
||||||
@@ -32,10 +33,10 @@ func (e *Editor) Transaction(edit func(*Editor) error) error {
|
|||||||
*e = before
|
*e = before
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
next = e.transactionSnapshot()
|
changed := e.revision != before.revision
|
||||||
*e = before
|
e.history, e.historyIndex, e.historyLimit = before.history, before.historyIndex, before.historyLimit
|
||||||
changed := next.revision != before.revision
|
e.backends, e.fontFS = before.backends, before.fontFS
|
||||||
e.restoreTransaction(next)
|
e.fontRenderer, e.fontMetrics = before.fontRenderer, before.fontMetrics
|
||||||
if changed {
|
if changed {
|
||||||
e.serial, e.revision = before.serial, before.revision
|
e.serial, e.revision = before.serial, before.revision
|
||||||
if change := e.recordChange(); change != nil {
|
if change := e.recordChange(); change != nil {
|
||||||
|
|||||||
+55
-9
@@ -21,8 +21,10 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"image"
|
"image"
|
||||||
"io"
|
"io"
|
||||||
|
"maps"
|
||||||
"path"
|
"path"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -117,14 +119,12 @@ type PageCapabilities struct {
|
|||||||
// 页面按需解析,未修改条目直接保留;编辑器及其Reader快照使用期间不得关闭输入Reader
|
// 页面按需解析,未修改条目直接保留;编辑器及其Reader快照使用期间不得关闭输入Reader
|
||||||
// 返回: *Editor 编辑器, error 错误信息
|
// 返回: *Editor 编辑器, error 错误信息
|
||||||
func (r *Reader) Editor() (*Editor, error) {
|
func (r *Reader) Editor() (*Editor, error) {
|
||||||
reader := &Reader{Zip: r.Zip, files: r.files, encryption: r.encryption}
|
_, err := r.Doc()
|
||||||
if err := reader.initRoot(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
doc, err := reader.Doc()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
reader := cloneEditorReader(r)
|
||||||
|
doc := reader.doc
|
||||||
maximum := doc.CommonData.MaxUnitID
|
maximum := doc.CommonData.MaxUnitID
|
||||||
if maximum < 0 {
|
if maximum < 0 {
|
||||||
return nil, fmt.Errorf("invalid MaxUnitID")
|
return nil, fmt.Errorf("invalid MaxUnitID")
|
||||||
@@ -137,17 +137,60 @@ func (r *Reader) Editor() (*Editor, error) {
|
|||||||
e.encryption = r.encryption
|
e.encryption = r.encryption
|
||||||
e.Info, e.maxID = cloneEditorData(*info), maximum
|
e.Info, e.maxID = cloneEditorData(*info), maximum
|
||||||
directory := cleanPackagePath(reader.RootDir)
|
directory := cleanPackagePath(reader.RootDir)
|
||||||
e.source = &editorSource{reader: reader, document: doc, info: cloneEditorData(*info), directory: directory, pages: make(map[string]*editorSourcePage), origins: make(map[string]*editorObjectOrigin)}
|
e.source = &editorSource{reader: reader, document: doc, info: cloneEditorData(*info), directory: directory, pages: make(map[string]*editorSourcePage, len(doc.Pages.Page)), origins: make(map[string]*editorObjectOrigin)}
|
||||||
for _, page := range doc.Pages.Page {
|
e.pages = make([]PageContent, len(doc.Pages.Page))
|
||||||
|
sourcePages := make([]editorSourcePage, len(doc.Pages.Page))
|
||||||
|
for i, page := range doc.Pages.Page {
|
||||||
if page.ID == "" || e.source.pages[page.ID] != nil {
|
if page.ID == "" || e.source.pages[page.ID] != nil {
|
||||||
return nil, fmt.Errorf("missing or duplicate page ID %q", page.ID)
|
return nil, fmt.Errorf("missing or duplicate page ID %q", page.ID)
|
||||||
}
|
}
|
||||||
e.source.pages[page.ID] = &editorSourcePage{ref: page}
|
sourcePages[i].ref = page
|
||||||
e.pages = append(e.pages, PageContent{ID: page.ID})
|
e.source.pages[page.ID] = &sourcePages[i]
|
||||||
|
e.pages[i] = PageContent{ID: page.ID}
|
||||||
}
|
}
|
||||||
return e, nil
|
return e, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cloneEditorReader 复用已验证的只读包索引,独立保存可变文档与读取缓存
|
||||||
|
// 入参: reader 已加载主文档的阅读器
|
||||||
|
// 返回: *Reader 不拥有输入文件的独立阅读器
|
||||||
|
func cloneEditorReader(reader *Reader) *Reader {
|
||||||
|
next := *reader
|
||||||
|
next.Closer = nil
|
||||||
|
next.OFD = cloneEditorData(reader.OFD)
|
||||||
|
doc := *reader.doc
|
||||||
|
pages := doc.Pages.Page
|
||||||
|
doc.Pages.Page = nil
|
||||||
|
doc = cloneEditorData(doc)
|
||||||
|
doc.Pages.Page = slices.Clone(pages)
|
||||||
|
next.doc = &doc
|
||||||
|
next.files = maps.Clone(reader.files)
|
||||||
|
next.ResMap = maps.Clone(reader.ResMap)
|
||||||
|
next.resourcesRead = maps.Clone(reader.resourcesRead)
|
||||||
|
next.resourceFiles = maps.Clone(reader.resourceFiles)
|
||||||
|
next.fontCache = cloneEditorMap(reader.fontCache)
|
||||||
|
next.fontFaces = maps.Clone(reader.fontFaces)
|
||||||
|
next.colorSpaceCache = cloneEditorMap(reader.colorSpaceCache)
|
||||||
|
next.drawParamCache = cloneEditorMap(reader.drawParamCache)
|
||||||
|
next.compositeGraphicUnitCache = cloneEditorMap(reader.compositeGraphicUnitCache)
|
||||||
|
next.pageHeaderCache = maps.Clone(reader.pageHeaderCache)
|
||||||
|
next.Stamps = cloneEditorMap(reader.Stamps)
|
||||||
|
next.Annots = cloneEditorMap(reader.Annots)
|
||||||
|
next.annotationFiles = cloneEditorMap(reader.annotationFiles)
|
||||||
|
return &next
|
||||||
|
}
|
||||||
|
|
||||||
|
// cloneEditorMap 复制对外可变缓存值,避免快照之间共享嵌套切片或指针
|
||||||
|
// 入参: values 原缓存
|
||||||
|
// 返回: map[string]T 独立缓存
|
||||||
|
func cloneEditorMap[T any](values map[string]T) map[string]T {
|
||||||
|
result := maps.Clone(values)
|
||||||
|
for key, value := range result {
|
||||||
|
result[key] = cloneEditorData(value)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
// prepareSourceIDs 首次新增内容前流式核对原包标识,不依赖可能缺失或过期的MaxUnitID
|
// prepareSourceIDs 首次新增内容前流式核对原包标识,不依赖可能缺失或过期的MaxUnitID
|
||||||
// 返回: error 错误信息
|
// 返回: error 错误信息
|
||||||
func (e *Editor) prepareSourceIDs() error {
|
func (e *Editor) prepareSourceIDs() error {
|
||||||
@@ -366,6 +409,8 @@ func (e *Editor) loadSourcePage(index int) error {
|
|||||||
if source == nil || source.original != nil {
|
if source == nil || source.original != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
loaded := *source
|
||||||
|
source = &loaded
|
||||||
reader := e.source.reader
|
reader := e.source.reader
|
||||||
data, err := reader.readFile(reader.ResPath(source.ref.BaseLoc))
|
data, err := reader.readFile(reader.ResPath(source.ref.BaseLoc))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -426,6 +471,7 @@ func (e *Editor) loadSourcePage(index int) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
source.original = page
|
source.original = page
|
||||||
|
e.source.pages[source.ref.ID] = source
|
||||||
e.pages[index] = copyEditorPage(*page)
|
e.pages[index] = copyEditorPage(*page)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
+57
-10
@@ -35,12 +35,14 @@ func (e *Editor) sourceParts(progress editorProgress) (map[string][]byte, error)
|
|||||||
source := e.source
|
source := e.source
|
||||||
reader := source.reader
|
reader := source.reader
|
||||||
pageRefs := make([]Page, len(e.pages))
|
pageRefs := make([]Page, len(e.pages))
|
||||||
|
originalCount := 0
|
||||||
for i, page := range e.pages {
|
for i, page := range e.pages {
|
||||||
if err := progress.report("pages", i, len(e.pages)); err != nil {
|
if err := progress.report("pages", i, len(e.pages)); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
original := source.pages[page.ID]
|
original := source.pages[page.ID]
|
||||||
if original != nil {
|
if original != nil {
|
||||||
|
originalCount++
|
||||||
pageRefs[i] = original.ref
|
pageRefs[i] = original.ref
|
||||||
if original.original == nil {
|
if original.original == nil {
|
||||||
continue
|
continue
|
||||||
@@ -95,8 +97,8 @@ func (e *Editor) sourceParts(progress editorProgress) (map[string][]byte, error)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pagesChanged := !reflect.DeepEqual(pageRefs, source.document.Pages.Page)
|
pagesChanged := !slices.Equal(pageRefs, source.document.Pages.Page)
|
||||||
if len(parts) != 0 || pagesChanged {
|
if pagesChanged || len(resourceFiles) != 0 || len(parts) != 0 && e.maxID != source.document.CommonData.MaxUnitID {
|
||||||
name := reader.ResPath(reader.OFD.DocBody[0].DocRoot)
|
name := reader.ResPath(reader.OFD.DocBody[0].DocRoot)
|
||||||
data, err := reader.readFile(name)
|
data, err := reader.readFile(name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -138,15 +140,18 @@ func (e *Editor) sourceParts(progress editorProgress) (map[string][]byte, error)
|
|||||||
if pages == nil {
|
if pages == nil {
|
||||||
return nil, fmt.Errorf("document has no Pages")
|
return nil, fmt.Errorf("document has no Pages")
|
||||||
}
|
}
|
||||||
var entries [][]byte
|
originals := make(map[string]*editorXML, len(pages.children))
|
||||||
for _, page := range pageRefs {
|
|
||||||
var original *editorXML
|
|
||||||
for _, child := range pages.children {
|
for _, child := range pages.children {
|
||||||
if packageOFDNode(child, "Page") && child.attr("ID") == page.ID {
|
if packageOFDNode(child, "Page") {
|
||||||
original = child
|
id := child.attr("ID")
|
||||||
break
|
if originals[id] == nil {
|
||||||
|
originals[id] = child
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
entries := make([][]byte, 0, len(pageRefs))
|
||||||
|
for _, page := range pageRefs {
|
||||||
|
original := originals[page.ID]
|
||||||
if original != nil {
|
if original != nil {
|
||||||
entries = append(entries, data[original.start:original.end])
|
entries = append(entries, data[original.start:original.end])
|
||||||
} else {
|
} else {
|
||||||
@@ -209,9 +214,11 @@ func (e *Editor) sourceParts(progress editorProgress) (map[string][]byte, error)
|
|||||||
}
|
}
|
||||||
parts[name] = updated
|
parts[name] = updated
|
||||||
}
|
}
|
||||||
|
if originalCount != len(source.pages) || len(source.annotationPages) != 0 {
|
||||||
if err := e.prunePageReferences(parts); err != nil {
|
if err := e.prunePageReferences(parts); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
}
|
||||||
for name, data := range maps.Clone(parts) {
|
for name, data := range maps.Clone(parts) {
|
||||||
if file, ok := reader.packageFile(name); ok {
|
if file, ok := reader.packageFile(name); ok {
|
||||||
actual := cleanPackagePath(file.Name)
|
actual := cleanPackagePath(file.Name)
|
||||||
@@ -481,6 +488,7 @@ func (e *Editor) writeSource(writer io.Writer, fonts map[string][]byte, progress
|
|||||||
archive := zip.NewWriter(output)
|
archive := zip.NewWriter(output)
|
||||||
if reader.Zip != nil {
|
if reader.Zip != nil {
|
||||||
_ = archive.SetComment(reader.Zip.Comment)
|
_ = archive.SetComment(reader.Zip.Comment)
|
||||||
|
buffer := make([]byte, 32*1024)
|
||||||
for _, file := range reader.Zip.File {
|
for _, file := range reader.Zip.File {
|
||||||
if file.FileInfo().IsDir() {
|
if file.FileInfo().IsDir() {
|
||||||
header := file.FileHeader
|
header := file.FileHeader
|
||||||
@@ -503,9 +511,20 @@ func (e *Editor) writeSource(writer io.Writer, fonts map[string][]byte, progress
|
|||||||
return output.count, err
|
return output.count, err
|
||||||
}
|
}
|
||||||
delete(remaining, name)
|
delete(remaining, name)
|
||||||
} else if err := archive.Copy(file); err != nil {
|
} else {
|
||||||
|
input, err := file.OpenRaw()
|
||||||
|
if err != nil {
|
||||||
return output.count, err
|
return output.count, err
|
||||||
}
|
}
|
||||||
|
header := file.FileHeader
|
||||||
|
entry, err := archive.CreateRaw(&header)
|
||||||
|
if err != nil {
|
||||||
|
return output.count, err
|
||||||
|
}
|
||||||
|
if _, err := io.CopyBuffer(entry, input, buffer); err != nil {
|
||||||
|
return output.count, err
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, name := range slices.Sorted(maps.Keys(remaining)) {
|
for _, name := range slices.Sorted(maps.Keys(remaining)) {
|
||||||
@@ -533,6 +552,33 @@ func (e *Editor) sourceReader(progress editorProgress) (*Reader, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
otherParts := maps.Clone(parts)
|
||||||
|
if len(parts) != 0 {
|
||||||
|
for _, page := range e.source.pages {
|
||||||
|
if page.original != nil {
|
||||||
|
name := e.source.reader.ResPath(page.ref.BaseLoc)
|
||||||
|
if file, ok := e.source.reader.packageFile(name); ok {
|
||||||
|
name = cleanPackagePath(file.Name)
|
||||||
|
}
|
||||||
|
delete(otherParts, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(otherParts) == 0 {
|
||||||
|
reader := cloneEditorReader(e.source.reader)
|
||||||
|
if reader.files == nil {
|
||||||
|
reader.files = make(map[string][]byte)
|
||||||
|
}
|
||||||
|
maps.Copy(reader.files, parts)
|
||||||
|
reader.encryption = e.encryption
|
||||||
|
for name := range reader.pageHeaderCache {
|
||||||
|
actual := reader.fileNamesFold[strings.ToLower(name)]
|
||||||
|
if _, changed := parts[actual]; changed {
|
||||||
|
delete(reader.pageHeaderCache, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return reader, nil
|
||||||
|
}
|
||||||
files := maps.Clone(e.source.reader.files)
|
files := maps.Clone(e.source.reader.files)
|
||||||
if files == nil {
|
if files == nil {
|
||||||
files = make(map[string][]byte)
|
files = make(map[string][]byte)
|
||||||
@@ -546,7 +592,8 @@ func (e *Editor) sourceReader(progress editorProgress) (*Reader, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
for name, header := range e.source.reader.pageHeaderCache {
|
for name, header := range e.source.reader.pageHeaderCache {
|
||||||
if _, changed := parts[name]; !changed {
|
actual := e.source.reader.fileNamesFold[strings.ToLower(name)]
|
||||||
|
if _, changed := parts[actual]; !changed {
|
||||||
reader.pageHeaderCache[name] = header
|
reader.pageHeaderCache[name] = header
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-3
@@ -92,9 +92,13 @@ func (r *Reader) initRoot() error {
|
|||||||
// indexPackage 建立包路径索引,拒绝会使显示与验签产生歧义的同名条目
|
// indexPackage 建立包路径索引,拒绝会使显示与验签产生歧义的同名条目
|
||||||
// 返回: error 错误信息
|
// 返回: error 错误信息
|
||||||
func (r *Reader) indexPackage() error {
|
func (r *Reader) indexPackage() error {
|
||||||
r.fileIndex = make(map[string]*zip.File)
|
count := 0
|
||||||
r.fileIndexFold = make(map[string]*zip.File)
|
if r.Zip != nil {
|
||||||
r.fileNamesFold = make(map[string]string)
|
count = len(r.Zip.File)
|
||||||
|
}
|
||||||
|
r.fileIndex = make(map[string]*zip.File, count)
|
||||||
|
r.fileIndexFold = make(map[string]*zip.File, count)
|
||||||
|
r.fileNamesFold = make(map[string]string, count+len(r.files))
|
||||||
if r.Zip != nil {
|
if r.Zip != nil {
|
||||||
for _, f := range r.Zip.File {
|
for _, f := range r.Zip.File {
|
||||||
if f.FileInfo().IsDir() {
|
if f.FileInfo().IsDir() {
|
||||||
|
|||||||
Reference in new issue
Block a user