517 lines
16 KiB
JavaScript
517 lines
16 KiB
JavaScript
/* Copyright 2016 Mozilla Foundation
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
|
|
import {
|
|
docStyle,
|
|
PresentationModeState,
|
|
SidebarView,
|
|
toggleCheckedBtn,
|
|
toggleExpandedBtn,
|
|
} from "./ui_utils.js";
|
|
|
|
const SIDEBAR_WIDTH_VAR = "--sidebar-width";
|
|
const SIDEBAR_MIN_WIDTH = 200; // pixels
|
|
const SIDEBAR_RESIZING_CLASS = "sidebarResizing";
|
|
const UI_NOTIFICATION_CLASS = "pdfSidebarNotification";
|
|
|
|
/**
|
|
* @typedef {Object} PDFSidebarOptions
|
|
* @property {PDFSidebarElements} elements - The DOM elements.
|
|
* @property {EventBus} eventBus - The application event bus.
|
|
* @property {IL10n} l10n - The localization service.
|
|
*/
|
|
|
|
/**
|
|
* @typedef {Object} PDFSidebarElements
|
|
* @property {HTMLDivElement} outerContainer - The outer container
|
|
* (encasing both the viewer and sidebar elements).
|
|
* @property {HTMLDivElement} sidebarContainer - The sidebar container
|
|
* (in which the views are placed).
|
|
* @property {HTMLButtonElement} toggleButton - The button used for
|
|
* opening/closing the sidebar.
|
|
* @property {HTMLDivElement} resizer - The DOM element that can be dragged in
|
|
* order to adjust the width of the sidebar.
|
|
* @property {HTMLButtonElement} thumbnailButton - The button used to show
|
|
* the thumbnail view.
|
|
* @property {HTMLButtonElement} outlineButton - The button used to show
|
|
* the outline view.
|
|
* @property {HTMLButtonElement} attachmentsButton - The button used to show
|
|
* the attachments view.
|
|
* @property {HTMLButtonElement} layersButton - The button used to show
|
|
* the layers view.
|
|
* @property {HTMLDivElement} thumbnailView - The container in which
|
|
* the thumbnails are placed.
|
|
* @property {HTMLDivElement} outlineView - The container in which
|
|
* the outline is placed.
|
|
* @property {HTMLDivElement} attachmentsView - The container in which
|
|
* the attachments are placed.
|
|
* @property {HTMLDivElement} layersView - The container in which
|
|
* the layers are placed.
|
|
* @property {HTMLDivElement} outlineOptionsContainer - The container in which
|
|
* the outline view-specific option button(s) are placed.
|
|
* @property {HTMLButtonElement} currentOutlineItemButton - The button used to
|
|
* find the current outline item.
|
|
*/
|
|
|
|
class PDFSidebar {
|
|
#isRTL = false;
|
|
|
|
#mouseMoveBound = this.#mouseMove.bind(this);
|
|
|
|
#mouseUpBound = this.#mouseUp.bind(this);
|
|
|
|
#outerContainerWidth = null;
|
|
|
|
#width = null;
|
|
|
|
/**
|
|
* @param {PDFSidebarOptions} options
|
|
*/
|
|
constructor({ elements, eventBus, l10n }) {
|
|
this.isOpen = false;
|
|
this.active = SidebarView.THUMBS;
|
|
this.isInitialViewSet = false;
|
|
this.isInitialEventDispatched = false;
|
|
|
|
/**
|
|
* Callback used when the sidebar has been opened/closed, to ensure that
|
|
* the viewers (PDFViewer/PDFThumbnailViewer) are updated correctly.
|
|
*/
|
|
this.onToggled = null;
|
|
this.onUpdateThumbnails = null;
|
|
|
|
this.outerContainer = elements.outerContainer;
|
|
this.sidebarContainer = elements.sidebarContainer;
|
|
this.toggleButton = elements.toggleButton;
|
|
this.resizer = elements.resizer;
|
|
|
|
this.thumbnailButton = elements.thumbnailButton;
|
|
this.outlineButton = elements.outlineButton;
|
|
this.attachmentsButton = elements.attachmentsButton;
|
|
this.layersButton = elements.layersButton;
|
|
|
|
this.thumbnailView = elements.thumbnailView;
|
|
this.outlineView = elements.outlineView;
|
|
this.attachmentsView = elements.attachmentsView;
|
|
this.layersView = elements.layersView;
|
|
|
|
this._outlineOptionsContainer = elements.outlineOptionsContainer;
|
|
this._currentOutlineItemButton = elements.currentOutlineItemButton;
|
|
|
|
this.eventBus = eventBus;
|
|
|
|
this.#isRTL = l10n.getDirection() === "rtl";
|
|
this.#addEventListeners();
|
|
}
|
|
|
|
reset() {
|
|
this.isInitialViewSet = false;
|
|
this.isInitialEventDispatched = false;
|
|
|
|
this.#hideUINotification(/* reset = */ true);
|
|
this.switchView(SidebarView.THUMBS);
|
|
|
|
this.outlineButton.disabled = false;
|
|
this.attachmentsButton.disabled = false;
|
|
this.layersButton.disabled = false;
|
|
this._currentOutlineItemButton.disabled = true;
|
|
}
|
|
|
|
/**
|
|
* @type {number} One of the values in {SidebarView}.
|
|
*/
|
|
get visibleView() {
|
|
return this.isOpen ? this.active : SidebarView.NONE;
|
|
}
|
|
|
|
/**
|
|
* @param {number} view - The sidebar view that should become visible,
|
|
* must be one of the values in {SidebarView}.
|
|
*/
|
|
setInitialView(view = SidebarView.NONE) {
|
|
if (this.isInitialViewSet) {
|
|
return;
|
|
}
|
|
this.isInitialViewSet = true;
|
|
|
|
// If the user has already manually opened the sidebar, immediately closing
|
|
// it would be bad UX; also ignore the "unknown" sidebar view value.
|
|
if (view === SidebarView.NONE || view === SidebarView.UNKNOWN) {
|
|
this.#dispatchEvent();
|
|
return;
|
|
}
|
|
this.switchView(view, /* forceOpen = */ true);
|
|
|
|
// Prevent dispatching two back-to-back "sidebarviewchanged" events,
|
|
// since `this.switchView` dispatched the event if the view changed.
|
|
if (!this.isInitialEventDispatched) {
|
|
this.#dispatchEvent();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {number} view - The sidebar view that should be switched to,
|
|
* must be one of the values in {SidebarView}.
|
|
* @param {boolean} [forceOpen] - Ensure that the sidebar is open.
|
|
* The default value is `false`.
|
|
*/
|
|
switchView(view, forceOpen = false) {
|
|
const isViewChanged = view !== this.active;
|
|
let forceRendering = false;
|
|
|
|
switch (view) {
|
|
case SidebarView.NONE:
|
|
if (this.isOpen) {
|
|
this.close();
|
|
}
|
|
return; // Closing will trigger rendering and dispatch the event.
|
|
case SidebarView.THUMBS:
|
|
if (this.isOpen && isViewChanged) {
|
|
forceRendering = true;
|
|
}
|
|
break;
|
|
case SidebarView.OUTLINE:
|
|
if (this.outlineButton.disabled) {
|
|
return;
|
|
}
|
|
break;
|
|
case SidebarView.ATTACHMENTS:
|
|
if (this.attachmentsButton.disabled) {
|
|
return;
|
|
}
|
|
break;
|
|
case SidebarView.LAYERS:
|
|
if (this.layersButton.disabled) {
|
|
return;
|
|
}
|
|
break;
|
|
default:
|
|
console.error(`PDFSidebar.switchView: "${view}" is not a valid view.`);
|
|
return;
|
|
}
|
|
// Update the active view *after* it has been validated above,
|
|
// in order to prevent setting it to an invalid state.
|
|
this.active = view;
|
|
|
|
// Update the CSS classes (and aria attributes), for all buttons and views.
|
|
toggleCheckedBtn(
|
|
this.thumbnailButton,
|
|
view === SidebarView.THUMBS,
|
|
this.thumbnailView
|
|
);
|
|
toggleCheckedBtn(
|
|
this.outlineButton,
|
|
view === SidebarView.OUTLINE,
|
|
this.outlineView
|
|
);
|
|
toggleCheckedBtn(
|
|
this.attachmentsButton,
|
|
view === SidebarView.ATTACHMENTS,
|
|
this.attachmentsView
|
|
);
|
|
toggleCheckedBtn(
|
|
this.layersButton,
|
|
view === SidebarView.LAYERS,
|
|
this.layersView
|
|
);
|
|
|
|
// Finally, update view-specific CSS classes.
|
|
this._outlineOptionsContainer.classList.toggle(
|
|
"hidden",
|
|
view !== SidebarView.OUTLINE
|
|
);
|
|
|
|
if (forceOpen && !this.isOpen) {
|
|
this.open();
|
|
return; // Opening will trigger rendering and dispatch the event.
|
|
}
|
|
if (forceRendering) {
|
|
this.onUpdateThumbnails();
|
|
this.onToggled();
|
|
}
|
|
if (isViewChanged) {
|
|
this.#dispatchEvent();
|
|
}
|
|
}
|
|
|
|
open() {
|
|
if (this.isOpen) {
|
|
return;
|
|
}
|
|
this.isOpen = true;
|
|
toggleExpandedBtn(this.toggleButton, true);
|
|
|
|
this.outerContainer.classList.add("sidebarMoving", "sidebarOpen");
|
|
|
|
if (this.active === SidebarView.THUMBS) {
|
|
this.onUpdateThumbnails();
|
|
}
|
|
this.onToggled();
|
|
this.#dispatchEvent();
|
|
|
|
this.#hideUINotification();
|
|
}
|
|
|
|
close(evt = null) {
|
|
if (!this.isOpen) {
|
|
return;
|
|
}
|
|
this.isOpen = false;
|
|
toggleExpandedBtn(this.toggleButton, false);
|
|
|
|
this.outerContainer.classList.add("sidebarMoving");
|
|
this.outerContainer.classList.remove("sidebarOpen");
|
|
|
|
this.onToggled();
|
|
this.#dispatchEvent();
|
|
|
|
if (evt?.detail > 0) {
|
|
// Remove focus from the toggleButton if it's clicked (see issue 17361).
|
|
this.toggleButton.blur();
|
|
}
|
|
}
|
|
|
|
toggle(evt = null) {
|
|
if (this.isOpen) {
|
|
this.close(evt);
|
|
} else {
|
|
this.open();
|
|
}
|
|
}
|
|
|
|
#dispatchEvent() {
|
|
if (this.isInitialViewSet) {
|
|
this.isInitialEventDispatched ||= true;
|
|
}
|
|
|
|
this.eventBus.dispatch("sidebarviewchanged", {
|
|
source: this,
|
|
view: this.visibleView,
|
|
});
|
|
}
|
|
|
|
#showUINotification() {
|
|
this.toggleButton.setAttribute(
|
|
"data-l10n-id",
|
|
"pdfjs-toggle-sidebar-notification-button"
|
|
);
|
|
|
|
if (!this.isOpen) {
|
|
// Only show the notification on the `toggleButton` if the sidebar is
|
|
// currently closed, to avoid unnecessarily bothering the user.
|
|
this.toggleButton.classList.add(UI_NOTIFICATION_CLASS);
|
|
}
|
|
}
|
|
|
|
#hideUINotification(reset = false) {
|
|
if (this.isOpen || reset) {
|
|
// Only hide the notification on the `toggleButton` if the sidebar is
|
|
// currently open, or when the current PDF document is being closed.
|
|
this.toggleButton.classList.remove(UI_NOTIFICATION_CLASS);
|
|
}
|
|
|
|
if (reset) {
|
|
this.toggleButton.setAttribute(
|
|
"data-l10n-id",
|
|
"pdfjs-toggle-sidebar-button"
|
|
);
|
|
}
|
|
}
|
|
|
|
#addEventListeners() {
|
|
this.sidebarContainer.addEventListener("transitionend", evt => {
|
|
if (evt.target === this.sidebarContainer) {
|
|
this.outerContainer.classList.remove("sidebarMoving");
|
|
// Ensure that rendering is triggered after opening/closing the sidebar.
|
|
this.eventBus.dispatch("resize", { source: this });
|
|
}
|
|
});
|
|
|
|
this.toggleButton.addEventListener("click", evt => {
|
|
this.toggle(evt);
|
|
});
|
|
|
|
// Buttons for switching views.
|
|
this.thumbnailButton.addEventListener("click", () => {
|
|
this.switchView(SidebarView.THUMBS);
|
|
});
|
|
|
|
this.outlineButton.addEventListener("click", () => {
|
|
this.switchView(SidebarView.OUTLINE);
|
|
});
|
|
this.outlineButton.addEventListener("dblclick", () => {
|
|
this.eventBus.dispatch("toggleoutlinetree", { source: this });
|
|
});
|
|
|
|
this.attachmentsButton.addEventListener("click", () => {
|
|
this.switchView(SidebarView.ATTACHMENTS);
|
|
});
|
|
|
|
this.layersButton.addEventListener("click", () => {
|
|
this.switchView(SidebarView.LAYERS);
|
|
});
|
|
this.layersButton.addEventListener("dblclick", () => {
|
|
this.eventBus.dispatch("resetlayers", { source: this });
|
|
});
|
|
|
|
// Buttons for view-specific options.
|
|
this._currentOutlineItemButton.addEventListener("click", () => {
|
|
this.eventBus.dispatch("currentoutlineitem", { source: this });
|
|
});
|
|
|
|
// Disable/enable views.
|
|
const onTreeLoaded = (count, button, view) => {
|
|
button.disabled = !count;
|
|
|
|
if (count) {
|
|
this.#showUINotification();
|
|
} else if (this.active === view) {
|
|
// If the `view` was opened by the user during document load,
|
|
// switch away from it if it turns out to be empty.
|
|
this.switchView(SidebarView.THUMBS);
|
|
}
|
|
};
|
|
|
|
this.eventBus._on("outlineloaded", evt => {
|
|
onTreeLoaded(evt.outlineCount, this.outlineButton, SidebarView.OUTLINE);
|
|
|
|
evt.currentOutlineItemPromise.then(enabled => {
|
|
if (!this.isInitialViewSet) {
|
|
return;
|
|
}
|
|
this._currentOutlineItemButton.disabled = !enabled;
|
|
});
|
|
});
|
|
|
|
this.eventBus._on("attachmentsloaded", evt => {
|
|
onTreeLoaded(
|
|
evt.attachmentsCount,
|
|
this.attachmentsButton,
|
|
SidebarView.ATTACHMENTS
|
|
);
|
|
});
|
|
|
|
this.eventBus._on("layersloaded", evt => {
|
|
onTreeLoaded(evt.layersCount, this.layersButton, SidebarView.LAYERS);
|
|
});
|
|
|
|
// Update the thumbnailViewer, if visible, when exiting presentation mode.
|
|
this.eventBus._on("presentationmodechanged", evt => {
|
|
if (
|
|
evt.state === PresentationModeState.NORMAL &&
|
|
this.visibleView === SidebarView.THUMBS
|
|
) {
|
|
this.onUpdateThumbnails();
|
|
}
|
|
});
|
|
|
|
// Handle resizing of the sidebar.
|
|
this.resizer.addEventListener("mousedown", evt => {
|
|
if (evt.button !== 0) {
|
|
return;
|
|
}
|
|
// Disable the `transition-duration` rules when sidebar resizing begins,
|
|
// in order to improve responsiveness and to avoid visual glitches.
|
|
this.outerContainer.classList.add(SIDEBAR_RESIZING_CLASS);
|
|
|
|
window.addEventListener("mousemove", this.#mouseMoveBound);
|
|
window.addEventListener("mouseup", this.#mouseUpBound);
|
|
});
|
|
|
|
this.eventBus._on("resize", evt => {
|
|
// When the *entire* viewer is resized, such that it becomes narrower,
|
|
// ensure that the sidebar doesn't end up being too wide.
|
|
if (evt.source !== window) {
|
|
return;
|
|
}
|
|
// Always reset the cached width when the viewer is resized.
|
|
this.#outerContainerWidth = null;
|
|
|
|
if (!this.#width) {
|
|
// The sidebar hasn't been resized, hence no need to adjust its width.
|
|
return;
|
|
}
|
|
// NOTE: If the sidebar is closed, we don't need to worry about
|
|
// visual glitches nor ensure that rendering is triggered.
|
|
if (!this.isOpen) {
|
|
this.#updateWidth(this.#width);
|
|
return;
|
|
}
|
|
this.outerContainer.classList.add(SIDEBAR_RESIZING_CLASS);
|
|
const updated = this.#updateWidth(this.#width);
|
|
|
|
Promise.resolve().then(() => {
|
|
this.outerContainer.classList.remove(SIDEBAR_RESIZING_CLASS);
|
|
// Trigger rendering if the sidebar width changed, to avoid
|
|
// depending on the order in which 'resize' events are handled.
|
|
if (updated) {
|
|
this.eventBus.dispatch("resize", { source: this });
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @type {number}
|
|
*/
|
|
get outerContainerWidth() {
|
|
return (this.#outerContainerWidth ||= this.outerContainer.clientWidth);
|
|
}
|
|
|
|
/**
|
|
* returns {boolean} Indicating if the sidebar width was updated.
|
|
*/
|
|
#updateWidth(width = 0) {
|
|
// Prevent the sidebar from becoming too narrow, or from occupying more
|
|
// than half of the available viewer width.
|
|
const maxWidth = Math.floor(this.outerContainerWidth / 2);
|
|
if (width > maxWidth) {
|
|
width = maxWidth;
|
|
}
|
|
if (width < SIDEBAR_MIN_WIDTH) {
|
|
width = SIDEBAR_MIN_WIDTH;
|
|
}
|
|
// Only update the UI when the sidebar width did in fact change.
|
|
if (width === this.#width) {
|
|
return false;
|
|
}
|
|
this.#width = width;
|
|
|
|
docStyle.setProperty(SIDEBAR_WIDTH_VAR, `${width}px`);
|
|
return true;
|
|
}
|
|
|
|
#mouseMove(evt) {
|
|
let width = evt.clientX;
|
|
// For sidebar resizing to work correctly in RTL mode, invert the width.
|
|
if (this.#isRTL) {
|
|
width = this.outerContainerWidth - width;
|
|
}
|
|
this.#updateWidth(width);
|
|
}
|
|
|
|
#mouseUp(evt) {
|
|
// Re-enable the `transition-duration` rules when sidebar resizing ends...
|
|
this.outerContainer.classList.remove(SIDEBAR_RESIZING_CLASS);
|
|
// ... and ensure that rendering will always be triggered.
|
|
this.eventBus.dispatch("resize", { source: this });
|
|
|
|
window.removeEventListener("mousemove", this.#mouseMoveBound);
|
|
window.removeEventListener("mouseup", this.#mouseUpBound);
|
|
}
|
|
}
|
|
|
|
export { PDFSidebar };
|