mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-29 17:27:08 +08:00
This commit is contained in:
1 parent
e3908c1149
commit
46e6d5b622
9 files changed
+611
-152
No files matched your search
+40
-2
@@ -568,7 +568,7 @@ button:not(:disabled):hover {
|
||||
|
||||
.page-control {
|
||||
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;
|
||||
gap: 6px;
|
||||
height: var(--control-height);
|
||||
@@ -1303,6 +1303,39 @@ button.outline-link:hover {
|
||||
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 {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
@@ -1398,6 +1431,7 @@ button.outline-link:hover {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
overflow-anchor: none;
|
||||
background:
|
||||
linear-gradient(90deg, 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;
|
||||
}
|
||||
|
||||
.svg-host.virtual-pages {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.page-shell {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
@@ -2613,7 +2651,7 @@ button.outline-link:hover {
|
||||
}
|
||||
|
||||
.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;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
+388
-91
@@ -6,6 +6,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 SCROLL_SEGMENT_SIZE = 1000000;
|
||||
const OBJECT_CLIPBOARD_TYPE = "application/x-ofdgo-objects";
|
||||
const STATUS = {
|
||||
ready: "选择 OFD 文件",
|
||||
@@ -100,6 +101,11 @@ const state = {
|
||||
pageObserver: null,
|
||||
visiblePages: new Set(),
|
||||
scrollFrame: 0,
|
||||
pageWindow: null,
|
||||
thumbnailWindow: null,
|
||||
thumbnailFrame: 0,
|
||||
pageDragIndex: null,
|
||||
pageListCurrent: null,
|
||||
thumbnailInFlight: new Set(),
|
||||
thumbnailObserver: null,
|
||||
visibleThumbnails: new Set(),
|
||||
@@ -334,6 +340,7 @@ const el = {
|
||||
localFontButton: document.querySelector("#localFontButton"),
|
||||
prevButton: document.querySelector("#prevButton"),
|
||||
nextButton: document.querySelector("#nextButton"),
|
||||
pageControl: document.querySelector(".page-control"),
|
||||
pageInput: document.querySelector("#pageInput"),
|
||||
pageTotal: document.querySelector("#pageTotal"),
|
||||
zoomOutButton: document.querySelector("#zoomOutButton"),
|
||||
@@ -1172,6 +1179,28 @@ COMPACT_LAYOUT.addEventListener("change", syncLayoutMode);
|
||||
el.viewerPanel.addEventListener("scroll", () => {
|
||||
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("pointermove", movePan);
|
||||
el.viewerPanel.addEventListener("pointerup", endPan);
|
||||
@@ -1229,6 +1258,24 @@ function handleKeyDown(event) {
|
||||
if (document.body.hasAttribute("aria-busy") || formDialogOpen()) {
|
||||
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 (state.editing && !target.closest("input, textarea, select, [contenteditable]")
|
||||
&& (key.toLowerCase() === "z" || (!event.shiftKey && key.toLowerCase() === "y"))) {
|
||||
@@ -2519,11 +2566,8 @@ async function refreshEditorPage(doc, index, openSeq, clearSelection = false) {
|
||||
renderMeta(true);
|
||||
updateControls();
|
||||
loadDocumentDetails(openSeq);
|
||||
for (const page of doc.pages) {
|
||||
observeFlowPage(pageShell(page.index));
|
||||
const button = el.pageList.querySelector(`[data-page-index="${page.index}"]`);
|
||||
observeThumbnail(button, page.index, openSeq);
|
||||
}
|
||||
for (const shell of el.svgHost.children) observeFlowPage(shell);
|
||||
for (const button of el.pageList.children) observeThumbnail(button, Number(button.dataset.pageIndex), openSeq);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2627,6 +2671,7 @@ function syncLayoutMode(event) {
|
||||
|
||||
function resizeViewer() {
|
||||
if (state.doc) {
|
||||
syncThumbnailWindow();
|
||||
applyFit(false);
|
||||
if (state.fitMode !== "free") {
|
||||
scrollToPage(state.pageIndex);
|
||||
@@ -3551,6 +3596,7 @@ async function openDocument(options = {}) {
|
||||
return;
|
||||
}
|
||||
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) : [];
|
||||
if (!state.ready || state.wasmExited) {
|
||||
await ensureWASM();
|
||||
@@ -3658,6 +3704,8 @@ async function openDocument(options = {}) {
|
||||
restoreScaleAnchor(options.previewAnchor);
|
||||
} else if (options.previewScroll) {
|
||||
Object.assign(el.viewerPanel, options.previewScroll);
|
||||
if (previewPosition && state.pageWindow) syncPageWindow(state.pageWindow,
|
||||
state.pageWindow.offsets[previewPosition.index] + previewPosition.offset);
|
||||
} else {
|
||||
scrollToPage(pageIndex);
|
||||
}
|
||||
@@ -3742,6 +3790,8 @@ async function renderPage(index, options = {}) {
|
||||
}
|
||||
if (options.scroll !== false) {
|
||||
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 });
|
||||
if (openSeq !== state.openSeq || index !== state.pageIndex) {
|
||||
@@ -4096,8 +4146,123 @@ function cancelExport() {
|
||||
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() {
|
||||
el.svgHost.replaceChildren();
|
||||
state.pageWindow = null;
|
||||
state.visiblePages.clear();
|
||||
el.viewerPanel.classList.remove("single-page-fits-height");
|
||||
if (!state.doc) {
|
||||
return;
|
||||
@@ -4105,31 +4270,10 @@ function renderPageFlow() {
|
||||
if (state.pageObserver) {
|
||||
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.pageFrame.hidden = false;
|
||||
state.pageWindow = createPageWindow(el.svgHost, el.viewerPanel, 600, createFlowShell, releaseFlowShell, observeFlowPage);
|
||||
layoutPages();
|
||||
}
|
||||
|
||||
function resetPageFlow(keepCache = false) {
|
||||
@@ -4157,9 +4301,14 @@ function resetPageLoading() {
|
||||
}
|
||||
|
||||
function observeFlowPage(shell) {
|
||||
if (!shell) return;
|
||||
const observer = flowPageObserver();
|
||||
if (observer) {
|
||||
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) {
|
||||
const index = Number.parseInt(entry.target.dataset.pageIndex, 10);
|
||||
if (state.pageWindow && state.pageWindow.nodes.get(index) !== entry.target) continue;
|
||||
if (!entry.isIntersecting) {
|
||||
state.visiblePages.delete(index);
|
||||
unmountPage(index);
|
||||
@@ -4195,6 +4345,7 @@ function flowPageObserver() {
|
||||
|
||||
function unmountPage(index) {
|
||||
const shell = pageShell(index);
|
||||
if (!shell) return;
|
||||
const selection = document.getSelection();
|
||||
const range = !selection.isCollapsed && selection.rangeCount ? selection.getRangeAt(0) : null;
|
||||
if (canvasEditor.input?.item.index === index || canvasEditor.crop?.item.index === index
|
||||
@@ -4219,7 +4370,7 @@ async function renderFlowPage(index, options = {}) {
|
||||
}
|
||||
if (page) {
|
||||
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);
|
||||
}
|
||||
updateThumbnail(index, openSeq);
|
||||
@@ -4337,6 +4488,9 @@ async function processPageRenderQueue() {
|
||||
|
||||
function trimPageCache() {
|
||||
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()) {
|
||||
protectedPages.add(task.index);
|
||||
}
|
||||
@@ -4547,9 +4701,10 @@ async function navigateDestination(dest) {
|
||||
point.x = Math.min(point.x, bottom.x);
|
||||
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();
|
||||
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) {
|
||||
@@ -4834,13 +4989,19 @@ function queueNearbyPages(index, openSeq = state.openSeq) {
|
||||
}
|
||||
|
||||
function pageShell(index) {
|
||||
return el.svgHost.children.item(index);
|
||||
return state.pageWindow?.nodes.get(index) || null;
|
||||
}
|
||||
|
||||
function scrollToPage(index) {
|
||||
if (state.pageWindow) syncPageWindow(state.pageWindow, state.pageWindow.offsets[index]);
|
||||
const shell = pageShell(index);
|
||||
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" });
|
||||
} else {
|
||||
const viewerRect = el.viewerPanel.getBoundingClientRect();
|
||||
@@ -4870,6 +5031,7 @@ function schedulePageSync() {
|
||||
}
|
||||
|
||||
function syncCurrentPageFromScroll() {
|
||||
syncPageWindow(state.pageWindow);
|
||||
const shell = pageShellFromView();
|
||||
if (!shell) {
|
||||
return;
|
||||
@@ -4879,6 +5041,17 @@ function syncCurrentPageFromScroll() {
|
||||
setCurrentPage(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() {
|
||||
@@ -4917,6 +5090,11 @@ function setCurrentPage(index) {
|
||||
}
|
||||
|
||||
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]");
|
||||
if (current) {
|
||||
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}"]`);
|
||||
if (next) {
|
||||
setPageItemCurrent(next, true);
|
||||
if (state.showPages && !el.pageList.hidden) {
|
||||
if (state.showPages && !el.pageList.hidden && !state.thumbnailWindow) {
|
||||
const panel = el.navigationContent.getBoundingClientRect();
|
||||
const item = next.getBoundingClientRect();
|
||||
if (item.top < panel.top) {
|
||||
@@ -4952,12 +5130,25 @@ function layoutPages() {
|
||||
return;
|
||||
}
|
||||
canvasEditor.cancel();
|
||||
for (const page of state.doc.pages || []) {
|
||||
const shell = pageShell(page.index);
|
||||
if (shell) {
|
||||
layoutPageShell(shell, page);
|
||||
}
|
||||
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 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) {
|
||||
@@ -5057,6 +5248,7 @@ function showNavigation(selected) {
|
||||
}
|
||||
el.pageSelectionTools.hidden = !state.editing || !state.pageMultiSelect || selected !== el.pagesTab;
|
||||
el.navigationContent.scrollTop = state.navigationScroll.get(selected) || 0;
|
||||
if (selected === el.pagesTab) syncThumbnailWindow();
|
||||
}
|
||||
|
||||
function renderOutlines(reset = true) {
|
||||
@@ -5212,7 +5404,7 @@ async function selectSearchMatch(index) {
|
||||
if (mark) {
|
||||
const box = mark.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) {
|
||||
el.viewerPanel.scrollLeft += box.left - viewer.left;
|
||||
} else if (box.right > viewer.left + el.viewerPanel.clientWidth) {
|
||||
@@ -5456,6 +5648,18 @@ el.pageList.addEventListener("contextmenu", 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 ((event.ctrlKey || event.metaKey) && !event.altKey && event.key.toLowerCase() === "a") {
|
||||
state.pageSelection = new Set(state.doc.pages.map(page => page.id));
|
||||
@@ -5472,6 +5676,9 @@ el.pageList.addEventListener("keydown", event => {
|
||||
|
||||
function renderPageList() {
|
||||
el.pageList.replaceChildren();
|
||||
state.thumbnailWindow = null;
|
||||
state.pageListCurrent = null;
|
||||
state.visibleThumbnails.clear();
|
||||
if (!state.doc) {
|
||||
return;
|
||||
}
|
||||
@@ -5480,41 +5687,98 @@ function renderPageList() {
|
||||
}
|
||||
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);
|
||||
const fragment = document.createDocumentFragment();
|
||||
const thumbnailTargets = [];
|
||||
const openSeq = state.openSeq;
|
||||
for (const page of state.doc.pages || []) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "page-list-item";
|
||||
button.dataset.pageIndex = String(page.index);
|
||||
button.title = `${formatSize(page.width)} x ${formatSize(page.height)} mm`;
|
||||
setPageItemCurrent(button, page.index === state.pageIndex);
|
||||
layoutThumbnail(button, page);
|
||||
|
||||
const thumb = document.createElement("span");
|
||||
thumb.className = "thumb-paper";
|
||||
thumb.setAttribute("aria-hidden", "true");
|
||||
setThumbnailContent(thumb, state.pageCache.get(page.index)?.svg, page.index, openSeq);
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.className = "thumb-label";
|
||||
label.textContent = `第 ${page.index + 1} 页`;
|
||||
|
||||
const size = document.createElement("span");
|
||||
size.className = "thumb-size";
|
||||
size.textContent = `${formatSize(page.width)} x ${formatSize(page.height)} mm`;
|
||||
|
||||
button.append(thumb, label, size);
|
||||
if (state.editing && pageCan("move", page.index)) enablePageDrag(button, page.index);
|
||||
button.addEventListener("click", event => selectThumbnailPage(event, page.index));
|
||||
fragment.append(button);
|
||||
thumbnailTargets.push([button, page.index]);
|
||||
}
|
||||
el.pageList.append(fragment);
|
||||
state.thumbnailWindow = createPageWindow(el.pageList, el.navigationContent, 180, createThumbnail, releaseThumbnail, observeThumbnail);
|
||||
layoutThumbnails();
|
||||
syncPageSelection();
|
||||
for (const [button, index] of thumbnailTargets) {
|
||||
observeThumbnail(button, index, openSeq);
|
||||
}
|
||||
|
||||
function createThumbnail(page) {
|
||||
const openSeq = state.openSeq;
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "page-list-item";
|
||||
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`;
|
||||
setPageItemCurrent(button, page.index === state.pageIndex);
|
||||
layoutThumbnail(button, page);
|
||||
|
||||
const thumb = document.createElement("span");
|
||||
thumb.className = "thumb-paper";
|
||||
thumb.setAttribute("aria-hidden", "true");
|
||||
setThumbnailContent(thumb, state.pageCache.get(page.index)?.svg, page.index, openSeq);
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.className = "thumb-label";
|
||||
label.textContent = `第 ${page.index + 1} 页`;
|
||||
|
||||
const size = document.createElement("span");
|
||||
size.className = "thumb-size";
|
||||
size.textContent = `${formatSize(page.width)} x ${formatSize(page.height)} mm`;
|
||||
|
||||
button.append(thumb, label, size);
|
||||
if (state.editing && pageCan("move", page.index)) enablePageDrag(button, page.index);
|
||||
button.addEventListener("click", event => selectThumbnailPage(event, page.index));
|
||||
button.style.height = `${state.thumbnailWindow.offsets[page.index + 1] - state.thumbnailWindow.offsets[page.index] - 12}px`;
|
||||
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;
|
||||
}
|
||||
view.offsets = offsets;
|
||||
view.gap = 12;
|
||||
view.total = Math.max(0, offsets.at(-1) - 12);
|
||||
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();
|
||||
const seq = state.openSeq, pointer = event.pointerId;
|
||||
const indexes = selectDragPages(index);
|
||||
state.pageDragIndex = index;
|
||||
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 locate = () => {
|
||||
@@ -5575,7 +5840,11 @@ function enablePageDrag(button, index) {
|
||||
if (dragging) {
|
||||
const rect = el.navigationContent.getBoundingClientRect();
|
||||
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();
|
||||
}
|
||||
frame = requestAnimationFrame(tick);
|
||||
@@ -5588,6 +5857,7 @@ function enablePageDrag(button, index) {
|
||||
if (dragging) locate();
|
||||
};
|
||||
const finish = async commit => {
|
||||
state.pageDragIndex = null;
|
||||
cancelAnimationFrame(frame);
|
||||
grip.removeEventListener("pointermove", move);
|
||||
grip.removeEventListener("pointerup", up);
|
||||
@@ -5616,7 +5886,10 @@ function enablePageDrag(button, index) {
|
||||
const target = direction < 0 ? indexes[0] - 1 : indexes.at(-1) + 1;
|
||||
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);
|
||||
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) {
|
||||
if (!button) return;
|
||||
const observer = thumbnailObserver();
|
||||
if (observer) {
|
||||
observer.observe(button);
|
||||
}
|
||||
if (index === state.pageIndex || !observer && index < 8) {
|
||||
if (index === state.pageIndex || !observer) {
|
||||
if (!observer) state.visibleThumbnails.add(index);
|
||||
renderThumbnail(index, openSeq);
|
||||
}
|
||||
}
|
||||
@@ -5679,6 +5954,7 @@ function thumbnailObserver() {
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const index = Number.parseInt(entry.target.dataset.pageIndex, 10);
|
||||
if (state.thumbnailWindow && state.thumbnailWindow.nodes.get(index) !== entry.target) continue;
|
||||
if (!entry.isIntersecting) {
|
||||
state.visibleThumbnails.delete(index);
|
||||
const thumb = entry.target.querySelector(".thumb-paper");
|
||||
@@ -6014,8 +6290,8 @@ function signatureReferenceStatus(signature) {
|
||||
}
|
||||
|
||||
function formatDocumentTime(value) {
|
||||
const text = String(value || "").trim();
|
||||
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", " ");
|
||||
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 /^0001[-/]0?1[-/]0?1(?: 0{1,2}:00:00(?:\.0+)?(?:Z|[+-]00:?00)?)?$/.test(text) ? "" : text;
|
||||
}
|
||||
|
||||
function appendInfoLine(row, label, value, status = "") {
|
||||
@@ -6079,7 +6355,13 @@ function highlightPageRegion(region) {
|
||||
mark.style.width = `${region.width * MM_TO_PX}px`;
|
||||
mark.style.height = `${region.height * MM_TO_PX}px`;
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -6249,6 +6531,10 @@ function setContinuous(continuous) {
|
||||
function toggleContinuous() {
|
||||
const anchor = state.fitMode === "free" ? scaleAnchor() : null;
|
||||
setContinuous(!state.continuous);
|
||||
if (state.pageWindow) {
|
||||
updateFitSpace();
|
||||
layoutPages();
|
||||
}
|
||||
clearRegionHighlights();
|
||||
if (state.fitMode === "free") {
|
||||
restoreScaleAnchor(anchor);
|
||||
@@ -6266,9 +6552,7 @@ function rotatePages() {
|
||||
clearRegionHighlights();
|
||||
el.viewerPanel.classList.remove("single-page-fits-height");
|
||||
layoutPages();
|
||||
for (const page of state.doc.pages) {
|
||||
layoutThumbnail(el.pageList.children.item(page.index), page);
|
||||
}
|
||||
layoutThumbnails();
|
||||
if (state.fitMode === "free") {
|
||||
updateFitSpace();
|
||||
} else {
|
||||
@@ -6305,7 +6589,8 @@ function startPan(event) {
|
||||
return;
|
||||
}
|
||||
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.classList.add("panning");
|
||||
canvasEditor.focus();
|
||||
@@ -6317,7 +6602,8 @@ function movePan(event) {
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
@@ -6349,13 +6635,17 @@ function fitWidthScale(page) {
|
||||
const width = Math.max(1, page.width * MM_TO_PX);
|
||||
let available = Math.max(1, el.viewerPanel.clientWidth - space * 2);
|
||||
if (!viewerHasVerticalScrollbar()) {
|
||||
const height = state.continuous
|
||||
? state.doc.pages.reduce((total, item) => {
|
||||
const limit = Math.max(1, el.viewerPanel.clientHeight - space * 2);
|
||||
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);
|
||||
return total + available * size.height / size.width;
|
||||
}, 0)
|
||||
: Math.max(1, page.height * MM_TO_PX) * (available / width);
|
||||
if (height > Math.max(1, el.viewerPanel.clientHeight - space * 2)) {
|
||||
height += available * size.height / size.width;
|
||||
if (height > limit) break;
|
||||
}
|
||||
}
|
||||
if (height > limit) {
|
||||
available = Math.max(1, available - scrollbarWidth());
|
||||
}
|
||||
}
|
||||
@@ -6423,6 +6713,7 @@ function setScale(nextScale, updateStatus = true, fitMode = "free") {
|
||||
layoutPages();
|
||||
}
|
||||
updateFitSpace();
|
||||
if (state.pageWindow && state.pageWindow.gap !== (Number.parseFloat(getComputedStyle(el.svgHost).rowGap) || 0)) layoutPages();
|
||||
if (layoutChanged) {
|
||||
restoreScaleAnchor(anchor);
|
||||
}
|
||||
@@ -6456,6 +6747,11 @@ function restoreScaleAnchor(anchor) {
|
||||
scrollToPage(anchor.index);
|
||||
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);
|
||||
if (!shell) {
|
||||
return;
|
||||
@@ -6463,7 +6759,7 @@ function restoreScaleAnchor(anchor) {
|
||||
const viewerRect = el.viewerPanel.getBoundingClientRect();
|
||||
const shellRect = shell.getBoundingClientRect();
|
||||
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() {
|
||||
@@ -6525,6 +6821,7 @@ function updateControls() {
|
||||
el.pageInput.disabled = !hasDoc;
|
||||
el.pageInput.max = String(pageCount || 1);
|
||||
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.zoomInButton.disabled = !hasDoc || state.scale >= 4;
|
||||
el.fitButton.disabled = !hasDoc;
|
||||
|
||||
Reference in new issue
Block a user