Parcourir la source

add: 添加放大缩小、页面模式、打印、旋转功能

liutian il y a 2 ans
Parent
commit
5f424524b2
85 fichiers modifiés avec 6272 ajouts et 0 suppressions
  1. 109 0
      packages/core/src/download_manager.js
  2. 93 0
      packages/core/src/fit_curve.js
  3. 138 0
      packages/core/src/password_prompt.js
  4. 722 0
      packages/core/src/pdf_link_service.js
  5. 408 0
      packages/core/src/pdf_presentation_mode.js
  6. 238 0
      packages/core/src/text_accessibility.js
  7. 754 0
      packages/core/src/tools.js
  8. 1585 0
      packages/core/src/ui_utils.js
  9. BIN
      packages/webview/public/example/ComPDFKit Introduction Booklet - PDF Technologies, Inc.pdf
  10. BIN
      packages/webview/public/images/loading-icon.gif
  11. 3 0
      packages/webview/src/assets/icons/icon-annotation.svg
  12. 1 0
      packages/webview/src/assets/icons/icon-arrow.svg
  13. 1 0
      packages/webview/src/assets/icons/icon-compare.svg
  14. 3 0
      packages/webview/src/assets/icons/icon-delete.svg
  15. 3 0
      packages/webview/src/assets/icons/icon-export.svg
  16. 1 0
      packages/webview/src/assets/icons/icon-full-screen.svg
  17. 3 0
      packages/webview/src/assets/icons/icon-hand-tool.svg
  18. 3 0
      packages/webview/src/assets/icons/icon-header-clockwise.svg
  19. 3 0
      packages/webview/src/assets/icons/icon-header-counter-clockwise.svg
  20. 3 0
      packages/webview/src/assets/icons/icon-header-sidebar-line.svg
  21. 3 0
      packages/webview/src/assets/icons/icon-header-zoom-in.svg
  22. 3 0
      packages/webview/src/assets/icons/icon-header-zoom-out.svg
  23. 3 0
      packages/webview/src/assets/icons/icon-import.svg
  24. 1 0
      packages/webview/src/assets/icons/icon-loading.svg
  25. 3 0
      packages/webview/src/assets/icons/icon-next-right.svg
  26. 1 0
      packages/webview/src/assets/icons/icon-open-file.svg
  27. 3 0
      packages/webview/src/assets/icons/icon-outline.svg
  28. 3 0
      packages/webview/src/assets/icons/icon-previous-left.svg
  29. 1 0
      packages/webview/src/assets/icons/icon-ruler.svg
  30. 1 0
      packages/webview/src/assets/icons/icon-save.svg
  31. 3 0
      packages/webview/src/assets/icons/icon-search.svg
  32. 1 0
      packages/webview/src/assets/icons/icon-setting.svg
  33. 3 0
      packages/webview/src/assets/icons/icon-sticky-note.svg
  34. 4 0
      packages/webview/src/assets/icons/icon-thumbnail.svg
  35. 3 0
      packages/webview/src/assets/icons/icon-view-layers.svg
  36. 1 0
      packages/webview/src/assets/logo.svg
  37. 112 0
      packages/webview/src/components/App/index.vue
  38. 84 0
      packages/webview/src/components/Button/Button.vue
  39. 590 0
      packages/webview/src/components/DocumentContainer/DocumentContainer.vue
  40. 105 0
      packages/webview/src/components/DownloadButton/DownloadButton.vue
  41. 40 0
      packages/webview/src/components/FullScreenButton/FullScreenButton.vue
  42. 36 0
      packages/webview/src/components/HandButton/HandButton.vue
  43. 34 0
      packages/webview/src/components/Header/Header.vue
  44. 61 0
      packages/webview/src/components/HeaderItems/HeaderItems.vue
  45. 286 0
      packages/webview/src/components/LeftPanel/LeftPanel.vue
  46. 68 0
      packages/webview/src/components/LeftPanel/LeftPanelTabs.vue
  47. 20 0
      packages/webview/src/components/OpenFileButton/OpenFileButton.vue
  48. 80 0
      packages/webview/src/components/PageDisplayButton/PageDisplayButton.vue
  49. 142 0
      packages/webview/src/components/PageNavOverlay/PageNavOverlay.vue
  50. 41 0
      packages/webview/src/components/PrintButton/PrintButton.vue
  51. 39 0
      packages/webview/src/components/ThemeMode/ThemeMode.vue
  52. 23 0
      packages/webview/src/components/ViewRotationControls/ViewRotationControls.vue
  53. 137 0
      packages/webview/src/components/ZoomOverlay/ZoomOverlay.vue
  54. 3 0
      packages/webview/src/components/toolButton/toolButton.vue
  55. 3 0
      packages/webview/src/core/addEvent.js
  56. 18 0
      packages/webview/src/core/documentViewers.js
  57. 3 0
      packages/webview/src/core/download.js
  58. 3 0
      packages/webview/src/core/eventBus.js
  59. 3 0
      packages/webview/src/core/getCurrentPage.js
  60. 4 0
      packages/webview/src/core/getPagesCount.js
  61. 3 0
      packages/webview/src/core/getScale.js
  62. 69 0
      packages/webview/src/core/index.js
  63. 3 0
      packages/webview/src/core/initConfig.js
  64. 19 0
      packages/webview/src/core/initializeViewer.js
  65. 3 0
      packages/webview/src/core/loadDocument.js
  66. 3 0
      packages/webview/src/core/nextPage.js
  67. 3 0
      packages/webview/src/core/pageNumberChanged.js
  68. 3 0
      packages/webview/src/core/previousPage.js
  69. 3 0
      packages/webview/src/core/requestFullScreenMode.js
  70. 3 0
      packages/webview/src/core/rotateClockwise.js
  71. 3 0
      packages/webview/src/core/rotateCounterclockwise.js
  72. 3 0
      packages/webview/src/core/scaleChanged.js
  73. 9 0
      packages/webview/src/core/setTool.js
  74. 3 0
      packages/webview/src/core/switchScrollMode.js
  75. 3 0
      packages/webview/src/core/switchSpreadMode.js
  76. 3 0
      packages/webview/src/core/switchTool.js
  77. 3 0
      packages/webview/src/core/toggleSidebar.js
  78. 3 0
      packages/webview/src/core/webViewerNamedAction.js
  79. 3 0
      packages/webview/src/core/webViewerPageMode.js
  80. 3 0
      packages/webview/src/core/zoomIn.js
  81. 3 0
      packages/webview/src/core/zoomOut.js
  82. 33 0
      packages/webview/src/helpers/device.js
  83. 22 0
      packages/webview/src/helpers/getHashParameters.js
  84. 7 0
      packages/webview/src/helpers/initDocument.js
  85. 16 0
      packages/webview/src/helpers/loadDocument.js

+ 109 - 0
packages/core/src/download_manager.js

@@ -0,0 +1,109 @@
+/* Copyright 2013 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.
+ */
+
+/** @typedef {import("./interfaces").IDownloadManager} IDownloadManager */
+
+import { createValidAbsoluteUrl, isPdfFile } from "pdfjs-dist/legacy/build/pdf";
+
+function download(blobUrl, filename) {
+  const a = document.createElement("a");
+  if (!a.click) {
+    throw new Error('DownloadManager: "a.click()" is not supported.');
+  }
+  a.href = blobUrl;
+  a.target = "_parent";
+  // Use a.download if available. This increases the likelihood that
+  // the file is downloaded instead of opened by another PDF plugin.
+  if ("download" in a) {
+    a.download = filename;
+  }
+  // <a> must be in the document for recent Firefox versions,
+  // otherwise .click() is ignored.
+  (document.body || document.documentElement).append(a);
+  a.click();
+  a.remove();
+}
+
+/**
+ * @implements {IDownloadManager}
+ */
+class DownloadManager {
+  #openBlobUrls = new WeakMap();
+
+  downloadUrl(url, filename) {
+    if (!createValidAbsoluteUrl(url, "http://example.com")) {
+      console.error(`downloadUrl - not a valid URL: ${url}`);
+      return; // restricted/invalid URL
+    }
+    download(url + "#pdfjs.action=download", filename);
+  }
+
+  downloadData(data, filename, contentType) {
+    const blobUrl = URL.createObjectURL(
+      new Blob([data], { type: contentType })
+    );
+    download(blobUrl, filename);
+  }
+
+  /**
+   * @returns {boolean} Indicating if the data was opened.
+   */
+  openOrDownloadData(element, data, filename) {
+    const isPdfData = isPdfFile(filename);
+    const contentType = isPdfData ? "application/pdf" : "";
+
+    if (isPdfData) {
+      let blobUrl = this.#openBlobUrls.get(element);
+      if (!blobUrl) {
+        blobUrl = URL.createObjectURL(new Blob([data], { type: contentType }));
+        this.#openBlobUrls.set(element, blobUrl);
+      }
+      let viewerUrl;
+      if (typeof PDFJSDev === "undefined" || PDFJSDev.test("GENERIC")) {
+        // The current URL is the viewer, let's use it and append the file.
+        viewerUrl = "?file=" + encodeURIComponent(blobUrl + "#" + filename);
+      } else if (PDFJSDev.test("CHROME")) {
+        // In the Chrome extension, the URL is rewritten using the history API
+        // in viewer.js, so an absolute URL must be generated.
+        viewerUrl =
+          // eslint-disable-next-line no-undef
+          chrome.runtime.getURL("/content/web/viewer.html") +
+          "?file=" +
+          encodeURIComponent(blobUrl + "#" + filename);
+      }
+
+      try {
+        window.open(viewerUrl);
+        return true;
+      } catch (ex) {
+        console.error(`openOrDownloadData: ${ex}`);
+        // Release the `blobUrl`, since opening it failed, and fallback to
+        // downloading the PDF file.
+        URL.revokeObjectURL(blobUrl);
+        this.#openBlobUrls.delete(element);
+      }
+    }
+
+    this.downloadData(data, filename, contentType);
+    return false;
+  }
+
+  download(blob, url, filename) {
+    const blobUrl = URL.createObjectURL(blob);
+    download(blobUrl, filename);
+  }
+}
+
+export { DownloadManager };

+ 93 - 0
packages/core/src/fit_curve.js

@@ -0,0 +1,93 @@
+function fitCurve(points, maxError, progressCallback) {
+  if (!Array.isArray(points)) {
+    throw new TypeError("First argument should be an array");
+  }
+  points.forEach(point => {
+    if (!Array.isArray(point) || point.some(item => typeof item !== 'number') || point.length !== points[0].length) {
+      throw Error("Each point should be an array of numbers. Each point should have the same amount of numbers.");
+    }
+  });
+  points = points.filter((point, i) => i === 0 || !point.every((val, j) => val === points[i - 1][j]));
+  if (points.length < 2) {
+    return [];
+  }
+  const len = points.length;
+  const leftTangent = createTangent(points[1], points[0]);
+  const rightTangent = createTangent(points[len - 2], points[len - 1]);
+  return fitCubic(points, leftTangent, rightTangent, maxError, progressCallback);
+}
+
+function createTangent(pointA, pointB) {
+  return maths.normalize(maths.subtract(pointA, pointB));
+}
+
+function fitCubic(points, leftTangent, rightTangent, error, progressCallback) {
+  const MaxIterations = 20;
+  var bezCurve, u, uPrime, maxError, prevErr, splitPoint, prevSplit, centerVector, toCenterTangent, fromCenterTangent, beziers, dist, i;
+  if (points.length === 2) {
+    dist = maths.vectorLen(maths.subtract(points[0], points[1])) / 3.0;
+    bezCurve = [points[0], maths.addArrays(points[0], maths.mulItems(leftTangent, dist)), maths.addArrays(points[1], maths.mulItems(rightTangent, dist)), points[1]];
+    return [bezCurve];
+  }
+  u = chordLengthParameterize(points);
+  [bezCurve, maxError, splitPoint] = generateAndReport(points, u, u, leftTangent, rightTangent, progressCallback);
+  if (maxError === 0 || maxError < error) {
+    return [bezCurve];
+  }
+  if (maxError < error * error) {
+    uPrime = u;
+    prevErr = maxError;
+    prevSplit = splitPoint;
+    for (i = 0; i < MaxIterations; i++) {
+      uPrime = reparameterize(bezCurve, points, uPrime);
+      [bezCurve, maxError, splitPoint] = generateAndReport(points, u, uPrime, leftTangent, rightTangent, progressCallback);
+      if (maxError < error) {
+        return [bezCurve];
+      } else if (splitPoint === prevSplit) {
+        let errChange = maxError / prevErr;
+        if (errChange > .9999 && errChange < 1.0001) {
+          break;
+        }
+      }
+      prevErr = maxError;
+      prevSplit = splitPoint;
+    }
+  }
+  beziers = [];
+  centerVector = maths.subtract(points[splitPoint - 1], points[splitPoint + 1]);
+  if (centerVector.every(val => val === 0)) {
+    centerVector = maths.subtract(points[splitPoint - 1], points[splitPoint]);
+    [centerVector[0], centerVector[1]] = [-centerVector[1], centerVector[0]];
+  }
+  toCenterTangent = maths.normalize(centerVector);
+  fromCenterTangent = maths.mulItems(toCenterTangent, -1);
+  beziers = beziers.concat(fitCubic(points.slice(0, splitPoint + 1), leftTangent, toCenterTangent, error, progressCallback));
+  beziers = beziers.concat(fitCubic(points.slice(splitPoint), fromCenterTangent, rightTangent, error, progressCallback));
+  return beziers;
+}
+
+class maths {
+  static mulItems(items, multiplier) {
+    return items.map(x => x * multiplier);
+  }
+  static mulMatrix(m1, m2) {
+    return m1.reduce((sum, x1, i) => sum + x1 * m2[i], 0);
+  }
+  static subtract(arr1, arr2) {
+    return arr1.map((x1, i) => x1 - arr2[i]);
+  }
+  static addArrays(arr1, arr2) {
+    return arr1.map((x1, i) => x1 + arr2[i]);
+  }
+  static vectorLen(v) {
+    return Math.hypot(...v);
+  }
+  static divItems(items, divisor) {
+    return items.map(x => x / divisor);
+  }
+  static normalize(v) {
+    return this.divItems(v, this.vectorLen(v));
+  }
+}
+
+export { fitCurve }

+ 138 - 0
packages/core/src/password_prompt.js

@@ -0,0 +1,138 @@
+/* Copyright 2012 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 { createPromiseCapability, PasswordResponses } from "pdfjs-dist/legacy/build/pdf";
+
+/**
+ * @typedef {Object} PasswordPromptOptions
+ * @property {HTMLDialogElement} dialog - The overlay's DOM element.
+ * @property {HTMLParagraphElement} label - Label containing instructions for
+ *                                          entering the password.
+ * @property {HTMLInputElement} input - Input field for entering the password.
+ * @property {HTMLButtonElement} submitButton - Button for submitting the
+ *                                              password.
+ * @property {HTMLButtonElement} cancelButton - Button for cancelling password
+ *                                              entry.
+ */
+
+class PasswordPrompt {
+  #activeCapability = null;
+
+  #updateCallback = null;
+
+  #callback = null;
+
+  #reason = null;
+
+  #active = false
+
+  /**
+   * @param {PasswordPromptOptions} options
+   * @param {OverlayManager} overlayManager - Manager for the viewer overlays.
+   * @param {IL10n} l10n - Localization service.
+   * @param {boolean} [isViewerEmbedded] - If the viewer is embedded, in e.g.
+   *   an <iframe> or an <object>. The default value is `false`.
+   */
+  constructor(options, isViewerEmbedded = false) {
+    this.dialog = options.dialog;
+    this.label = options.label;
+    this.input = options.input;
+    this.submitButton = options.submitButton;
+    this.cancelButton = options.cancelButton;
+    this._isViewerEmbedded = isViewerEmbedded;
+
+    // Attach the event listeners.
+    this.submitButton.addEventListener("click", this.#verify.bind(this));
+    this.cancelButton.addEventListener("click", this.close.bind(this));
+    this.input.addEventListener("keydown", e => {
+      if (e.keyCode === /* Enter = */ 13) {
+        this.#verify();
+      }
+    });
+  }
+
+  async open() {
+    if (this.#activeCapability) {
+      await this.#activeCapability.promise;
+    }
+    this.#activeCapability = createPromiseCapability();
+
+    try {
+      this.dialog.classList.add('show');
+      this.#active = true;
+    } catch (ex) {
+      this.#activeCapability = null;
+      throw ex;
+    }
+
+    const passwordIncorrect =
+      this.#reason === PasswordResponses.INCORRECT_PASSWORD;
+
+    if (!this._isViewerEmbedded || passwordIncorrect) {
+      this.input.focus();
+    }
+    if (passwordIncorrect) {
+      this.label.textContent = 'Invalid password. Please try again.'
+      this.label.style.color = 'red'
+    } else {
+      this.label.style.color = ''
+      this.label.textContent = 'Enter the password to open this PDF file.'
+    }
+  }
+
+  async close() {
+    if (this.#active) {
+      this.dialog.classList.remove('show');
+      this.#active = false;
+      this.#cancel()
+    }
+  }
+
+  #verify() {
+    const password = this.input.value;
+    if (password?.length > 0) {
+      this.#invokeCallback(password);
+    }
+  }
+
+  #cancel() {
+    this.#invokeCallback(new Error("PasswordPrompt cancelled."));
+    this.#activeCapability.resolve();
+  }
+
+  #invokeCallback(password) {
+    if (!this.#updateCallback) {
+      return; // Ensure that the callback is only invoked once.
+    }
+    this.input.value = "";
+
+    this.#updateCallback(password);
+    this.#callback(password);
+    this.#callback = null;
+    this.#updateCallback = null;
+    this.close();
+  }
+
+  async setUpdateCallback(updateCallback, reason, callback) {
+    if (this.#activeCapability) {
+      await this.#activeCapability.promise;
+    }
+    this.#updateCallback = updateCallback;
+    this.#reason = reason;
+    this.#callback = callback
+  }
+}
+
+export { PasswordPrompt };

+ 722 - 0
packages/core/src/pdf_link_service.js

@@ -0,0 +1,722 @@
+import { parseQueryString, removeNullCharacters } from "./ui_utils.js";
+
+const DEFAULT_LINK_REL = "noopener noreferrer nofollow";
+
+const LinkTarget = {
+  NONE: 0, // Default value.
+  SELF: 1,
+  BLANK: 2,
+  PARENT: 3,
+  TOP: 4,
+};
+
+/**
+ * @typedef {Object} ExternalLinkParameters
+ * @property {string} url - An absolute URL.
+ * @property {LinkTarget} [target] - The link target. The default value is
+ *   `LinkTarget.NONE`.
+ * @property {string} [rel] - The link relationship. The default value is
+ *   `DEFAULT_LINK_REL`.
+ * @property {boolean} [enabled] - Whether the link should be enabled. The
+ *   default value is true.
+ */
+
+/**
+ * Adds various attributes (href, title, target, rel) to hyperlinks.
+ * @param {HTMLAnchorElement} link - The link element.
+ * @param {ExternalLinkParameters} params
+ */
+function addLinkAttributes(link, { url, target, rel, enabled = true } = {}) {
+  if (!url || typeof url !== "string") {
+    throw new Error('A valid "url" parameter must provided.');
+  }
+
+  const urlNullRemoved = removeNullCharacters(url);
+  if (enabled) {
+    link.href = link.title = urlNullRemoved;
+  } else {
+    link.href = "";
+    link.title = `Disabled: ${urlNullRemoved}`;
+    link.onclick = () => {
+      return false;
+    };
+  }
+
+  let targetStr = ""; // LinkTarget.NONE
+  switch (target) {
+    case LinkTarget.NONE:
+      break;
+    case LinkTarget.SELF:
+      targetStr = "_self";
+      break;
+    case LinkTarget.BLANK:
+      targetStr = "_blank";
+      break;
+    case LinkTarget.PARENT:
+      targetStr = "_parent";
+      break;
+    case LinkTarget.TOP:
+      targetStr = "_top";
+      break;
+  }
+  link.target = targetStr;
+
+  link.rel = typeof rel === "string" ? rel : DEFAULT_LINK_REL;
+}
+
+/**
+ * @typedef {Object} PDFLinkServiceOptions
+ * @property {EventBus} eventBus - The application event bus.
+ * @property {number} [externalLinkTarget] - Specifies the `target` attribute
+ *   for external links. Must use one of the values from {LinkTarget}.
+ *   Defaults to using no target.
+ * @property {string} [externalLinkRel] - Specifies the `rel` attribute for
+ *   external links. Defaults to stripping the referrer.
+ * @property {boolean} [ignoreDestinationZoom] - Ignores the zoom argument,
+ *   thus preserving the current zoom level in the viewer, when navigating
+ *   to internal destinations. The default value is `false`.
+ */
+
+/**
+ * Performs navigation functions inside PDF, such as opening specified page,
+ * or destination.
+ * @implements {IPDFLinkService}
+ */
+class PDFLinkService {
+  #pagesRefCache = new Map();
+
+  /**
+   * @param {PDFLinkServiceOptions} options
+   */
+  constructor({
+    externalLinkTarget = null,
+    externalLinkRel = null,
+    ignoreDestinationZoom = false,
+  } = {}) {
+    this.externalLinkTarget = externalLinkTarget;
+    this.externalLinkRel = externalLinkRel;
+    this.externalLinkEnabled = true;
+    this._ignoreDestinationZoom = ignoreDestinationZoom;
+
+    this.baseUrl = null;
+    this.pdfDocument = null;
+    this.pdfViewer = null;
+    this.pdfHistory = null;
+  }
+
+  setDocument(pdfDocument, baseUrl = null) {
+    this.baseUrl = baseUrl;
+    this.pdfDocument = pdfDocument;
+    this.#pagesRefCache.clear();
+  }
+
+  setViewer(pdfViewer) {
+    this.pdfViewer = pdfViewer;
+  }
+
+  setHistory(pdfHistory) {
+    this.pdfHistory = pdfHistory;
+  }
+
+  /**
+   * @type {number}
+   */
+  get pagesCount() {
+    return this.pdfDocument ? this.pdfDocument.numPages : 0;
+  }
+
+  /**
+   * @type {number}
+   */
+  get page() {
+    return this.pdfViewer.currentPageNumber;
+  }
+
+  /**
+   * @param {number} value
+   */
+  set page(value) {
+    this.pdfViewer.currentPageNumber = value;
+  }
+
+  /**
+   * @type {number}
+   */
+  get rotation() {
+    return this.pdfViewer.pagesRotation;
+  }
+
+  /**
+   * @param {number} value
+   */
+  set rotation(value) {
+    this.pdfViewer.pagesRotation = value;
+  }
+
+  #goToDestinationHelper(rawDest, namedDest = null, explicitDest, changeScale = true) {
+    // Dest array looks like that: <page-ref> </XYZ|/FitXXX> <args..>
+    const destRef = explicitDest[0];
+    let pageNumber;
+
+    if (typeof destRef === "object" && destRef !== null) {
+      pageNumber = this._cachedPageNumber(destRef);
+
+      if (!pageNumber) {
+        // Fetch the page reference if it's not yet available. This could
+        // only occur during loading, before all pages have been resolved.
+        this.pdfDocument
+          .getPageIndex(destRef)
+          .then(pageIndex => {
+            this.cachePageRef(pageIndex + 1, destRef);
+            this.#goToDestinationHelper(rawDest, namedDest, explicitDest);
+          })
+          .catch(() => {
+            console.error(
+              `PDFLinkService.#goToDestinationHelper: "${destRef}" is not ` +
+                `a valid page reference, for dest="${rawDest}".`
+            );
+          });
+        return;
+      }
+    } else if (Number.isInteger(destRef)) {
+      pageNumber = destRef + 1;
+    } else {
+      console.error(
+        `PDFLinkService.#goToDestinationHelper: "${destRef}" is not ` +
+          `a valid destination reference, for dest="${rawDest}".`
+      );
+      return;
+    }
+    if (!pageNumber || pageNumber < 1 || pageNumber > this.pagesCount) {
+      console.error(
+        `PDFLinkService.#goToDestinationHelper: "${pageNumber}" is not ` +
+          `a valid page number, for dest="${rawDest}".`
+      );
+      return;
+    }
+
+    if (this.pdfHistory) {
+      // Update the browser history before scrolling the new destination into
+      // view, to be able to accurately capture the current document position.
+      this.pdfHistory.pushCurrentPosition();
+      this.pdfHistory.push({ namedDest, explicitDest, pageNumber });
+    }
+
+    let scrollData = null
+    if (changeScale) {
+      scrollData = {
+        pageNumber,
+        destArray: explicitDest,
+        ignoreDestinationZoom: this._ignoreDestinationZoom,
+      }
+    } else {
+      scrollData = {
+        pageNumber,
+      }
+    }
+    this.pdfViewer.scrollPageIntoView(scrollData);
+  }
+
+  /**
+   * This method will, when available, also update the browser history.
+   *
+   * @param {string|Array} dest - The named, or explicit, PDF destination.
+   */
+  async goToDestination(dest, changeScale = true) {
+    if (!this.pdfDocument) {
+      return;
+    }
+    let namedDest, explicitDest;
+    if (typeof dest === "string") {
+      namedDest = dest;
+      explicitDest = await this.pdfDocument.getDestination(dest);
+    } else {
+      namedDest = null;
+      explicitDest = await dest;
+    }
+    if (!Array.isArray(explicitDest)) {
+      console.error(
+        `PDFLinkService.goToDestination: "${explicitDest}" is not ` +
+          `a valid destination array, for dest="${dest}".`
+      );
+      return;
+    }
+    this.#goToDestinationHelper(dest, namedDest, explicitDest, changeScale);
+  }
+
+  /**
+   * This method will, when available, also update the browser history.
+   *
+   * @param {number|string} val - The page number, or page label.
+   */
+  goToPage(val) {
+    if (!this.pdfDocument) {
+      return;
+    }
+    const pageNumber =
+      (typeof val === "string" && this.pdfViewer.pageLabelToPageNumber(val)) ||
+      val | 0;
+    if (
+      !(
+        Number.isInteger(pageNumber) &&
+        pageNumber > 0 &&
+        pageNumber <= this.pagesCount
+      )
+    ) {
+      console.error(`PDFLinkService.goToPage: "${val}" is not a valid page.`);
+      return;
+    }
+
+    if (this.pdfHistory) {
+      // Update the browser history before scrolling the new page into view,
+      // to be able to accurately capture the current document position.
+      this.pdfHistory.pushCurrentPosition();
+      this.pdfHistory.pushPage(pageNumber);
+    }
+
+    this.pdfViewer.scrollPageIntoView({ pageNumber });
+  }
+
+  /**
+   * Wrapper around the `addLinkAttributes` helper function.
+   * @param {HTMLAnchorElement} link
+   * @param {string} url
+   * @param {boolean} [newWindow]
+   */
+  addLinkAttributes(link, url, newWindow = false) {
+    addLinkAttributes(link, {
+      url,
+      target: newWindow ? LinkTarget.BLANK : this.externalLinkTarget,
+      rel: this.externalLinkRel,
+      enabled: this.externalLinkEnabled,
+    });
+  }
+
+  /**
+   * @param {string|Array} dest - The PDF destination object.
+   * @returns {string} The hyperlink to the PDF object.
+   */
+  getDestinationHash(dest) {
+    if (typeof dest === "string") {
+      if (dest.length > 0) {
+        return this.getAnchorUrl("#" + escape(dest));
+      }
+    } else if (Array.isArray(dest)) {
+      const str = JSON.stringify(dest);
+      if (str.length > 0) {
+        return this.getAnchorUrl("#" + escape(str));
+      }
+    }
+    return this.getAnchorUrl("");
+  }
+
+  /**
+   * Prefix the full url on anchor links to make sure that links are resolved
+   * relative to the current URL instead of the one defined in <base href>.
+   * @param {string} anchor - The anchor hash, including the #.
+   * @returns {string} The hyperlink to the PDF object.
+   */
+  getAnchorUrl(anchor) {
+    return (this.baseUrl || "") + anchor;
+  }
+
+  /**
+   * @param {string} hash
+   */
+  setHash(hash) {
+    if (!this.pdfDocument) {
+      return;
+    }
+    let pageNumber, dest;
+    if (hash.includes("=")) {
+      const params = parseQueryString(hash);
+      // borrowing syntax from "Parameters for Opening PDF Files"
+      if (params.has("page")) {
+        pageNumber = params.get("page") | 0 || 1;
+      }
+      if (params.has("zoom")) {
+        // Build the destination array.
+        const zoomArgs = params.get("zoom").split(","); // scale,left,top
+        const zoomArg = zoomArgs[0];
+        const zoomArgNumber = parseFloat(zoomArg);
+
+        if (!zoomArg.includes("Fit")) {
+          // If the zoomArg is a number, it has to get divided by 100. If it's
+          // a string, it should stay as it is.
+          dest = [
+            null,
+            { name: "XYZ" },
+            zoomArgs.length > 1 ? zoomArgs[1] | 0 : null,
+            zoomArgs.length > 2 ? zoomArgs[2] | 0 : null,
+            zoomArgNumber ? zoomArgNumber / 100 : zoomArg,
+          ];
+        } else {
+          if (zoomArg === "Fit" || zoomArg === "FitB") {
+            dest = [null, { name: zoomArg }];
+          } else if (
+            zoomArg === "FitH" ||
+            zoomArg === "FitBH" ||
+            zoomArg === "FitV" ||
+            zoomArg === "FitBV"
+          ) {
+            dest = [
+              null,
+              { name: zoomArg },
+              zoomArgs.length > 1 ? zoomArgs[1] | 0 : null,
+            ];
+          } else if (zoomArg === "FitR") {
+            if (zoomArgs.length !== 5) {
+              console.error(
+                'PDFLinkService.setHash: Not enough parameters for "FitR".'
+              );
+            } else {
+              dest = [
+                null,
+                { name: zoomArg },
+                zoomArgs[1] | 0,
+                zoomArgs[2] | 0,
+                zoomArgs[3] | 0,
+                zoomArgs[4] | 0,
+              ];
+            }
+          } else {
+            console.error(
+              `PDFLinkService.setHash: "${zoomArg}" is not a valid zoom value.`
+            );
+          }
+        }
+      }
+      if (dest) {
+        this.pdfViewer.scrollPageIntoView({
+          pageNumber: pageNumber || this.page,
+          destArray: dest,
+          allowNegativeOffset: true,
+        });
+      } else if (pageNumber) {
+        this.page = pageNumber; // simple page
+      }
+      // Ensure that this parameter is *always* handled last, in order to
+      // guarantee that it won't be overridden (e.g. by the "page" parameter).
+      if (params.has("nameddest")) {
+        this.goToDestination(params.get("nameddest"));
+      }
+    } else {
+      // Named (or explicit) destination.
+      dest = unescape(hash);
+      try {
+        dest = JSON.parse(dest);
+
+        if (!Array.isArray(dest)) {
+          // Avoid incorrectly rejecting a valid named destination, such as
+          // e.g. "4.3" or "true", because `JSON.parse` converted its type.
+          dest = dest.toString();
+        }
+      } catch (ex) {}
+
+      if (
+        typeof dest === "string" ||
+        PDFLinkService.#isValidExplicitDestination(dest)
+      ) {
+        this.goToDestination(dest);
+        return;
+      }
+      console.error(
+        `PDFLinkService.setHash: "${unescape(
+          hash
+        )}" is not a valid destination.`
+      );
+    }
+  }
+
+  /**
+   * @param {string} action
+   */
+  executeNamedAction(action) {
+    // See PDF reference, table 8.45 - Named action
+    switch (action) {
+      case "GoBack":
+        this.pdfHistory?.back();
+        break;
+
+      case "GoForward":
+        this.pdfHistory?.forward();
+        break;
+
+      case "NextPage":
+        this.pdfViewer.nextPage();
+        break;
+
+      case "PrevPage":
+        this.pdfViewer.previousPage();
+        break;
+
+      case "LastPage":
+        this.page = this.pagesCount;
+        break;
+
+      case "FirstPage":
+        this.page = 1;
+        break;
+
+      default:
+        break; // No action according to spec
+    }
+
+    this.eventBus.dispatch("namedaction", {
+      source: this,
+      action,
+    });
+  }
+
+  /**
+   * @param {Object} action
+   */
+  async executeSetOCGState(action) {
+    const pdfDocument = this.pdfDocument;
+    const optionalContentConfig = await this.pdfViewer
+      .optionalContentConfigPromise;
+
+    if (pdfDocument !== this.pdfDocument) {
+      return; // The document was closed while the optional content resolved.
+    }
+    let operator;
+
+    for (const elem of action.state) {
+      switch (elem) {
+        case "ON":
+        case "OFF":
+        case "Toggle":
+          operator = elem;
+          continue;
+      }
+      switch (operator) {
+        case "ON":
+          optionalContentConfig.setVisibility(elem, true);
+          break;
+        case "OFF":
+          optionalContentConfig.setVisibility(elem, false);
+          break;
+        case "Toggle":
+          const group = optionalContentConfig.getGroup(elem);
+          if (group) {
+            optionalContentConfig.setVisibility(elem, !group.visible);
+          }
+          break;
+      }
+    }
+
+    this.pdfViewer.optionalContentConfigPromise = Promise.resolve(
+      optionalContentConfig
+    );
+  }
+
+  /**
+   * @param {number} pageNum - page number.
+   * @param {Object} pageRef - reference to the page.
+   */
+  cachePageRef(pageNum, pageRef) {
+    if (!pageRef) {
+      return;
+    }
+    const refStr =
+      pageRef.gen === 0 ? `${pageRef.num}R` : `${pageRef.num}R${pageRef.gen}`;
+    this.#pagesRefCache.set(refStr, pageNum);
+  }
+
+  /**
+   * @ignore
+   */
+  _cachedPageNumber(pageRef) {
+    if (!pageRef) {
+      return null;
+    }
+    const refStr =
+      pageRef.gen === 0 ? `${pageRef.num}R` : `${pageRef.num}R${pageRef.gen}`;
+    return this.#pagesRefCache.get(refStr) || null;
+  }
+
+  /**
+   * @param {number} pageNumber
+   */
+  isPageVisible(pageNumber) {
+    return this.pdfViewer.isPageVisible(pageNumber);
+  }
+
+  /**
+   * @param {number} pageNumber
+   */
+  isPageCached(pageNumber) {
+    return this.pdfViewer.isPageCached(pageNumber);
+  }
+
+  static #isValidExplicitDestination(dest) {
+    if (!Array.isArray(dest)) {
+      return false;
+    }
+    const destLength = dest.length;
+    if (destLength < 2) {
+      return false;
+    }
+    const page = dest[0];
+    if (
+      !(
+        typeof page === "object" &&
+        Number.isInteger(page.num) &&
+        Number.isInteger(page.gen)
+      ) &&
+      !(Number.isInteger(page) && page >= 0)
+    ) {
+      return false;
+    }
+    const zoom = dest[1];
+    if (!(typeof zoom === "object" && typeof zoom.name === "string")) {
+      return false;
+    }
+    let allowNull = true;
+    switch (zoom.name) {
+      case "XYZ":
+        if (destLength !== 5) {
+          return false;
+        }
+        break;
+      case "Fit":
+      case "FitB":
+        return destLength === 2;
+      case "FitH":
+      case "FitBH":
+      case "FitV":
+      case "FitBV":
+        if (destLength !== 3) {
+          return false;
+        }
+        break;
+      case "FitR":
+        if (destLength !== 6) {
+          return false;
+        }
+        allowNull = false;
+        break;
+      default:
+        return false;
+    }
+    for (let i = 2; i < destLength; i++) {
+      const param = dest[i];
+      if (!(typeof param === "number" || (allowNull && param === null))) {
+        return false;
+      }
+    }
+    return true;
+  }
+}
+
+/**
+ * @implements {IPDFLinkService}
+ */
+class SimpleLinkService {
+  constructor() {
+    this.externalLinkEnabled = true;
+  }
+
+  /**
+   * @type {number}
+   */
+  get pagesCount() {
+    return 0;
+  }
+
+  /**
+   * @type {number}
+   */
+  get page() {
+    return 0;
+  }
+
+  /**
+   * @param {number} value
+   */
+  set page(value) {}
+
+  /**
+   * @type {number}
+   */
+  get rotation() {
+    return 0;
+  }
+
+  /**
+   * @param {number} value
+   */
+  set rotation(value) {}
+
+  /**
+   * @param {string|Array} dest - The named, or explicit, PDF destination.
+   */
+  async goToDestination(dest) {}
+
+  /**
+   * @param {number|string} val - The page number, or page label.
+   */
+  goToPage(val) {}
+
+  /**
+   * @param {HTMLAnchorElement} link
+   * @param {string} url
+   * @param {boolean} [newWindow]
+   */
+  addLinkAttributes(link, url, newWindow = false) {
+    addLinkAttributes(link, { url, enabled: this.externalLinkEnabled });
+  }
+
+  /**
+   * @param dest - The PDF destination object.
+   * @returns {string} The hyperlink to the PDF object.
+   */
+  getDestinationHash(dest) {
+    return "#";
+  }
+
+  /**
+   * @param hash - The PDF parameters/hash.
+   * @returns {string} The hyperlink to the PDF object.
+   */
+  getAnchorUrl(hash) {
+    return "#";
+  }
+
+  /**
+   * @param {string} hash
+   */
+  setHash(hash) {}
+
+  /**
+   * @param {string} action
+   */
+  executeNamedAction(action) {}
+
+  /**
+   * @param {Object} action
+   */
+  executeSetOCGState(action) {}
+
+  /**
+   * @param {number} pageNum - page number.
+   * @param {Object} pageRef - reference to the page.
+   */
+  cachePageRef(pageNum, pageRef) {}
+
+  /**
+   * @param {number} pageNumber
+   */
+  isPageVisible(pageNumber) {
+    return true;
+  }
+
+  /**
+   * @param {number} pageNumber
+   */
+  isPageCached(pageNumber) {
+    return true;
+  }
+}
+
+export { LinkTarget, PDFLinkService, SimpleLinkService };

+ 408 - 0
packages/core/src/pdf_presentation_mode.js

@@ -0,0 +1,408 @@
+/* Copyright 2012 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 {
+  normalizeWheelEventDelta,
+  PresentationModeState,
+  ScrollMode,
+  SpreadMode,
+} from "./ui_utils.js";
+import { AnnotationEditorType } from "pdfjs-dist/legacy/build/pdf";
+
+const DELAY_BEFORE_HIDING_CONTROLS = 3000; // in ms
+const ACTIVE_SELECTOR = "pdfPresentationMode";
+const CONTROLS_SELECTOR = "pdfPresentationModeControls";
+const MOUSE_SCROLL_COOLDOWN_TIME = 50; // in ms
+const PAGE_SWITCH_THRESHOLD = 0.1;
+
+// Number of CSS pixels for a movement to count as a swipe.
+const SWIPE_MIN_DISTANCE_THRESHOLD = 50;
+
+// Swipe angle deviation from the x or y axis before it is not
+// considered a swipe in that direction any more.
+const SWIPE_ANGLE_THRESHOLD = Math.PI / 6;
+
+/**
+ * @typedef {Object} PDFPresentationModeOptions
+ * @property {HTMLDivElement} container - The container for the viewer element.
+ * @property {PDFViewer} pdfViewer - The document viewer.
+ * @property {EventBus} eventBus - The application event bus.
+ */
+
+class PDFPresentationMode {
+  #state = PresentationModeState.UNKNOWN;
+
+  #args = null;
+
+  /**
+   * @param {PDFPresentationModeOptions} options
+   */
+  constructor({ container, pdfViewer, eventBus }) {
+    this.container = container;
+    this.pdfViewer = pdfViewer;
+    this.eventBus = eventBus;
+
+    this.contextMenuOpen = false;
+    this.mouseScrollTimeStamp = 0;
+    this.mouseScrollDelta = 0;
+    this.touchSwipeState = null;
+  }
+
+  /**
+   * Request the browser to enter fullscreen mode.
+   * @returns {Promise<boolean>} Indicating if the request was successful.
+   */
+  async request() {
+    const { container, pdfViewer } = this;
+
+    if (this.active || !pdfViewer.pagesCount) {
+      return false;
+    }
+    this.#addFullscreenChangeListeners();
+    this.#notifyStateChange(PresentationModeState.CHANGING);
+
+    let promise = null;
+    if (container.webkitRequestFullScreen) {
+      promise = container.webkitRequestFullScreen();
+    } else if (container.mozRequestFullScreen) {
+      promise = container.mozRequestFullScreen()
+    } else if (container.requestFullscreen) {
+      promise = container.requestFullscreen()
+    }
+    this.#args = {
+      pageNumber: pdfViewer.currentPageNumber,
+      scaleValue: pdfViewer.currentScaleValue,
+      scrollMode: pdfViewer.scrollMode,
+      spreadMode: null,
+      annotationEditorMode: null,
+    };
+
+    if (
+      pdfViewer.spreadMode !== SpreadMode.NONE &&
+      !(pdfViewer.pageViewsReady && pdfViewer.hasEqualPageSizes)
+    ) {
+      console.warn(
+        "Ignoring Spread modes when entering PresentationMode, " +
+          "since the document may contain varying page sizes."
+      );
+      this.#args.spreadMode = pdfViewer.spreadMode;
+    }
+    if (pdfViewer.annotationEditorMode !== AnnotationEditorType.DISABLE) {
+      this.#args.annotationEditorMode = pdfViewer.annotationEditorMode;
+    }
+
+    try {
+      await promise;
+      pdfViewer.focus(); // Fixes bug 1787456.
+      return true;
+    } catch (reason) {
+      this.#removeFullscreenChangeListeners();
+      this.#notifyStateChange(PresentationModeState.NORMAL);
+    }
+    return false;
+  }
+
+  get active() {
+    return (
+      this.#state === PresentationModeState.CHANGING ||
+      this.#state === PresentationModeState.FULLSCREEN
+    );
+  }
+
+  #mouseWheel(evt) {
+    if (!this.active) {
+      return;
+    }
+    evt.preventDefault();
+
+    const delta = normalizeWheelEventDelta(evt);
+    const currentTime = Date.now();
+    const storedTime = this.mouseScrollTimeStamp;
+
+    // If we've already switched page, avoid accidentally switching again.
+    if (
+      currentTime > storedTime &&
+      currentTime - storedTime < MOUSE_SCROLL_COOLDOWN_TIME
+    ) {
+      return;
+    }
+    // If the scroll direction changed, reset the accumulated scroll delta.
+    if (
+      (this.mouseScrollDelta > 0 && delta < 0) ||
+      (this.mouseScrollDelta < 0 && delta > 0)
+    ) {
+      this.#resetMouseScrollState();
+    }
+    this.mouseScrollDelta += delta;
+
+    if (Math.abs(this.mouseScrollDelta) >= PAGE_SWITCH_THRESHOLD) {
+      const totalDelta = this.mouseScrollDelta;
+      this.#resetMouseScrollState();
+      const success =
+        totalDelta > 0
+          ? this.pdfViewer.previousPage()
+          : this.pdfViewer.nextPage();
+      if (success) {
+        this.mouseScrollTimeStamp = currentTime;
+      }
+    }
+  }
+
+  #notifyStateChange(state) {
+    this.#state = state;
+
+    this.eventBus.dispatch("presentationmodechanged", { source: this, state });
+  }
+
+  #enter() {
+    this.#notifyStateChange(PresentationModeState.FULLSCREEN);
+    this.container.classList.add(ACTIVE_SELECTOR);
+
+    // Ensure that the correct page is scrolled into view when entering
+    // Presentation Mode, by waiting until fullscreen mode in enabled.
+    setTimeout(() => {
+      this.pdfViewer.scrollMode = ScrollMode.PAGE;
+      if (this.#args.spreadMode !== null) {
+        this.pdfViewer.spreadMode = SpreadMode.NONE;
+      }
+      this.pdfViewer.currentPageNumber = this.#args.pageNumber;
+      this.pdfViewer.currentScaleValue = "page-fit";
+
+      if (this.#args.annotationEditorMode !== null) {
+        this.pdfViewer.annotationEditorMode = AnnotationEditorType.NONE;
+      }
+    }, 0);
+
+    this.#addWindowListeners();
+    this.#showControls();
+    this.contextMenuOpen = false;
+
+    // Text selection is disabled in Presentation Mode, thus it's not possible
+    // for the user to deselect text that is selected (e.g. with "Select all")
+    // when entering Presentation Mode, hence we remove any active selection.
+    window.getSelection().removeAllRanges();
+  }
+
+  #exit() {
+    const pageNumber = this.pdfViewer.currentPageNumber;
+    this.container.classList.remove(ACTIVE_SELECTOR);
+
+    // Ensure that the correct page is scrolled into view when exiting
+    // Presentation Mode, by waiting until fullscreen mode is disabled.
+    setTimeout(() => {
+      this.#removeFullscreenChangeListeners();
+      this.#notifyStateChange(PresentationModeState.NORMAL);
+
+      this.pdfViewer.scrollMode = this.#args.scrollMode;
+      if (this.#args.spreadMode !== null) {
+        this.pdfViewer.spreadMode = this.#args.spreadMode;
+      }
+      this.pdfViewer.currentScaleValue = this.#args.scaleValue;
+      this.pdfViewer.currentPageNumber = pageNumber;
+
+      if (this.#args.annotationEditorMode !== null) {
+        this.pdfViewer.annotationEditorMode = this.#args.annotationEditorMode;
+      }
+      this.#args = null;
+    }, 0);
+
+    this.#removeWindowListeners();
+    this.#hideControls();
+    this.#resetMouseScrollState();
+    this.contextMenuOpen = false;
+  }
+
+  #mouseDown(evt) {
+    if (this.contextMenuOpen) {
+      this.contextMenuOpen = false;
+      evt.preventDefault();
+      return;
+    }
+    if (evt.button !== 0) {
+      return;
+    }
+    // Enable clicking of links in presentation mode. Note: only links
+    // pointing to destinations in the current PDF document work.
+    if (
+      evt.target.href &&
+      evt.target.parentNode?.hasAttribute("data-internal-link")
+    ) {
+      return;
+    }
+    // Unless an internal link was clicked, advance one page.
+    evt.preventDefault();
+
+    if (evt.shiftKey) {
+      this.pdfViewer.previousPage();
+    } else {
+      this.pdfViewer.nextPage();
+    }
+  }
+
+  #contextMenu() {
+    this.contextMenuOpen = true;
+  }
+
+  #showControls() {
+    if (this.controlsTimeout) {
+      clearTimeout(this.controlsTimeout);
+    } else {
+      this.container.classList.add(CONTROLS_SELECTOR);
+    }
+    this.controlsTimeout = setTimeout(() => {
+      this.container.classList.remove(CONTROLS_SELECTOR);
+      delete this.controlsTimeout;
+    }, DELAY_BEFORE_HIDING_CONTROLS);
+  }
+
+  #hideControls() {
+    if (!this.controlsTimeout) {
+      return;
+    }
+    clearTimeout(this.controlsTimeout);
+    this.container.classList.remove(CONTROLS_SELECTOR);
+    delete this.controlsTimeout;
+  }
+
+  /**
+   * Resets the properties used for tracking mouse scrolling events.
+   */
+  #resetMouseScrollState() {
+    this.mouseScrollTimeStamp = 0;
+    this.mouseScrollDelta = 0;
+  }
+
+  #touchSwipe(evt) {
+    if (!this.active) {
+      return;
+    }
+    if (evt.touches.length > 1) {
+      // Multiple touch points detected; cancel the swipe.
+      this.touchSwipeState = null;
+      return;
+    }
+
+    switch (evt.type) {
+      case "touchstart":
+        this.touchSwipeState = {
+          startX: evt.touches[0].pageX,
+          startY: evt.touches[0].pageY,
+          endX: evt.touches[0].pageX,
+          endY: evt.touches[0].pageY,
+        };
+        break;
+      case "touchmove":
+        if (this.touchSwipeState === null) {
+          return;
+        }
+        this.touchSwipeState.endX = evt.touches[0].pageX;
+        this.touchSwipeState.endY = evt.touches[0].pageY;
+        // Avoid the swipe from triggering browser gestures (Chrome in
+        // particular has some sort of swipe gesture in fullscreen mode).
+        evt.preventDefault();
+        break;
+      case "touchend":
+        if (this.touchSwipeState === null) {
+          return;
+        }
+        let delta = 0;
+        const dx = this.touchSwipeState.endX - this.touchSwipeState.startX;
+        const dy = this.touchSwipeState.endY - this.touchSwipeState.startY;
+        const absAngle = Math.abs(Math.atan2(dy, dx));
+        if (
+          Math.abs(dx) > SWIPE_MIN_DISTANCE_THRESHOLD &&
+          (absAngle <= SWIPE_ANGLE_THRESHOLD ||
+            absAngle >= Math.PI - SWIPE_ANGLE_THRESHOLD)
+        ) {
+          // Horizontal swipe.
+          delta = dx;
+        } else if (
+          Math.abs(dy) > SWIPE_MIN_DISTANCE_THRESHOLD &&
+          Math.abs(absAngle - Math.PI / 2) <= SWIPE_ANGLE_THRESHOLD
+        ) {
+          // Vertical swipe.
+          delta = dy;
+        }
+        if (delta > 0) {
+          this.pdfViewer.previousPage();
+        } else if (delta < 0) {
+          this.pdfViewer.nextPage();
+        }
+        break;
+    }
+  }
+
+  #addWindowListeners() {
+    this.showControlsBind = this.#showControls.bind(this);
+    this.mouseDownBind = this.#mouseDown.bind(this);
+    this.mouseWheelBind = this.#mouseWheel.bind(this);
+    this.resetMouseScrollStateBind = this.#resetMouseScrollState.bind(this);
+    this.contextMenuBind = this.#contextMenu.bind(this);
+    this.touchSwipeBind = this.#touchSwipe.bind(this);
+
+    window.addEventListener("mousemove", this.showControlsBind);
+    window.addEventListener("mousedown", this.mouseDownBind);
+    window.addEventListener("wheel", this.mouseWheelBind, { passive: false });
+    window.addEventListener("keydown", this.resetMouseScrollStateBind);
+    window.addEventListener("contextmenu", this.contextMenuBind);
+    window.addEventListener("touchstart", this.touchSwipeBind);
+    window.addEventListener("touchmove", this.touchSwipeBind);
+    window.addEventListener("touchend", this.touchSwipeBind);
+  }
+
+  #removeWindowListeners() {
+    window.removeEventListener("mousemove", this.showControlsBind);
+    window.removeEventListener("mousedown", this.mouseDownBind);
+    window.removeEventListener("wheel", this.mouseWheelBind, {
+      passive: false,
+    });
+    window.removeEventListener("keydown", this.resetMouseScrollStateBind);
+    window.removeEventListener("contextmenu", this.contextMenuBind);
+    window.removeEventListener("touchstart", this.touchSwipeBind);
+    window.removeEventListener("touchmove", this.touchSwipeBind);
+    window.removeEventListener("touchend", this.touchSwipeBind);
+
+    delete this.showControlsBind;
+    delete this.mouseDownBind;
+    delete this.mouseWheelBind;
+    delete this.resetMouseScrollStateBind;
+    delete this.contextMenuBind;
+    delete this.touchSwipeBind;
+  }
+
+  #fullscreenChange() {
+    if (/* isFullscreen = */ document.fullscreenElement) {
+      this.#enter();
+    } else {
+      this.#exit();
+    }
+  }
+
+  #addFullscreenChangeListeners() {
+    this.fullscreenChangeBind = this.#fullscreenChange.bind(this);
+    window.addEventListener("fullscreenchange", this.fullscreenChangeBind);
+    window.addEventListener("webkitfullscreenchange", this.fullscreenChangeBind);
+    window.addEventListener("mozfullscreenchange", this.fullscreenChangeBind);
+  }
+
+  #removeFullscreenChangeListeners() {
+    window.removeEventListener("fullscreenchange", this.fullscreenChangeBind);
+    window.removeEventListener("webkitfullscreenchange", this.fullscreenChangeBind);
+    window.removeEventListener("mozfullscreenchange", this.fullscreenChangeBind);
+    delete this.fullscreenChangeBind;
+  }
+}
+
+export { PDFPresentationMode };

+ 238 - 0
packages/core/src/text_accessibility.js

@@ -0,0 +1,238 @@
+import { binarySearchFirstItem } from "./ui_utils.js";
+
+/**
+ * This class aims to provide some methods:
+ *  - to reorder elements in the DOM with respect to the visual order;
+ *  - to create a link, using aria-owns, between spans in the textLayer and
+ *    annotations in the annotationLayer. The goal is to help to know
+ *    where the annotations are in the text flow.
+ */
+class TextAccessibilityManager {
+  #enabled = false;
+
+  #textChildren = null;
+
+  #textNodes = new Map();
+
+  #waitingElements = new Map();
+
+  setTextMapping(textDivs) {
+    this.#textChildren = textDivs;
+  }
+
+  /**
+   * Compare the positions of two elements, it must correspond to
+   * the visual ordering.
+   *
+   * @param {HTMLElement} e1
+   * @param {HTMLElement} e2
+   * @returns {number}
+   */
+  static #compareElementPositions(e1, e2) {
+    const rect1 = e1.getBoundingClientRect();
+    const rect2 = e2.getBoundingClientRect();
+
+    if (rect1.width === 0 && rect1.height === 0) {
+      return +1;
+    }
+
+    if (rect2.width === 0 && rect2.height === 0) {
+      return -1;
+    }
+
+    const top1 = rect1.y;
+    const bot1 = rect1.y + rect1.height;
+    const mid1 = rect1.y + rect1.height / 2;
+
+    const top2 = rect2.y;
+    const bot2 = rect2.y + rect2.height;
+    const mid2 = rect2.y + rect2.height / 2;
+
+    if (mid1 <= top2 && mid2 >= bot1) {
+      return -1;
+    }
+
+    if (mid2 <= top1 && mid1 >= bot2) {
+      return +1;
+    }
+
+    const centerX1 = rect1.x + rect1.width / 2;
+    const centerX2 = rect2.x + rect2.width / 2;
+
+    return centerX1 - centerX2;
+  }
+
+  /**
+   * Function called when the text layer has finished rendering.
+   */
+  enable() {
+    if (this.#enabled) {
+      throw new Error("TextAccessibilityManager is already enabled.");
+    }
+    if (!this.#textChildren) {
+      throw new Error("Text divs and strings have not been set.");
+    }
+
+    this.#enabled = true;
+    this.#textChildren = this.#textChildren.slice();
+    this.#textChildren.sort(TextAccessibilityManager.#compareElementPositions);
+
+    if (this.#textNodes.size > 0) {
+      // Some links have been made before this manager has been disabled, hence
+      // we restore them.
+      const textChildren = this.#textChildren;
+      for (const [id, nodeIndex] of this.#textNodes) {
+        const element = document.getElementById(id);
+        if (!element) {
+          // If the page was *fully* reset the element no longer exists, and it
+          // will be re-inserted later (i.e. when the annotationLayer renders).
+          this.#textNodes.delete(id);
+          continue;
+        }
+        this.#addIdToAriaOwns(id, textChildren[nodeIndex]);
+      }
+    }
+
+    for (const [element, isRemovable] of this.#waitingElements) {
+      this.addPointerInTextLayer(element, isRemovable);
+    }
+    this.#waitingElements.clear();
+  }
+
+  disable() {
+    if (!this.#enabled) {
+      return;
+    }
+
+    // Don't clear this.#textNodes which is used to rebuild the aria-owns
+    // in case it's re-enabled at some point.
+
+    this.#waitingElements.clear();
+    this.#textChildren = null;
+    this.#enabled = false;
+  }
+
+  /**
+   * Remove an aria-owns id from a node in the text layer.
+   * @param {HTMLElement} element
+   */
+  removePointerInTextLayer(element) {
+    if (!this.#enabled) {
+      this.#waitingElements.delete(element);
+      return;
+    }
+
+    const children = this.#textChildren;
+    if (!children || children.length === 0) {
+      return;
+    }
+
+    const { id } = element;
+    const nodeIndex = this.#textNodes.get(id);
+    if (nodeIndex === undefined) {
+      return;
+    }
+
+    const node = children[nodeIndex];
+
+    this.#textNodes.delete(id);
+    let owns = node.getAttribute("aria-owns");
+    if (owns?.includes(id)) {
+      owns = owns
+        .split(" ")
+        .filter(x => x !== id)
+        .join(" ");
+      if (owns) {
+        node.setAttribute("aria-owns", owns);
+      } else {
+        node.removeAttribute("aria-owns");
+        node.setAttribute("role", "presentation");
+      }
+    }
+  }
+
+  #addIdToAriaOwns(id, node) {
+    const owns = node.getAttribute("aria-owns");
+    if (!owns?.includes(id)) {
+      node.setAttribute("aria-owns", owns ? `${owns} ${id}` : id);
+    }
+    node.removeAttribute("role");
+  }
+
+  /**
+   * Find the text node which is the nearest and add an aria-owns attribute
+   * in order to correctly position this editor in the text flow.
+   * @param {HTMLElement} element
+   * @param {boolean} isRemovable
+   */
+  addPointerInTextLayer(element, isRemovable) {
+    const { id } = element;
+    if (!id) {
+      return;
+    }
+
+    if (!this.#enabled) {
+      // The text layer needs to be there, so we postpone the association.
+      this.#waitingElements.set(element, isRemovable);
+      return;
+    }
+
+    if (isRemovable) {
+      this.removePointerInTextLayer(element);
+    }
+
+    const children = this.#textChildren;
+    if (!children || children.length === 0) {
+      return;
+    }
+
+    const index = binarySearchFirstItem(
+      children,
+      node =>
+        TextAccessibilityManager.#compareElementPositions(element, node) < 0
+    );
+
+    const nodeIndex = Math.max(0, index - 1);
+    this.#addIdToAriaOwns(id, children[nodeIndex]);
+    this.#textNodes.set(id, nodeIndex);
+  }
+
+  /**
+   * Move a div in the DOM in order to respect the visual order.
+   * @param {HTMLDivElement} element
+   */
+  moveElementInDOM(container, element, contentElement, isRemovable) {
+    this.addPointerInTextLayer(contentElement, isRemovable);
+
+    if (!container.hasChildNodes()) {
+      container.append(element);
+      return;
+    }
+
+    const children = Array.from(container.childNodes).filter(
+      node => node !== element
+    );
+
+    if (children.length === 0) {
+      return;
+    }
+
+    const elementToCompare = contentElement || element;
+    const index = binarySearchFirstItem(
+      children,
+      node =>
+        TextAccessibilityManager.#compareElementPositions(
+          elementToCompare,
+          node
+        ) < 0
+    );
+
+    if (index === 0) {
+      children[0].before(element);
+    } else {
+      children[index - 1].after(element);
+    }
+  }
+}
+
+export { TextAccessibilityManager };

+ 754 - 0
packages/core/src/tools.js

@@ -0,0 +1,754 @@
+import { shadow } from "./ui_utils.js";
+import { AnnotationEditorType } from "../constants"
+/**
+ * Class to handle the different keyboards shortcuts we can have on mac or
+ * non-mac OSes.
+ */
+class KeyboardManager {
+  /**
+   * Create a new keyboard manager class.
+   * @param {Array<Array>} callbacks - an array containing an array of shortcuts
+   * and a callback to call.
+   * A shortcut is a string like `ctrl+c` or `mac+ctrl+c` for mac OS.
+   */
+  constructor(callbacks) {
+    this.buffer = [];
+    this.callbacks = new Map();
+    this.allKeys = new Set();
+
+    const isMac = KeyboardManager.platform.isMac;
+    for (const [keys, callback] of callbacks) {
+      for (const key of keys) {
+        const isMacKey = key.startsWith("mac+");
+        if (isMac && isMacKey) {
+          this.callbacks.set(key.slice(4), callback);
+          this.allKeys.add(key.split("+").at(-1));
+        } else if (!isMac && !isMacKey) {
+          this.callbacks.set(key, callback);
+          this.allKeys.add(key.split("+").at(-1));
+        }
+      }
+    }
+  }
+
+  static get platform() {
+    const platform = typeof navigator !== "undefined" ? navigator.platform : "";
+
+    return shadow(this, "platform", {
+      isWin: platform.includes("Win"),
+      isMac: platform.includes("Mac"),
+    });
+  }
+
+  /**
+   * Serialize an event into a string in order to match a
+   * potential key for a callback.
+   * @param {KeyboardEvent} event
+   * @returns {string}
+   */
+  _serialize(event) {
+    if (event.altKey) {
+      this.buffer.push("alt");
+    }
+    if (event.ctrlKey) {
+      this.buffer.push("ctrl");
+    }
+    if (event.metaKey) {
+      this.buffer.push("meta");
+    }
+    if (event.shiftKey) {
+      this.buffer.push("shift");
+    }
+    this.buffer.push(event.key);
+    const str = this.buffer.join("+");
+    this.buffer.length = 0;
+
+    return str;
+  }
+
+  /**
+   * Execute a callback, if any, for a given keyboard event.
+   * The self is used as `this` in the callback.
+   * @param {Object} self.
+   * @param {KeyboardEvent} event
+   * @returns
+   */
+  exec(self, event) {
+    if (!this.allKeys.has(event.key)) {
+      return;
+    }
+    const callback = this.callbacks.get(this._serialize(event));
+    if (!callback) {
+      return;
+    }
+    callback.bind(self)();
+    event.stopPropagation();
+    event.preventDefault();
+  }
+}
+
+/**
+ * Convert a number between 0 and 100 into an hex number between 0 and 255.
+ * @param {number} opacity
+ * @return {string}
+ */
+ function opacityToHex(opacity) {
+  return Math.round(Math.min(255, Math.max(1, 255 * opacity)))
+    .toString(16)
+    .padStart(2, "0");
+}
+
+const AnnotationEditorPrefix = "pdfjs_internal_editor_";
+/**
+ * Class to create some unique ids for the different editors.
+ */
+class IdManager {
+  #id = 0;
+
+  /**
+   * Get a unique id.
+   * @returns {string}
+   */
+  getId() {
+    return `${AnnotationEditorPrefix}${this.#id++}`;
+  }
+}
+
+/**
+ * A pdf has several pages and each of them when it will rendered
+ * will have an AnnotationEditorLayer which will contain the some
+ * new Annotations associated to an editor in order to modify them.
+ *
+ * This class is used to manage all the different layers, editors and
+ * some action like copy/paste, undo/redo, ...
+ */
+class AnnotationManager {
+
+  constructor(container, eventBus) {
+    
+    this._activeEditor = null;
+
+    this._allEditors = new Map();
+
+    this._allLayers = new Map();
+
+    this._currentPageIndex = 0;
+
+    this._editorTypes = null;
+
+    this._isEnabled = false;
+
+    this._mode = AnnotationEditorType.NONE;
+
+    this._idManager = new IdManager();
+    this._selectedEditors = new Set();
+
+    this._boundCopy = this.copy.bind(this);
+
+    this._boundCut = this.cut.bind(this);
+
+    this._boundPaste = this.paste.bind(this);
+
+    this._boundKeydown = this.keydown.bind(this);
+
+    this._boundOnEditingAction = this.onEditingAction.bind(this);
+
+    this._boundOnPageChanging = this.onPageChanging.bind(this);
+
+    this._previousStates = {
+      isEditing: false,
+      isEmpty: true,
+      hasSomethingToUndo: false,
+      hasSomethingToRedo: false,
+      hasSelectedEditor: false,
+    };
+
+    this._keyboardManager = new KeyboardManager([
+      [
+        [
+          "Backspace",
+          "alt+Backspace",
+          "ctrl+Backspace",
+          "shift+Backspace",
+          "mac+Backspace",
+          "mac+alt+Backspace",
+          "mac+ctrl+Backspace",
+          "Delete",
+          "ctrl+Delete",
+          "shift+Delete",
+        ],
+        AnnotationManager.prototype.delete,
+      ]
+    ]);
+
+    this._container = container;
+    this._eventBus = eventBus;
+    this._eventBus._on("editingaction", this._boundOnEditingAction);
+    this._eventBus._on("pagechanging", this._boundOnPageChanging);
+  }
+
+  destroy() {
+    this._removeKeyboardManager();
+    this._eventBus._off("editingaction", this._boundOnEditingAction);
+    this._eventBus._off("pagechanging", this._boundOnPageChanging);
+    for (const layer of this._allLayers.values()) {
+      layer.destroy();
+    }
+    this._allLayers.clear();
+    this._allEditors.clear();
+    this._activeEditor = null;
+    this._selectedEditors.clear();
+  }
+
+  removeAllAnnotations() {
+    for (const layer of this._allLayers.values()) {
+      layer.removeAllAnnotations();
+    }
+    this._allEditors.clear();
+    this._activeEditor = null;
+    this._selectedEditors.clear();
+  }
+
+  onPageChanging({ pageNumber }) {
+    this._currentPageIndex = pageNumber - 1;
+  }
+
+  focusMainContainer() {
+    this._container.focus();
+  }
+
+  _addKeyboardManager() {
+    // The keyboard events are caught at the container level in order to be able
+    // to execute some callbacks even if the current page doesn't have focus.
+    // this._container.addEventListener("keydown", this._boundKeydown);
+  }
+
+  _removeKeyboardManager() {
+    this._container.removeEventListener("keydown", this._boundKeydown);
+  }
+
+  _addCopyPasteListeners() {
+    document.addEventListener("copy", this._boundCopy);
+    document.addEventListener("cut", this._boundCut);
+    document.addEventListener("paste", this._boundPaste);
+  }
+
+  _removeCopyPasteListeners() {
+    document.removeEventListener("copy", this._boundCopy);
+    document.removeEventListener("cut", this._boundCut);
+    document.removeEventListener("paste", this._boundPaste);
+  }
+
+  /**
+   * Copy callback.
+   * @param {ClipboardEvent} event
+   */
+  copy(event) {
+    event.preventDefault();
+
+    if (this._activeEditor) {
+      // An editor is being edited so just commit it.
+      this._activeEditor.commitOrRemove();
+    }
+
+    if (!this.hasSelection) {
+      return;
+    }
+
+    const editors = [];
+    for (const editor of this._selectedEditors) {
+      if (!editor.isEmpty()) {
+        editors.push(editor.serialize());
+      }
+    }
+    if (editors.length === 0) {
+      return;
+    }
+
+    event.clipboardData.setData("application/pdfjs", JSON.stringify(editors));
+  }
+
+  /**
+   * Cut callback.
+   * @param {ClipboardEvent} event
+   */
+  cut(event) {
+    this.copy(event);
+    this.delete();
+  }
+
+  /**
+   * Paste callback.
+   * @param {ClipboardEvent} event
+   */
+  paste(event) {
+    event.preventDefault();
+
+    let data = event.clipboardData.getData("application/pdfjs");
+    if (!data) {
+      return;
+    }
+
+    try {
+      data = JSON.parse(data);
+    } catch (ex) {
+      console.warn(`paste: "${ex.message}".`);
+      return;
+    }
+
+    if (!Array.isArray(data)) {
+      return;
+    }
+
+    this.unselectAll();
+    const layer = this._allLayers.get(this._currentPageIndex);
+
+    try {
+      const newEditors = [];
+      for (const editor of data) {
+        const deserializedEditor = layer.deserialize(editor);
+        if (!deserializedEditor) {
+          return;
+        }
+        newEditors.push(deserializedEditor);
+      }
+
+      for (const editor of newEditors) {
+        this._addEditorToLayer(editor);
+      }
+      this._selectEditors(newEditors);
+    } catch (ex) {
+      console.warn(`paste: "${ex.message}".`);
+    }
+  }
+
+  /**
+   * Keydown callback.
+   * @param {KeyboardEvent} event
+   */
+  keydown(event) {
+    if (!this.getActive()?.shouldGetKeyboardEvents()) {
+      this._keyboardManager.exec(this, event);
+    }
+  }
+
+  /**
+   * Execute an action for a given name.
+   * For example, the user can click on the "Undo" entry in the context menu
+   * and it'll trigger the undo action.
+   * @param {Object} details
+   */
+  onEditingAction(details) {
+    if (["delete"].includes(details.name)) {
+      this[details.name]();
+    }
+  }
+
+  /**
+   * Update the different possible states of this manager, e.g. is there
+   * something to undo, redo, ...
+   * @param {Object} details
+   */
+  _dispatchUpdateStates(details) {
+    const hasChanged = Object.entries(details).some(
+      ([key, value]) => this._previousStates[key] !== value
+    );
+
+    if (hasChanged) {
+      this._eventBus.dispatch("annotationeditorstateschanged", {
+        source: this,
+        details: Object.assign(this._previousStates, details),
+      });
+    }
+  }
+
+  _dispatchUpdateUI(details) {
+    this._eventBus.dispatch("annotationeditorparamschanged", {
+      source: this,
+      details,
+    });
+  }
+
+  /**
+   * Set the editing state.
+   * It can be useful to temporarily disable it when the user is editing a
+   * FreeText annotation.
+   * @param {boolean} isEditing
+   */
+  setEditingState(isEditing) {
+    if (isEditing) {
+      this._addKeyboardManager();
+      this._addCopyPasteListeners();
+      this._dispatchUpdateStates({
+        isEditing: this._mode !== AnnotationEditorType.NONE,
+        isEmpty: this._isEmpty(),
+        hasSelectedEditor: false,
+      });
+    } else {
+      this._removeKeyboardManager();
+      this._removeCopyPasteListeners();
+      this._dispatchUpdateStates({
+        isEditing: false,
+      });
+    }
+  }
+
+  registerEditorTypes(types) {
+    if (this._editorTypes) {
+      return;
+    }
+    this._editorTypes = types;
+    for (const editorType of this._editorTypes) {
+      this._dispatchUpdateUI(editorType.defaultPropertiesToUpdate);
+    }
+  }
+
+  /**
+   * Get an id.
+   * @returns {string}
+   */
+  getId() {
+    return this._idManager.getId();
+  }
+
+  /**
+   * Add a new layer for a page which will contains the editors.
+   * @param {AnnotationEditorLayer} layer
+   */
+  addLayer(layer) {
+    this._allLayers.set(layer.pageIndex, layer);
+    if (this._isEnabled) {
+      layer.enable();
+    } else {
+      layer.disable();
+    }
+  }
+
+  /**
+   * Remove a layer.
+   * @param {AnnotationEditorLayer} layer
+   */
+  removeLayer(layer) {
+    this._allLayers.delete(layer.pageIndex);
+  }
+
+  /**
+   * Change the editor mode (None, FreeText, Ink, ...)
+   * @param {number} mode
+   */
+  updateMode(mode) {
+    this._mode = mode;
+    if (mode === AnnotationEditorType.NONE) {
+      this.setEditingState(false);
+      this._disableAll();
+    } else {
+      this.setEditingState(true);
+      this._enableAll();
+      for (const layer of this._allLayers.values()) {
+        layer.updateMode(mode);
+      }
+    }
+  }
+
+  /**
+   * Update the toolbar if it's required to reflect the tool currently used.
+   * @param {number} mode
+   * @returns {undefined}
+   */
+  updateToolbar(mode) {
+    if (mode === this._mode) {
+      return;
+    }
+    this._eventBus.dispatch("switchannotationeditormode", {
+      source: this,
+      mode,
+    });
+  }
+
+  /**
+   * Update a parameter in the current editor or globally.
+   * @param {number} type
+   * @param {*} value
+   */
+  updateParams(type, value) {
+    if (!this._editorTypes) {
+      return;
+    }
+
+    for (const editor of this._selectedEditors) {
+      editor.updateParams(type, value);
+    }
+
+    for (const editorType of this._editorTypes) {
+      editorType.updateDefaultParams(type, value);
+    }
+  }
+
+  /**
+   * Enable all the layers.
+   */
+   _enableAll() {
+    if (!this._isEnabled) {
+      this._isEnabled = true;
+      for (const layer of this._allLayers.values()) {
+        layer.enable();
+      }
+    }
+  }
+
+  /**
+   * Disable all the layers.
+   */
+  _disableAll() {
+    this.unselectAll();
+    if (this._isEnabled) {
+      this._isEnabled = false;
+      for (const layer of this._allLayers.values()) {
+        layer.disable();
+      }
+    }
+  }
+
+  /**
+   * Get all the editors belonging to a give page.
+   * @param {number} pageIndex
+   * @returns {Array<AnnotationEditor>}
+   */
+  getEditors(pageIndex) {
+    const editors = [];
+    for (const editor of this._allEditors.values()) {
+      if (editor.pageIndex === pageIndex) {
+        editors.push(editor);
+      }
+    }
+    return editors;
+  }
+
+  /**
+   * Get an editor with the given id.
+   * @param {string} id
+   * @returns {AnnotationEditor}
+   */
+  getEditor(id) {
+    return this._allEditors.get(id);
+  }
+
+  /**
+   * Add a new editor.
+   * @param {AnnotationEditor} editor
+   */
+  addEditor(editor) {
+    this._allEditors.set(editor.id, editor);
+  }
+
+  /**
+   * Remove an editor.
+   * @param {AnnotationEditor} editor
+   */
+  removeEditor(editor) {
+    this._allEditors.delete(editor.id);
+    this.unselect(editor);
+  }
+
+  /**
+   * Add an editor to the layer it belongs to or add it to the global map.
+   * @param {AnnotationEditor} editor
+   */
+  _addEditorToLayer(editor) {
+    const layer = this._allLayers.get(editor.pageIndex);
+    if (layer) {
+      layer.addOrRebuild(editor);
+    } else {
+      this.addEditor(editor);
+    }
+  }
+
+  /**
+   * Set the given editor as the active one.
+   * @param {AnnotationEditor} editor
+   */
+  setActiveEditor(editor) {
+    if (this._activeEditor === editor) {
+      return;
+    }
+
+    this._activeEditor = editor;
+    if (editor) {
+      this._dispatchUpdateUI(editor.propertiesToUpdate);
+    }
+  }
+
+  /**
+   * Add or remove an editor the current selection.
+   * @param {AnnotationEditor} editor
+   */
+  toggleSelected(editor) {
+    if (this._selectedEditors.has(editor)) {
+      this._selectedEditors.delete(editor);
+      editor.unselect();
+      this._dispatchUpdateStates({
+        hasSelectedEditor: this.hasSelection,
+      });
+      return;
+    }
+    this._selectedEditors.add(editor);
+    editor.select();
+    this._dispatchUpdateUI(editor.propertiesToUpdate);
+    this._dispatchUpdateStates({
+      hasSelectedEditor: true,
+    });
+  }
+
+  /**
+   * Set the last selected editor.
+   * @param {AnnotationEditor} editor
+   */
+  setSelected(editor) {
+    for (const ed of this._selectedEditors) {
+      if (ed !== editor) {
+        ed.unselect();
+      }
+    }
+    this._selectedEditors.clear();
+
+    this._selectedEditors.add(editor);
+    editor.select();
+    this._dispatchUpdateUI(editor.propertiesToUpdate);
+    this._dispatchUpdateStates({
+      hasSelectedEditor: true,
+    });
+  }
+
+  /**
+   * Check if the editor is selected.
+   * @param {AnnotationEditor} editor
+   */
+  isSelected(editor) {
+    return this._selectedEditors.has(editor);
+  }
+
+  /**
+   * Unselect an editor.
+   * @param {AnnotationEditor} editor
+   */
+  unselect(editor) {
+    editor.unselect();
+    this._selectedEditors.delete(editor);
+    this._dispatchUpdateStates({
+      hasSelectedEditor: this.hasSelection,
+    });
+  }
+
+  get hasSelection() {
+    return this._selectedEditors.size !== 0;
+  }
+
+  _isEmpty() {
+    if (this._allEditors.size === 0) {
+      return true;
+    }
+
+    if (this._allEditors.size === 1) {
+      for (const editor of this._allEditors.values()) {
+        return editor.isEmpty();
+      }
+    }
+
+    return false;
+  }
+
+  /**
+   * Delete the current editor or all.
+   */
+  delete() {
+    this.commitOrRemove();
+    const editors = [...this._selectedEditors];
+    for (const editor of editors) {
+      editor.remove();
+    }
+  }
+
+  commitOrRemove() {
+    // An editor is being edited so just commit it.
+    this._activeEditor?.commitOrRemove();
+  }
+
+  /**
+   * Select the editors.
+   * @param {Array<AnnotationEditor>} editors
+   */
+  _selectEditors(editors) {
+    this._selectedEditors.clear();
+    for (const editor of editors) {
+      if (editor.isEmpty()) {
+        continue;
+      }
+      this._selectedEditors.add(editor);
+      editor.select();
+    }
+    this._dispatchUpdateStates({ hasSelectedEditor: true });
+  }
+
+  /**
+   * Select all the editors.
+   */
+  selectAll() {
+    for (const editor of this._selectedEditors) {
+      editor.commit();
+    }
+    this._selectEditors(this._allEditors.values());
+  }
+
+  /**
+   * Unselect all the selected editors.
+   */
+  unselectAll() {
+    if (this._activeEditor) {
+      // An editor is being edited so just commit it.
+      this._activeEditor.commitOrRemove();
+      return;
+    }
+
+    if (this._selectedEditors.size === 0) {
+      return;
+    }
+    for (const editor of this._selectedEditors) {
+      editor.unselect();
+    }
+    this._selectedEditors.clear();
+    this._dispatchUpdateStates({
+      hasSelectedEditor: false,
+    });
+  }
+
+  /**
+   * Is the current editor the one passed as argument?
+   * @param {AnnotationEditor} editor
+   * @returns
+   */
+  isActive(editor) {
+    return this._activeEditor === editor;
+  }
+
+  /**
+   * Get the current active editor.
+   * @returns {AnnotationEditor|null}
+   */
+  getActive() {
+    return this._activeEditor;
+  }
+
+  /**
+   * Get the current editor mode.
+   * @returns {number}
+   */
+  getMode() {
+    return this._mode;
+  }
+}
+
+export {
+  AnnotationManager,
+  KeyboardManager,
+  opacityToHex
+};

Fichier diff supprimé car celui-ci est trop grand
+ 1585 - 0
packages/core/src/ui_utils.js


BIN
packages/webview/public/example/ComPDFKit Introduction Booklet - PDF Technologies, Inc.pdf


BIN
packages/webview/public/images/loading-icon.gif


+ 3 - 0
packages/webview/src/assets/icons/icon-annotation.svg

@@ -0,0 +1,3 @@
+<svg width="20" height="20" viewBox="0 0 20 20">
+<g fill="none" fill-rule="evenodd"><path d="M0 0h20v20H0z"></path><path d="M20 1.25l-2 17.5H0l2-17.5h18zm-9.564 2.268h-1.16L4.623 15.144H3.125v1.25h3.846v-1.25h-1l.942-2.356h5.885l.942 2.356h-1v1.25h3.847v-1.25h-1.501l-4.65-11.626zM9.855 5.43l2.443 6.107H7.413L9.855 5.43z" fill="currentColor"></path></g>
+</svg>

+ 1 - 0
packages/webview/src/assets/icons/icon-arrow.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 12 12"><path class="cls-1" d="M4.5,10l4-4-4-4-1,1,3,3-3,3Z" fill="currentColor"></path></svg>

Fichier diff supprimé car celui-ci est trop grand
+ 1 - 0
packages/webview/src/assets/icons/icon-compare.svg


+ 3 - 0
packages/webview/src/assets/icons/icon-delete.svg

@@ -0,0 +1,3 @@
+<svg width="20" height="20" viewBox="0 0 20 20">
+<g fill="none" fill-rule="evenodd"><path d="M0 0h20v20H0z"></path><path d="M19 4.25v1.5h-2.25v13H3.25v-13H1v-1.5h18zm-3.75 1.5H4.75v11.5h10.5V5.75zm-2.5 2.75v6h-1.5v-6h1.5zm-4 0v6h-1.5v-6h1.5zM13 1.25v1.5H7v-1.5h6z" fill="currentColor"></path></g>
+</svg>

+ 3 - 0
packages/webview/src/assets/icons/icon-export.svg

@@ -0,0 +1,3 @@
+<svg width="20" height="20" viewBox="0 0 20 20">
+<g fill="none" fill-rule="evenodd"><path d="M0 0h20v20H0z"></path><path d="M3.5 12.503v4.25H17v-4.25h1.5v5.75H2v-5.75h1.5zM10.25 1.5l4.998 4.443-.996 1.121L11 4.173v10.33H9.5V4.173L6.248 7.064l-.996-1.121L10.25 1.5z" fill="currentColor"></path></g>
+</svg>

Fichier diff supprimé car celui-ci est trop grand
+ 1 - 0
packages/webview/src/assets/icons/icon-full-screen.svg


Fichier diff supprimé car celui-ci est trop grand
+ 3 - 0
packages/webview/src/assets/icons/icon-hand-tool.svg


+ 3 - 0
packages/webview/src/assets/icons/icon-header-clockwise.svg

@@ -0,0 +1,3 @@
+<svg width="24" height="24" viewBox="0 0 24 24">
+<path d="M12 2.1v9.599l9.6.001v10.2H1.8V2.1H12zm7.8 11.4H3.599v6.6H19.8v-6.6zm-9.6-9.6H3.6v7.799h6.599L10.2 3.9zm6.816-1.49a5.702 5.702 0 0 1 4.21 5.081l.929-.927 1.273 1.272-3.182 3.182-3.182-3.182 1.272-1.272 1.097 1.095a3.902 3.902 0 0 0-5.616-3.242l-.226.12-.9-1.558a5.684 5.684 0 0 1 4.325-.57z" fill="currentColor"></path>
+</svg>

+ 3 - 0
packages/webview/src/assets/icons/icon-header-counter-clockwise.svg

@@ -0,0 +1,3 @@
+<svg width="24" height="24" viewBox="0 0 24 24">
+<path d="M12 2.1v9.599l-9.6.001v10.2h19.8V2.1H12zM4.2 13.5h16.201v6.6H4.2v-6.6zm9.6-9.6h6.6v7.799h-6.599L13.8 3.9zM6.984 2.41a5.702 5.702 0 0 0-4.21 5.081l-.929-.927L.572 7.836l3.182 3.182 3.182-3.182-1.272-1.272-1.097 1.095a3.902 3.902 0 0 1 5.616-3.242l.226.12.9-1.558a5.684 5.684 0 0 0-4.325-.57z" fill="currentColor"></path>
+</svg>

Fichier diff supprimé car celui-ci est trop grand
+ 3 - 0
packages/webview/src/assets/icons/icon-header-sidebar-line.svg


+ 3 - 0
packages/webview/src/assets/icons/icon-header-zoom-in.svg

@@ -0,0 +1,3 @@
+<svg width="24" height="24" viewBox="0 0 24 24">
+<path d="M12.6 6v5.4H18v1.2h-5.401L12.6 18h-1.2l-.001-5.4H6v-1.2h5.4V6h1.2z" fill="currentColor"></path>
+</svg>

+ 3 - 0
packages/webview/src/assets/icons/icon-header-zoom-out.svg

@@ -0,0 +1,3 @@
+<svg width="24" height="24" viewBox="0 0 24 24">
+<path fill="currentColor" d="M6 11.4h12v1.2H6z"></path>
+</svg>

+ 3 - 0
packages/webview/src/assets/icons/icon-import.svg

@@ -0,0 +1,3 @@
+<svg width="20" height="20" viewBox="0 0 20 20">
+<g fill="none" fill-rule="evenodd"><path d="M0 0h20v20H0z"></path><path d="M18.5 16.476V18h-17v-1.524h17zM10.773 2l-.001 10.043 3.351-2.935 1.027 1.138-4.378 3.836v.108h-.124l-.648.57-.65-.57h-.123v-.108L4.85 10.246l1.027-1.138 3.35 2.936V2h1.546z" fill="currentColor"></path></g>
+</svg>

Fichier diff supprimé car celui-ci est trop grand
+ 1 - 0
packages/webview/src/assets/icons/icon-loading.svg


+ 3 - 0
packages/webview/src/assets/icons/icon-next-right.svg

@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12">
+<path d="M4.5,10l4-4-4-4-1,1,3,3-3,3Z" fill="currentColor"></path>
+</svg>

Fichier diff supprimé car celui-ci est trop grand
+ 1 - 0
packages/webview/src/assets/icons/icon-open-file.svg


+ 3 - 0
packages/webview/src/assets/icons/icon-outline.svg

@@ -0,0 +1,3 @@
+<svg width="24" height="24" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M3 1.25H15V2.75H3V1.25ZM15 5.25H3V6.75H15V5.25ZM15 13.25H3V14.75H15V13.25ZM15 9.25H6V10.75H15V9.25ZM0 5.25H1.5V6.75H0V5.25ZM1.5 13.25H0V14.75H1.5V13.25ZM0 1.25H1.5V2.75H0V1.25ZM4.5 9.25H3V10.75H4.5V9.25Z" fill="currentColor"/>
+</svg>

+ 3 - 0
packages/webview/src/assets/icons/icon-previous-left.svg

@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12">
+<path d="M7.5,2l-4,4,4,4,1-1-3-3,3-3Z" fill="currentColor"></path>
+</svg>

Fichier diff supprimé car celui-ci est trop grand
+ 1 - 0
packages/webview/src/assets/icons/icon-ruler.svg


+ 1 - 0
packages/webview/src/assets/icons/icon-save.svg

@@ -0,0 +1 @@
+<svg width="24" height="24" viewBox="0 0 24 24"><path d="M21 2.25v19.5H6.233L3 16.824V2.25h18zM6.3 3.75H4.5v12.626l2.543 3.874h1.319v-6h7.276l-.001 6H19.5V3.75h-1.8v8H6.3v-8zm7.837 12H9.862v4.5h4.275v-4.5zm2.063-12H7.8v6.5h8.4v-6.5z" fill="currentColor"></path></svg>

Fichier diff supprimé car celui-ci est trop grand
+ 3 - 0
packages/webview/src/assets/icons/icon-search.svg


Fichier diff supprimé car celui-ci est trop grand
+ 1 - 0
packages/webview/src/assets/icons/icon-setting.svg


+ 3 - 0
packages/webview/src/assets/icons/icon-sticky-note.svg

@@ -0,0 +1,3 @@
+<svg width="24" height="24" viewBox="0 0 24 24">
+<g fill-rule="evenodd"><path d="M21.6 3.6V18H12l-6 3.6V18H2.4V3.6h19.2zM18 11.4H6v1.8h12v-1.8zm-4.8-4.2H6V9h7.2V7.2z" fill="currentColor"></path></g>
+</svg>

+ 4 - 0
packages/webview/src/assets/icons/icon-thumbnail.svg

@@ -0,0 +1,4 @@
+<svg width="20" height="20" viewBox="0 0 20 20">
+<path d="M12.259 1.375l5.366 5.366v11.884H2.375V1.375h9.884zm-.885 1.25H3.625v14.749h12.749l-.001-9.749h-4.998l-.001-5zm4.117 3.75l-2.867-2.867v2.867h2.867z" fill="currentColor">
+</path>
+</svg>

Fichier diff supprimé car celui-ci est trop grand
+ 3 - 0
packages/webview/src/assets/icons/icon-view-layers.svg


+ 1 - 0
packages/webview/src/assets/logo.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"  xmlns:v="https://vecta.io/nano"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>

+ 112 - 0
packages/webview/src/components/App/index.vue

@@ -0,0 +1,112 @@
+<template>
+  <div id="outerContainer" class="app" :class="{ dark: themeMode === 'Dark' }">
+    <Header />
+    <div class="content">
+      <LeftPanel />
+      <DocumentContainer />
+    </div>
+  </div>
+  <div id="printContainer"></div>
+</template>
+<script setup>
+  import initDocument from '@/helpers/initDocument'
+  import { ref, computed, provide } from 'vue'
+  import { useViewerStore } from '@/stores/modules/viewer'
+
+  const useViewer = useViewerStore()
+  const themeMode = computed(() => useViewer.getThemeMode)
+
+  provide('themeMode', themeMode)
+  initDocument()
+  window.instance.changeFile = () => {
+    const openFileInput = document.getElementById("fileInput")
+    openFileInput.click()
+  }
+</script>
+
+<style lang="scss">
+  .dark {
+    .loading {
+      color: #FFF;
+    }
+    .form-container span {
+      color: #FFF;
+    }
+    .findbar {
+      background-color: #343A40;
+    }
+    .thumbnail .box-thumbnail {
+      color: #FFF;
+    }
+    .print-dialog .print-container, .password-dialog .password-container {
+      background-color: #000;
+      color: #FFF;
+    }
+    .outline-view, .layer-view {
+      .treeItem {
+        .title {
+          &:hover {
+            background-color: rgba(255, 255, 255, 0.1);
+          }
+          &.selected {
+            background-color: rgba(255, 255, 255, 0.2);
+          }
+          .treeItemToggler {
+            color: #FFF;
+          }
+        }
+        .title a, > a {
+          color: #FFF;
+        }
+        &:hover > a {
+          background-color: rgba(255, 255, 255, 0.1);
+        }
+        &.selected > a {
+          background-color: rgba(255, 255, 255, 0.2);
+        }
+      }
+    }
+    &#outerContainer .drop-down {
+      background-color: #343A40;
+      color: #FFF;
+    }
+    &#outerContainer .drop-item {
+      &:hover {
+        background-color: rgba(255, 255, 255, 0.1);
+      }
+      &.active {
+        background-color: rgba(255, 255, 255, 0.2);
+      }
+    }
+    .measure-pop {
+      background-color: #000;
+      div {
+        color: #FFF;
+      }
+    }
+  }
+ .app {
+    display: flex;
+    flex-direction: column;
+    position: relative;
+    width: 100%;
+    height: 100%;
+    margin: 0;
+    padding: 0;
+    -webkit-font-smoothing: antialiased;
+    overflow: hidden;
+  }
+  .content {
+    display: flex;
+    flex-direction: row;
+    flex: 1;
+    overflow: auto;
+  }
+  .divider {
+    width: 1px;
+    height: 20px;
+    margin-right: 8px;
+    background-color: var(--c-divider);
+    flex-shrink: 0;
+  }
+</style>

+ 84 - 0
packages/webview/src/components/Button/Button.vue

@@ -0,0 +1,84 @@
+<template>
+  <button
+    class="button"
+    :class="{
+      active: isActive,
+      disabled,
+      [mediaQueryClassName]: mediaQueryClassName,
+      [className]: className
+    }"
+    @click="disabled || !onClick ? NOOP() : onClick($event)"
+    @dblclick="disabled ? NOOP : onDoubleClick"
+    @mouseup="disabled ? NOOP : onDoubleClick"
+    :disabled="disabled"
+    :title="title"
+  >
+    <slot />
+  </button>
+</template>
+
+<script setup>
+  const NOOP = (e) => {
+    e?.stopPropagation()
+    e?.preventDefault()
+  }
+  const { img } = defineProps({
+    title: String,
+    isActive: Boolean,
+    disabled: Boolean || undefined,
+    className: String,
+    mediaQueryClassName: String,
+    img: String,
+    onClick: Function,
+  })
+  const isBase64 = img?.trim().startsWith('data:')
+  const isGlyph = img && !isBase64 && (!img.includes('.') || img.startsWith('<svg'))
+</script>
+
+<style lang="scss">
+  .button {
+    border: none;
+    background-color: transparent;
+    position: relative;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    border-radius: 4px;
+    cursor: pointer;
+    color: var(--c-text);
+  }
+  @media screen and (max-width: 577px) {
+    .button {
+      &:not(.disabled):active {
+        background-color: var(--c-header-button-hover);
+      }
+      &:not(.disabled).active {
+        background-color: var(--c-header-button-active);
+      }
+      &.disabled {
+        opacity: 0.3;
+        pointer-events: none;
+      }
+      .arrow-left {
+        margin-left: 9px;
+      }
+    }
+  }
+  @media screen and (min-width: 578px) {
+  .button {
+      &:not(.disabled):hover {
+        background-color: var(--c-header-button-hover);
+      }
+      &:not(.disabled).active {
+        background-color: var(--c-header-button-active);
+      }
+      &.disabled {
+        opacity: 0.3;
+        pointer-events: none;
+      }
+      .arrow-left {
+        margin-left: 9px;
+      }
+    }
+  }
+</style>

+ 590 - 0
packages/webview/src/components/DocumentContainer/DocumentContainer.vue

@@ -0,0 +1,590 @@
+<template>
+  <div
+    ref="mainContainer"
+    class="document-container"
+    :class="{ 'no-select': isHandActive }"
+    :style="{
+      width: `calc(100% - ${leftPanelSpace}px)`,
+      'margin-left': `${leftPanelSpace}px`,
+    }"
+  >
+    <div ref="viewerContainer" class="document"></div>
+    <PageNavOverlay :style="{ left: `calc((50% + ${leftPanelSpace/2}px))` }" />
+  </div>
+  <div id="findbar" class="findbar hidden">
+    <div id="findbarInputContainer" class="findbarInputContainer">
+      <div class="input-container">
+        <input id="findInput" class="toolbarField" title="Find" placeholder="Find in document…">
+        <div id="findResultsCount" class="toolbarLabel"></div>
+      </div>
+      <Button
+        img="icon-previous-left"
+        id="findPrevious"
+        class="toolbarButton disabled"
+        title="Find the previous occurrence of the phrase"
+      >
+        <ArrowPrev />
+      </Button>
+      <Button
+        img="icon-next-right"
+        id="findNext"
+        class="toolbarButton disabled"
+        title="Find the next occurrence of the phrase"
+      >
+        <ArrowNext />
+      </Button>
+    </div>
+  </div>
+  <div id="passwordDialog" class="password-dialog">
+    <div class="password-container">
+      <div class="row">
+        <label id="passwordText" for="password">Enter the password to open this PDF file.</label>
+      </div>
+      <div class="row">
+        <input id="password" type="password" class="toolbarField">
+      </div>
+      <div class="button-container">
+        <span id="passwordCancel" class="password-cancel">Cancel</span><span id="passwordSubmit" class="password-submit">OK</span>
+      </div>
+    </div>
+  </div>
+  <div id="printServiceDialog" class="print-dialog">
+    <div class="print-container">
+      <div class="print-title">Preparing document for printing…</div>
+      <div class="print-progress">
+        <progress value="0" max="100"></progress>
+        <span class="relative-progress">0%</span>
+      </div>
+    </div>
+  </div>
+  <MeasurePop />
+  <div v-if="loading && loadingPercent < 100" class="loading-state">Loading...</div>
+  <div v-show="!load && loadingPercent <= 0 && activePanel !== 'COMPARISON'" class="upload-container">
+    <input id="fileInput" type="file" accept=".pdf" @change="handleUpload" />
+    <label for="fileInput">Upload</label>
+  </div>
+</template>
+
+<script setup>
+  import { useViewerStore } from '@/stores/modules/viewer'
+  import { useDocumentStore } from '@/stores/modules/document'
+  import { computed, onMounted, ref } from 'vue'
+  import getHashParameters from '@/helpers/getHashParameters'
+  import core from '@/core'
+  const { loadDocument, initializeViewer, initConfig } = core
+  const mainContainer = ref()
+  const viewerContainer = ref()
+  const useViewer = useViewerStore()
+  const useDocument = useDocumentStore()
+
+  const isHandActive = computed(() => {
+    return useViewer.getActiveHand
+  })
+
+  const leftPanelSpace = computed(() => {
+    return useViewer.isLeftPanelOpen('leftPanel') ? 220 : 0
+  })
+  const loading = ref(false)
+  const activePanel = computed(() => useViewer.getActiveLeftPanel)
+  const load = ref(true)
+
+  async function handleUpload (evt) {
+    const file = evt.target.files[0];
+    if (!file) return
+    useViewer.$patch({
+      fullMode: false,
+      currentPage: 0,
+      scale: '',
+      themeMode: 'Light',
+      pageMode: 0,
+      scrollMode: 'Vertical',
+      activeTab: 0,
+      activeLeftPanel: 'THUMBS',
+      activeLeftPanelTab: 'THUMBS',
+      searchStatus: false,
+      openElements: {
+        header: true,
+        leftPanel: false
+      }
+    })
+    useDocument.resetSetting()
+    core.webViewerNamedAction('Find')
+    useDocument.setToolState('')
+
+    loading.value = true
+    let filename = file.name
+
+    load.value = true
+    let url = URL.createObjectURL(file);
+    await handlePdf(url, filename)
+    loading.value = false
+  }
+
+  const loadingPercent = computed(() => useDocument.getLoadingProgress)
+  async function handlePdf (pdf, filename = null) {
+    const options = {
+      extension: getHashParameters('extension', null),
+      filename: getHashParameters('filename', null) || filename,
+      externalPath: getHashParameters('p', ''),
+      documentId: getHashParameters('did', null),
+      progress: useDocument.setLoadingProgress,
+      pageChangedCallback: useViewer.setCurrentPage,
+      scaleChangedCallback: useViewer.setCurrentScale,
+      annotationsNumChangedCallback: useDocument.setAnnotationsCount,
+      distanceChangedCallback: useDocument.setDistance
+    }
+    try {
+      await loadDocument(pdf, options)
+    } catch (error) {
+      console.log(error)
+      if (error === 'invalid_file_error' || error === 'no_password_given' || error === 'incorrect_password') {
+        load.value = false
+        useDocument.setLoadingProgress(0)
+        useViewer.resetSetting()
+        const openFileInput = document.getElementById("fileInput")
+        openFileInput.value = ''
+        return
+      }
+    }
+    const totalPages = core.getPagesCount()
+    const scale = core.getScale()
+    useDocument.setTotalPages(totalPages)
+    useViewer.setCurrentPage(1)
+    useViewer.setCurrentScale(scale)
+  }
+
+  onMounted(async () => {
+    const res = await initConfig({
+      license: '3AxOJBuKTqXhp+I9om9P+fvB2DMYXQbYnwfM7uExRRo='
+      // license: 'oWXBw+tacVrMjTGxBuRlrn+BPEU/ndvg2aemjPNOlNM='
+      // license: 'pB3xWyaCvnrPR/fDkBPjh+E1LeA0e+bEj6Z7a5VI1tQ='
+      // license: 'e+L5dBrcDnQJXP98kF7oHEo11SLrWIWse5oWqj5ykMU='
+    })
+    if (!res) return
+    const thumbnailView = document.querySelector('.thumbnail-view')
+    const annotationView = document.querySelector('.annotation-view')
+    const outlineView = document.querySelector('.outline-view')
+    const layerView = document.querySelector('.layer-view')
+    initializeViewer({
+      container: mainContainer.value,
+      viewer: viewerContainer.value,
+      thumbnailView,
+      outlineView,
+      layerView,
+      annotationView,
+      toggleButton: document.querySelector('.toggle-button')
+    })
+    let initialDoc = getHashParameters('d', '');
+    initialDoc = initialDoc ? JSON.parse(initialDoc) : '/example/ComPDFKit  Introduction Booklet - PDF Technologies, Inc.pdf'
+    initialDoc = Array.isArray(initialDoc) ? initialDoc : [initialDoc]
+    const activeTab = useViewer.activeTab || 0
+    initialDoc = initialDoc[activeTab]
+    if (initialDoc) {
+      load.value = true
+      loading.value = true
+      await handlePdf(initialDoc)
+      loading.value = false
+    }
+  })
+</script>
+
+<style lang="scss">
+  .grab-to-pan-grab {
+    cursor: grab !important;
+  }
+  .grab-to-pan-grabbing {
+    cursor: grabbing !important;
+    position: fixed;
+    background: rgba(0, 0, 0, 0);
+    display: block;
+    top: 0;
+    left: 0;
+    right: 0;
+    bottom: 0;
+    overflow: hidden;
+    z-index: 50000;
+  }
+  .document-container {
+    overflow: auto;
+    position: absolute;
+    top: 44px;
+    right: 0;
+    bottom: 0;
+    left: 0;
+    outline: none;
+    transition: all .3s ease-in-out;
+  }
+  .no-select {
+    .textLayer span {
+      user-select: none;
+      -webkit-user-select: none;
+    }
+    .annotationContainer > div .freetext {
+      pointer-events: none;
+    }
+  }
+  .loading-state {
+    position: fixed;
+    left: 50%;
+    top: 50%;
+    transform: translate(-50%, -50%);
+    font-size: 46px;
+  }
+  .upload-container {
+    position: relative;
+    z-index: 1;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    width: 100%;
+    height: calc(100vh - 44px);
+    input {
+      display: none;
+    }
+    label {
+      display: inline-block;
+      background-color: #0097FF;
+      width: 260px;
+      height: 48px;
+      font-size: 24px;
+      line-height: 48px;
+      text-align: center;
+      color: #FFFFFF;
+    }
+  }
+  .annotationLayer {
+    background: transparent;
+    position: absolute;
+    top: 0;
+    left: 0;
+    -webkit-transform-origin: 0 0;
+    transform-origin: 0 0;
+    cursor: auto;
+    z-index: 2;
+  }
+  .annotationLayer svg {
+    pointer-events: auto;
+  }
+  .inkEditor .inkEditorCanvas {
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    touch-action: none;
+  }
+  .inkEditor {
+    position: absolute;
+    background: transparent;
+    border-radius: 3px;
+    overflow: auto;
+    width: 100%;
+    height: 100%;
+    z-index: 1;
+    -webkit-transform-origin: 0 0;
+    transform-origin: 0 0;
+    cursor: auto;
+  }
+  .inkEditor.selectedAnnotation {
+    outline: solid 2px rgb(71, 126, 222);
+  }
+  .stickyAnnotation, .editor-container {
+    position: absolute;
+    pointer-events: auto;
+  }
+  .stickyAnnotation {
+    outline: none;
+    font-size: 0;
+    border: 1px solid transparent;
+  }
+  .selectedAnnotation {
+    border-color: rgb(71, 126, 222);
+  }
+  .editor-container {
+    transform: translateX(-50%);
+    width: 300px;
+    height: 247px;
+    padding: 6px;
+    z-index: 1;
+    box-sizing: border-box;
+    background-color: rgb(255, 245, 203);
+    box-shadow: rgb(0, 0, 0, 10%) 0px -2px 12px 2px;
+    border-radius: 5px;
+    border: none;
+    outline: none;
+    font-size: 12px;
+    color: rgb(51, 51, 51);
+    line-height: 17px;
+    resize: none;
+    text-align: left;
+  }
+  .print-dialog {
+    display: none;
+    position: fixed;
+    top: 0;
+    bottom: 0;
+    left: 0;
+    right: 0;
+    z-index: 100;
+    background-color: rgba(0, 0, 0, 0.2);
+    &.show {
+      display: block;
+    }
+    .print-container {
+      position: absolute;
+      top: 50%;
+      left: 50%;
+      transform: translate(-50%, -50%);
+      width: 100%;
+      max-width: 254px;
+      padding: 15px;
+      border-spacing: 4px;
+      color: #000;
+      font-size: 12px;
+      line-height: 14px;
+      background-color: #FFF;
+      border: 1px solid rgba(0, 0, 0, 0.5);
+      border-radius: 4px;
+      box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
+    }
+    .print-title {
+      margin-bottom: 20px;
+    }
+    .print-progress .relative-progress {
+      margin-left: 10px;
+    }
+  }
+  .password-dialog {
+    display: none;
+    position: fixed;
+    top: 0;
+    bottom: 0;
+    left: 0;
+    right: 0;
+    z-index: 100;
+    background-color: rgba(0, 0, 0, 0.2);
+    &.show {
+      display: block;
+    }
+    .password-container {
+      position: absolute;
+      top: 50%;
+      left: 50%;
+      transform: translate(-50%, -50%);
+      width: 100%;
+      max-width: 270px;
+      padding: 15px;
+      border-spacing: 4px;
+      color: #000;
+      font-size: 12px;
+      line-height: 14px;
+      background-color: #FFF;
+      border: 1px solid rgba(0, 0, 0, 0.5);
+      border-radius: 4px;
+      box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
+    }
+    .row {
+      margin-bottom: 10px;
+      text-align: center;
+      input {
+        width: 100%;
+        padding: 4px 7px;
+        border-radius: 2px;
+        background-color: #FFF;
+        background-clip: padding-box;
+        border: 1px solid #ccc;
+        box-shadow: none;
+        font-size: 12px;
+        line-height: 16px;
+        outline: none;
+      }
+    }
+    .button-container {
+      display: flex;
+      justify-content: space-around;
+      span {
+        display: inline-block;
+        padding: 5px 10px;
+        border: 1px solid #ccc;
+        border-radius: 4px;
+        cursor: pointer;
+      }
+    }
+  }
+  @media screen and (max-width: 640px) {
+    .document-container {
+      width: 100%;
+    }
+  }
+  @media screen and (min-width: 641px) {
+    
+    .document-container {
+      width: calc(100% - 260px);
+      margin-left: 260px;
+    }
+  }
+  .document .page {
+    position: relative;
+    margin: 20px auto;
+    overflow: visible;
+    background-clip: content-box;
+    background-color: rgba(255, 255, 255, 1);
+    > .freetext {
+      position: absolute;
+      z-index: 5;
+      pointer-events: auto;
+      outline: none;
+    }
+    .text-title {
+      padding: 8px 0 5px;
+      font-weight: bold;
+      font-size: 14px;
+      line-height: 16px;
+      color: #333333;
+    }
+  }
+  .document.scrollHorizontal, .document.scrollHorizontal, .spread {
+    white-space: nowrap;
+    text-align: center;
+    .spread, .page {
+      display: inline-block;
+      margin: 20px;
+      vertical-align: middle;
+    }
+  }
+  .document.scrollHorizontal .spread {
+    margin: 0px;
+  }
+  .document {
+    &.annotation-edit .textLayer {
+      pointer-events: none;
+    }
+    .canvasWrapper {
+      position: absolute;
+      pointer-events: none;
+      overflow: hidden;
+      width: 100%;
+      height: 100%;
+      z-index: 1;
+    }
+    .page canvas {
+      width: 100%;
+      height: 100%;
+    }
+    .page.loadingIcon:after {
+      position: absolute;
+      top: 0;
+      left: 0;
+      content: "";
+      width: 100%;
+      height: 100%;
+      background: url('/images/loading-icon.gif') center no-repeat;
+      display: none;
+      transition-property: display;
+      transition-delay: 400ms;
+      z-index: 5;
+      contain: strict;
+    }
+    .page.loading:after {
+      display: block;
+    }
+  }
+  .textLayer {
+    position: absolute;
+    text-align: initial;
+    left: 0;
+    top: 0;
+    right: 0;
+    bottom: 0;
+    overflow: hidden;
+    line-height: 1;
+    -webkit-text-size-adjust: none;
+    -moz-text-size-adjust: none;
+    -ms-text-size-adjust: none;
+    text-size-adjust: none;
+    forced-color-adjust: none;
+    line-height: 1;
+    transform-origin: 0 0;
+    z-index: 3;
+    & ::selection {
+      background-color: rgba(0, 0, 255, 0.25);
+    }
+  }
+  [data-main-rotation="90"] {
+    transform: rotate(90deg) translateY(-100%);
+  }
+  .textLayer .markedContent {
+    display: none;
+  }
+  .textLayer br {
+    display: none;
+  }
+  .textLayer span {
+    color: transparent;
+    position: absolute;
+    white-space: pre;
+    cursor: text;
+    -webkit-transform-origin: 0% 0%;
+    transform-origin: 0% 0%;
+  }
+  .textLayer .highlight {
+    background-color: rgba(255, 255, 0, 0.25);
+  }
+  .textLayer .highlight.selected {
+    background-color: rgba(255, 255, 0, 0.7);
+  }
+  .textLayer .highlight.appended {
+    position: initial;
+  }
+  .findbar {
+    position: absolute;
+    right: 18px;
+    top: 45px;
+    z-index: 73;
+    width: 256px;
+    padding: 8px;
+    border-radius: 1px;
+    background-color: var(--c-findbar-bg);
+    box-shadow: 2px 6px 18px rgba(0, 0, 0, 0.2);
+
+    &.hidden {
+      display: none;
+    }
+    .findbarInputContainer {
+      display: flex;
+      align-items: center;
+    }
+    .input-container {
+      flex-grow: 1;
+      display: flex;
+      width: 180px;
+      margin: 3px 0;
+      padding: 0 8px;
+      border-radius: 1px;
+      border: 1px solid var(--c-findbar-input-border);
+      background-color: var(--c-findbar-input-bg);
+    }
+    input {
+      flex-grow: 1;
+      width: 0;
+      height: 30px;
+      outline: none;
+      padding: 5px 8px;
+      box-shadow: none;
+      border: none;
+      background-color: var(--c-findbar-input-bg);
+      color: var(--c-findbar-text);
+    }
+    .toolbarLabel {
+      font-size: 14px;
+      line-height: 30px;
+      color: var(--c-findbar-text);
+      white-space: nowrap;
+    }
+    .button {
+      margin-left: 8px;
+      padding: 0;
+      color: var(--c-findbar-text);
+    }
+  }
+</style>

+ 105 - 0
packages/webview/src/components/DownloadButton/DownloadButton.vue

@@ -0,0 +1,105 @@
+<template>
+  <Button @click="downloadFile">
+    <Download />
+  </Button>
+  <div class="mask" :class="{ active: downloading || downloadError }">
+    <div v-show="downloading" class="loading"><Loading /></div>
+    <div v-show="downloadError" class="error-dialog">
+      <div class="close-btn">
+        <Button @click="downloadError = false">
+          <Close />
+        </Button>
+      </div>
+      <div class="fail-logo"><Failed /></div>
+      <div class="text">Downloaded failed</div>
+      <div class="confirm-btn" @click="downloadError = false">OK</div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+  import core from '@/core'
+  import { ref } from 'vue';
+  
+  const { download } = core
+  const downloading = ref(false)
+  const downloadError = ref(false)
+
+  const downloadFile = async () => {
+    downloading.value = true
+    try {
+      const res = await download()
+      downloading.value = false
+      if (!res) {
+        downloadError.value = true
+      }
+    } catch (error) {
+      console.log(error)
+    }
+  }
+</script>
+
+<style lang="scss">
+@keyframes spin {
+  0% { transform: rotate(0deg); }
+  100% { transform: rotate(360deg); }
+}
+.mask {
+  display: none;
+  position: fixed;
+  z-index: 999;
+  top: 0;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  justify-content: center;
+  align-items: center;
+  background-color: rgba(0, 0, 0, 0.3);
+  text-align: center;
+  .loading svg {
+    animation: spin .8s linear infinite;
+  }
+  .close-btn .button {
+    margin-left: auto;
+    margin-right: 0;
+  }
+  &.active {
+    display: flex;
+  }
+  .error-dialog {
+    width: 100%;
+    max-width: 284px;
+    color: #232A40;
+    padding: 16px 16px 40px;
+    background: #FFFFFF;
+    box-shadow: 0px 4px 32px rgba(129, 149, 200, 0.32);
+    border-radius: 4px;
+    .fail-logo svg {
+      width: 60px;
+      height: 60px;
+      margin-top: 12px;
+      margin-bottom: 16px;
+    }
+    .text {
+      font-size: 16px;
+      line-height: 20px;
+      color: #232A40;
+    }
+    .confirm-btn {
+      width: 100%;
+      max-width: 220px;
+      margin: 30px auto 0;
+      padding: 12px 0;
+      color: #FFFFFF;
+      background-color: #1460F3;
+      border-radius: 6px;
+      display: flex;
+      align-items: center;
+      font-weight: 700;
+      font-size: 14px;
+      line-height: 16px;
+      justify-content: center;
+    }
+  }
+}
+</style>

+ 40 - 0
packages/webview/src/components/FullScreenButton/FullScreenButton.vue

@@ -0,0 +1,40 @@
+<template>
+  <Button
+    img="icon-full-screen"
+    className="full-screen"
+    v-bind="{ ...item }"
+    :onClick="onClick"
+  >
+    <Fullscreen />
+  </Button>
+</template>
+
+<script setup>
+  import core from '@/core'
+  import { computed } from 'vue'
+  import { useViewerStore } from '@/stores/modules/viewer'
+  const { requestFullScreenMode } = core
+
+  const { item } = defineProps(['item'])
+  const useViewer = useViewerStore()
+  const isActive = computed(() => {
+    return useViewer.isLeftPanelOpen('leftPanel')
+  })
+
+  const onClick = () => {
+    const isiPad = (navigator.userAgent.match(/(iPad)/) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1))
+    if (isActive.value && (window.innerWidth < 930 || isiPad)) {
+      useViewer.toggleElement('leftPanel')
+      core.toggleSidebar()
+    }
+    requestFullScreenMode()
+  }
+</script>
+
+<style>
+@media screen and (max-width: 767.9px) {
+  .full-screen {
+    display: none;
+  }
+}
+</style>

+ 36 - 0
packages/webview/src/components/HandButton/HandButton.vue

@@ -0,0 +1,36 @@
+<template>
+  <Button
+    :isActive="isActive"
+    img="icon-hand-tool"
+    v-bind="{ ...item }"
+    :onClick="onClick"
+  >
+    <Pantool />
+  </Button>
+</template>
+
+<script setup>
+  import { useViewerStore } from '@/stores/modules/viewer'
+  import { useDocumentStore } from '@/stores/modules/document'
+  import { computed } from 'vue'
+  import core from '@/core'
+  const { switchTool, switchAnnotationEditorMode } = core
+  const useViewer = useViewerStore()
+  const useDocument = useDocumentStore()
+  const { item } = defineProps(['item'])
+
+  const isActive = computed(() => {
+    return useViewer.getActiveHand
+  })
+  const onClick = () => {
+    window.getSelection().empty()
+    useViewer.toggleActiveHand(!isActive.value)
+    const mode = isActive.value ? 1 : 0
+    switchTool(mode)
+    switchAnnotationEditorMode(0)
+    useViewer.closeActiveStickNote()
+    core.webViewerNamedAction('Find', 4)
+
+    useDocument.setToolState('')
+  }
+</script>

+ 34 - 0
packages/webview/src/components/Header/Header.vue

@@ -0,0 +1,34 @@
+<template>
+  <div class="header">
+    <HeaderItems :items="items" :rightItems="rightItems" />
+  </div>
+</template>
+
+<script setup>
+import { useViewerStore } from '@/stores/modules/viewer'
+const useViewer = useViewerStore()
+const items = useViewer.getActiveHeaderItems
+const rightItems = useViewer.getActiveRightHeaderItems
+</script>
+
+<style lang="scss">
+  .header {
+    position: relative;
+    box-sizing: content-box;
+    display: flex;
+    align-items: center;
+    width: 100%;
+    height: 44px;
+    padding: 0 20px;
+    background-color: var(--c-header-bg);
+    border-bottom: 1px solid var(--c-header-border);
+    z-index: 72;
+  }
+  @media screen and (max-width: 767.9px) {
+    .header {
+      padding: 10px;
+      overflow-x: auto;
+      overflow-y: hidden;
+    }
+  }
+</style>

+ 61 - 0
packages/webview/src/components/HeaderItems/HeaderItems.vue

@@ -0,0 +1,61 @@
+<template>
+  <div class="header-items">
+    <template v-for="(item, index) in items" :key="`${item.type}-${item.dataElement || index}`">
+      <ToggleElementButton v-if="item.type === 'toggleElementButton'" :item="item" />
+      <ToolButton  v-else-if="item.type === 'toolButton'" :item="item" />
+      <PageDisplayButton v-else-if="item.type === 'pageDisplayButton'" :item="item" />
+      <FullScreenButton v-else-if="item.type === 'fullScreenButton'" :item="item" />
+      <HandButton v-else-if="item.type === 'handToolButton'" :item="item" />
+      <ZoomOverlay v-else-if="item.type === 'zoomOverlay'" />
+      <ThemeMode v-else-if="item.type === 'themeMode'" />
+      <ViewRotationControls v-else-if="item.type === 'viewRotationControls'" />
+      <div v-else-if="['spacer', 'divider'].includes(item.type)" :class="item.type"></div>
+    </template>
+    <div class="annotate-container">
+      <template v-for="(item, index) in items" :key="`${item.type}-${item.dataElement || index}`">
+        <CompareButton v-if="item.type === 'compareButton' && !item.hidden" />
+        <Annotate v-else-if="item.type === 'markup' && !item.hidden"/>
+        <MeasureButton v-else-if="item.type === 'measureButton' && !item.hidden" />
+        <SaveButton v-else-if="item.type === 'saveButton' && !item.hidden" />
+      </template>
+    </div>
+    <div class="right-container">
+      <template v-for="(item, index) in rightItems" :key="`${item.type}-${item.dataElement || index}`">
+        <SearchContainer v-if="item.type === 'searchButton'" :item="item" />
+        <OpenFileButton v-if="item.type === 'openFileButton'" :item="item" />
+        <DownloadButton v-if="item.type === 'downloadButton'" :item="item" />
+        <PrintButton v-if="item.type === 'printButton'" :item="item" />
+      </template>
+    </div>
+  </div>
+</template>
+
+<script  setup>
+defineProps(['items', 'rightItems'])
+
+</script>
+
+<style lang="scss">
+  .header-items {
+    display: flex;
+    align-items: center;
+    width: 100%;
+    .spacer {
+      flex: 1;
+      height: 100%;
+    }
+    .annotate-container {
+      flex: 1;
+      display: flex;
+      align-items: center;
+    }
+    .right-container {
+      display: flex;
+      align-items: center;
+    }
+    .button {
+      margin-right: 8px;
+      padding: 5px;
+    }
+  }
+</style>

+ 286 - 0
packages/webview/src/components/LeftPanel/LeftPanel.vue

@@ -0,0 +1,286 @@
+<template>
+  <div
+    class="left-panel"
+    :class="{
+      closed: !isOpen
+    }">
+    <div class="sidebar-tabs-header">
+      <LeftPanelTabs :activePanelTab="activePanelTab" />
+    </div>
+    <div class="sidebar-content">
+      <div class="thumbnail-container" :class="{ hidden: activePanelTab !== 'THUMBS' }">
+        <h2>Thumbnails</h2>
+        <div class="thumbnail-view"></div>
+      </div>
+      <div class="annotation-container" :class="{ hidden: activePanelTab !== 'ANNOTATION' }">
+        <AnnotationHeader />
+        <AnnotationContent />
+      </div>
+      <div class="outline-view-container" :class="{ hidden: activePanelTab !== 'OUTLINE' }">
+        <h2>Outline</h2>
+        <div class="outline-view"></div>
+      </div>
+      <div class="layer-view-container" :class="{ hidden: activePanelTab !== 'LAYER' }">
+        <h2>Layers</h2>
+        <div class="layer-view"></div>
+      </div>
+      <div class="compare-container" :class="{ hidden: activePanelTab !== 'COMPARISON' }">
+        <Compare />
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+  import { computed } from 'vue'
+  import { useViewerStore } from '@/stores/modules/viewer'
+
+  const useViewer = useViewerStore()
+
+  const activePanelTab = computed(() => useViewer.getActiveLeftPanelTab)
+  const isOpen = computed(() => useViewer.isLeftPanelOpen('leftPanel'))
+</script>
+
+<style lang="scss">
+  .left-panel {
+    position: fixed;
+    left: 0;
+    z-index: 65;
+    display: flex;
+    flex-direction: column;
+    height: calc(100% - 44px);
+    overflow: hidden;
+    transition: transform .3s ease-in-out;
+    background-color: var(--c-side-bg);
+    border-right: 1px solid var(--c-side-header-border);
+    &.closed {
+      transform: translateX(-100%);
+    }
+    a {
+      text-decoration: none;
+    }
+  }
+  .sidebar-tabs-header {
+    display: flex;
+    align-items: center;
+    background-color: var(--c-side-header-bg);
+    border-bottom: 1px solid var(--c-side-header-border);
+    .button {
+      flex: 1;
+      margin: 0;
+      padding: 12px 0;
+      border-radius: initial;
+      color: var(--c-side-header-text);
+      &:not(.disabled):hover {
+        background-color: var(--c-side-header-bg);
+      }
+      &:not(.disabled).active {
+        background-color: var(--c-side-header-active);
+      }
+    }
+  }
+  .compare-container {
+    padding: 16px 16px 0;
+  }
+  .sidebar-content {
+    width: 100%;
+    flex: 1;
+    overflow: hidden;
+    padding-right: 10px;
+    .hidden {
+      display: none;
+    }
+  }
+  h2 {
+    margin: 0;
+    padding: 9px 16px;
+    line-height: 17px;
+    font-size: 14px;
+    color: var(--c-side-title);
+  }
+  .thumbnail-container {
+    position: relative;
+    width: 100%;
+    height: 100%;
+  }
+  .thumbnail-view {
+    position: relative;
+    padding: 4px 4px 0;
+    overflow: auto;
+    width: 100%;
+    height: calc(100% - 35px);
+    -webkit-user-select: none;
+    -moz-user-select: none;
+    -ms-user-select: none;
+    user-select: none;
+  }
+  .thumbnail  {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    margin: 20px 0 40px;
+    .box-thumbnail {
+      margin-top: 8px;
+      font-size: 12px;
+      line-height: 19px;
+      width: 24px;
+      height: 18px;
+      color: var(--c-side-text);
+      text-align: center;
+    }
+    &.selected {
+      .thumbnailSelectionRing {
+        border: 1px solid var(--c-side-thumbnails-active);
+      }
+      .box-thumbnail {
+        color: rgb(255, 255, 255);
+        background-color: var(--c-side-thumbnails-active);
+      }
+    }
+  }
+  .thumbnailSelectionRing {
+    margin: 0 auto;
+    border: 1px solid var(--c-black-5);
+  }
+  .no-outline, .no-layer {
+    position: absolute;
+    top: 50%;
+    left: 50%;
+    transform: translate(-50%, 50%);
+    text-align: center;
+    font-weight: 700;
+    color: var(--c-side-text);
+    font-size: 14px;
+    line-height: 16px;
+  }
+  .outline-view-container, .annotation-container, .layer-view-container {
+    height: 100%;
+  }
+  .outline-view, .layer-view {
+    position: relative;
+    height: calc(100% - 35px);
+    overflow: auto;
+    .treeItem > .treeItems {
+      margin-left: 24px;
+    }
+    .treeItem {
+      .title {
+        display: flex;
+        align-items: center;
+        padding: 8px 16px;
+        color: var(--c-side-outline-text);
+        font-size: 14px;
+        line-height: 16px;
+        .treeItemToggler {
+          display: flex;
+          align-items: center;
+          margin-right: 8px;
+          transition: transform .1s ease;
+        }
+        &.treeItemsHidden > .treeItemToggler {
+          transform: rotate(0deg);
+        }
+        > .treeItemToggler {
+          transform: rotate(90deg);
+        }
+        &.treeItemsHidden ~ .treeItems {
+          display: none;
+        }
+        a {
+          color: var(--c-side-outline-text);
+        }
+        &.selected {
+          background-color: var(--c-side-outline-bg-active);
+        }
+      }
+      & > a {
+        display: block;
+        width: 100%;
+        padding: 8px 16px;
+        color: var(--c-side-outline-text);
+        font-size: 14px;
+        line-height: 16px;
+        & > label {
+          display: flex;
+          align-items: center;
+          > input {
+            margin: 3px 4px 0;
+          }
+        }
+      }
+    }
+  }
+  .layer-view {
+    .toggle {
+      display: flex;
+      align-items: center;
+      padding: 8px 16px;
+      font-size: 14px;
+      line-height: 16px;
+      color: var(--c-side-outline-text);
+      cursor: pointer;
+      svg {
+        display: none;
+        margin-right: 8px;
+      }
+      .not-checked {
+        display: block;
+        color: var(--c-side-layers-not-checked-border);
+        fill: var(--c-side-layers-not-checked-bg);
+      }
+      &.selected {
+        .not-checked {
+          display: none;
+        }
+        .checked {
+          display: block;
+          color: var(--c-side-layers-checked-bg);
+        }
+      }
+    }
+    .treeItem {
+      margin-left: 8px;
+    }
+    .treeItem > a {
+      display: flex;
+      align-items: center;
+      cursor: pointer;
+      > label > input {
+        opacity: 0;
+        display: none;
+      }
+      svg {
+        display: none;
+        margin-right: 8px;
+      }
+      .not-checked {
+        display: block;
+        color: var(--c-side-layers-not-checked-border);
+        fill: var(--c-side-layers-not-checked-bg);
+      }
+      &.checked {
+        .checked {
+          display: block;
+          color: var(--c-side-layers-checked-bg);
+        }
+        .not-checked {
+          display: none;
+        }
+      }
+    }
+  }
+  @media screen and (max-width: 640px) {
+    .left-panel {
+      width: 100vw;
+      z-index: 95;
+    }
+  }
+  @media screen and (min-width: 641px) {
+    .left-panel {
+      width: 260px;
+      .thumbnail-view {
+        padding: 0 16px;
+      }
+    }
+  }
+</style>

+ 68 - 0
packages/webview/src/components/LeftPanel/LeftPanelTabs.vue

@@ -0,0 +1,68 @@
+<template>
+  <Button
+    :className="activePanelTab === 'THUMBS' ? 'active' : ''"
+    img="icon-thumbnail"
+    title="Thumbnails"
+    dataElement="THUMBS"
+    :isActive="activePanelTab === 'THUMBS'"
+    @click="setActiveLeftPanelTab('THUMBS', 'thumbs')"
+  >
+    <Thumbnail />
+  </Button>
+  <Button
+    :className="activePanelTab === 'OUTLINE' ? 'active' : ''"
+    img="icon-outline"
+    title="Outline"
+    dataElement="OUTLINE"
+    :isActive="activePanelTab === 'OUTLINE'"
+    @click="setActiveLeftPanelTab('OUTLINE', 'outline')"
+  >
+    <Outline />
+  </Button>
+  <Button
+    :className="activePanelTab === 'ANNOTATION' ? 'active' : ''"
+    img="icon-annotation"
+    title="Annotation"
+    dataElement="ANNOTATION"
+    :isActive="activePanelTab === 'ANNOTATION'"
+    @click="setActiveLeftPanelTab('ANNOTATION', 'none')"
+  >
+    <Annotation />
+  </Button>
+  <Button
+    :className="activePanelTab === 'LAYER' ? 'active' : ''"
+    img="icon-view-layers"
+    title="Layers"
+    dataElement="Layers"
+    :isActive="activePanelTab === 'LAYER'"
+    @click="setActiveLeftPanelTab('LAYER', 'layers')"
+  >
+    <Layers />
+  </Button>
+  <Button
+    :className="activePanelTab === 'COMPARISON' ? 'active' : ''"
+    v-if="activeLeftPanel === 'COMPARISON'"
+    img="icon-compare"
+    title="Compare"
+    dataElement="COMPARISON"
+    :isActive="activePanelTab === 'COMPARISON'"
+    @click="setActiveLeftPanelTab('COMPARISON')"
+  />
+</template>
+
+<script setup>
+  import { computed } from 'vue'
+  import core from '@/core'
+  import { useViewerStore } from '@/stores/modules/viewer'
+
+
+  defineProps(['activePanelTab'])
+  const activeLeftPanel = computed(() => useViewer.activeLeftPanel)
+  const useViewer = useViewerStore()
+  const setActiveLeftPanelTab = (dataElement, mode) => {
+    useViewer.setActiveLeftPanelTab(dataElement)
+    setTimeout(() => {
+      core.webViewerPageMode(mode)
+    }, 0)
+  }
+</script>

+ 20 - 0
packages/webview/src/components/OpenFileButton/OpenFileButton.vue

@@ -0,0 +1,20 @@
+<template>
+  <Button
+    v-bind="{ ...item }"
+    @click="onClick"
+  >
+  <Icon
+    glyph="icon-open-file"
+  />
+  </Button>
+</template>
+
+<script setup>
+  const { item } = defineProps(['item'])
+
+  const onClick = () => {
+    // const openFileInput = document.getElementById("fileInput")
+    // openFileInput.click()
+    window.instance.changeFile()
+  }
+</script>

+ 80 - 0
packages/webview/src/components/PageDisplayButton/PageDisplayButton.vue

@@ -0,0 +1,80 @@
+<template>
+  <div class="setting-container">
+    <n-popover
+      ref="popover"
+      placement="top-start"
+      trigger="click"
+      :show-arrow="false"
+      to="#outerContainer"
+      :raw="true"
+    >
+      <template #trigger>
+        <Button v-bind="{ ...item }" :class="{ active: showDropdown }">
+          <Pageset />
+          <Arrow class="arrow-left" />
+        </Button>
+      </template>
+      <div class="drop-down">
+        <div class="drop-item" :class="{ active: scrollMode === 'Vertical' }" @click="handleScrollMode('Vertical', 0)"><Button><VerticalScrolling /></Button>Vertical Scrolling</div>
+        <div class="drop-item" :class="{ active: scrollMode === 'Horizontal' }" @click="handleScrollMode('Horizontal', 1)"><Button><HorizontalScrolling /></Button>Horizontal Scrolling</div>
+        <div class="drop-down-divider"></div>
+        <div class="drop-item" :class="{ active: pageMode === 0 }" @click="handleSpreadMode(0)"><Button><SinglePage /></Button>Single Page</div>
+        <div class="drop-item" :class="{ active: pageMode === 1 }" @click="handleSpreadMode(1)"><Button><DoublePage /></Button>Double Page</div>
+        <div class="drop-item" :class="{ active: pageMode === 2 }" @click="handleSpreadMode(2)"><Button><Pagecover /></Button>Cover Facing Page</div>
+        <div class="drop-down-divider"></div>
+        <div class="drop-item" @click="rotateClockwise"><Button><Rotateright /></Button>Rotate Right</div>
+        <div class="drop-item" @click="rotateCounterclockwise"><Button><Rotateleft /></Button>Rotate Left</div>
+        
+        <Button
+          img="icon-header-counter-clockwise"
+          title="Rotate Counterclockwise"
+          :onClick="rotateCounterclockwise"
+        />
+        <Button
+          img="icon-header-clockwise"
+          title="Rotate Clockwise"
+          :onClick="rotateClockwise"
+        />
+      </div>
+    </n-popover>
+  </div>
+</template>
+
+<script setup>
+  import { computed, ref } from 'vue'
+  import { NPopover } from 'naive-ui'
+  import { useViewerStore } from '@/stores/modules/viewer'
+  import core from '@/core'
+
+  const popover = ref(null)
+
+  const useViewer = useViewerStore()
+
+  const { item } = defineProps(['item'])
+  const scrollMode = computed(() => useViewer.getScrollMode)
+  const pageMode = computed(() => useViewer.getPageMode)
+
+  const showDropdown = computed(() => popover.value && popover.value.getMergedShow())
+
+  const handleScrollMode = (mode, modeNum) => {
+    useViewer.setScrollMode(mode)
+    core.switchScrollMode(modeNum)
+    popover.value.setShow(false)
+  }
+  const handleSpreadMode = (mode) => {
+    core.switchSpreadMode(mode)
+    popover.value.setShow(false)
+    useViewer.setPageMode(mode)
+  }
+
+  const rotateCounterclockwise = () => {
+    core.rotateCounterclockwise()
+    popover.value.setShow(false)
+
+  }
+  const rotateClockwise = () => {
+    core.rotateClockwise()
+    popover.value.setShow(false)
+  }
+</script>
+

+ 142 - 0
packages/webview/src/components/PageNavOverlay/PageNavOverlay.vue

@@ -0,0 +1,142 @@
+<template>
+  <div v-if="totalPages > 0" class="page-nav">
+    <Button
+      className="side-arrow-container"
+      img="icon-previous-left"
+      :title="isFirstPage ? null : 'Previous Page'"
+      :onClick="goToPrevPage"
+      :disabled="isFirstPage"
+    >
+      <ArrowPrev />
+    </Button>
+    <div class="form-container">
+      <input
+        type="number"
+        v-model="pageValue"
+        @input="onChange"
+        @blur="handleBlur"
+        @keydown.enter="goToPage"
+        tabIndex=-1
+        :style="{ width: pageValue.toString().length * 8 + 'px' }"
+      />
+      <span>{{ `/${totalPages}` }}</span>
+    </div>
+    <Button
+      className="side-arrow-container"
+      img="icon-next-right"
+      :title="isLastPage ? null : 'Next Page'"
+      @click="goToNextPage"
+      :disabled="isLastPage"
+    >
+      <ArrowNext />
+    </Button>
+  </div>
+</template>
+
+<script setup>
+  import { ref, computed, watch, effect } from 'vue'
+  import { useViewerStore } from '@/stores/modules/viewer'
+  import { useDocumentStore } from '@/stores/modules/document'
+  import core from '@/core'
+  const useViewer = useViewerStore()
+  const useDocument = useDocumentStore()
+  const pageValue = ref(useViewer.getCurrentPage)
+
+  effect(() => pageValue.value = useViewer.getCurrentPage)
+
+  const isFirstPage = computed(() => {
+    return useViewer.getCurrentPage === 1
+  })
+
+  const totalPages = computed(() => useDocument.getTotalPages)
+
+  const isLastPage = computed(() => {
+    return totalPages === useViewer.getCurrentPage
+  })
+
+  watch(pageValue, (value, prev) => {
+    if (value !== '' && (value > totalPages || value <= 0)) {
+      pageValue.value = prev
+    }
+  })
+  const goToPrevPage = () => {
+    const previousFlag = core.previousPage()
+    if (previousFlag) {
+      useViewer.setCurrentPage(core.getCurrentPage())
+    }
+  }
+
+  const goToNextPage = () => {
+    const nextFlag = core.nextPage()
+    if (nextFlag) {
+      useViewer.setCurrentPage(core.getCurrentPage())
+    }
+  }
+  const goToPage = (event) => {
+    event.target.blur()
+    if (!event.target.value) {
+      pageValue.value = useViewer.getCurrentPage
+      return
+    }
+    core.pageNumberChanged(event.target)
+  }
+
+  const handleBlur = () => {
+    pageValue.value = useViewer.getCurrentPage
+  }
+</script>
+
+<style lang="scss">
+  .page-nav {
+    position: fixed;
+    bottom: 20px;
+    left: 50%;
+    z-index: 12;
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    transform: translateX(-50%);
+    width: 100px;
+    height: 36px;
+    padding: 8px;
+    background: rgba(0, 0, 0, 0.8);
+    border-radius: 2px;
+    transition: left 0.3s ease-in-out;
+    .button {
+      color: #FFF;
+      padding: 0;
+      &:hover, &:active {
+        background-color: transparent;
+      }
+    }
+  }
+  .form-container {
+    display: flex;
+    align-items: center;
+    input {
+      min-width: 8px;
+      line-height: 16px;
+      padding: 0;
+      text-align: right;
+      color: rgb(36, 42, 51);
+      background-color: transparent;
+      outline: none;
+      border: none;
+      color: #FFF;
+      box-sizing: border-box;
+      border-bottom: 1px solid #FFF;
+      text-align: center;
+      -moz-appearance: textfield;
+      &::-webkit-inner-spin-button {
+        -webkit-appearance: none;
+      }
+    }
+    span {
+      display: inline-block;
+      font-size: 12px;
+      line-height: 16px;
+      color: #FFF;
+      white-space: nowrap;
+    }
+  }
+</style>

+ 41 - 0
packages/webview/src/components/PrintButton/PrintButton.vue

@@ -0,0 +1,41 @@
+<template>
+  <Button @click="handlePrint">
+    <Print />
+  </Button>
+  <div class="mask" :class="{ active: downloading || downloadError }">
+    <div v-show="downloading" class="loading"><Loading /></div>
+    <div v-show="downloadError" class="error-dialog">
+      <div class="close-btn">
+        <Button @click="downloadError = false">
+          <Close />
+        </Button>
+      </div>
+      <div class="fail-logo"><Failed /></div>
+      <div class="text">Print failed</div>
+      <div class="confirm-btn" @click="downloadError = false">OK</div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+  import { ref } from 'vue';
+  import core from '@/core'
+  
+  const downloading = ref(false)
+  const downloadError = ref(false)
+
+  const handlePrint = async() => {
+    downloading.value = true
+    try {
+      const res = await core.webViewerNamedAction('Print')
+      downloading.value = false
+      // debugger
+      if (!res) {
+        downloadError.value = true
+      }
+    } catch (error) {
+      console.log(error)
+    }
+  }
+</script>
+

+ 39 - 0
packages/webview/src/components/ThemeMode/ThemeMode.vue

@@ -0,0 +1,39 @@
+<template>
+  <div class="theme-mode">
+    <Button @click="handleThemeMode('Light')" :class="{ active: themeMode === 'Light' }">
+      <Light />
+    </Button>
+    <Button @click="handleThemeMode('Dark')" :class="{ active: themeMode === 'Dark' }">
+      <Dark />
+    </Button>
+  </div>
+</template>
+
+<script setup>
+  import { computed, watch } from 'vue'
+  import { useViewerStore } from '@/stores/modules/viewer'
+
+  const useViewer = useViewerStore()
+
+  const themeMode = computed(() => useViewer.getThemeMode)
+  watch(themeMode, () => {
+    const $html = document.documentElement
+    $html.classList.toggle('dark')
+  })
+
+  const handleThemeMode = (mode) => {
+    if (mode === themeMode.value) return
+    useViewer.setThemeMode(mode)
+  }
+</script>
+
+<style lang="scss">
+.theme-mode {
+  display: flex;
+  justify-content: center;
+  .button.active {
+    color: var(--c-header-text-theme-active);
+    background-color: transparent !important;
+  }
+}
+</style>

+ 23 - 0
packages/webview/src/components/ViewRotationControls/ViewRotationControls.vue

@@ -0,0 +1,23 @@
+<template>
+  <Button
+    img="icon-header-counter-clockwise"
+    title="Rotate Counterclockwise"
+    :onClick="rotateCounterclockwise"
+  />
+  <Button
+    img="icon-header-clockwise"
+    title="Rotate Clockwise"
+    :onClick="rotateClockwise"
+  />
+</template>
+
+<script setup>
+  import core from '@/core'
+
+  const rotateCounterclockwise = () => {
+    core.rotateCounterclockwise()
+  }
+  const rotateClockwise = () => {
+    core.rotateClockwise()
+  }
+</script>

+ 137 - 0
packages/webview/src/components/ZoomOverlay/ZoomOverlay.vue

@@ -0,0 +1,137 @@
+<template>
+  <div class="zoom-overlay">
+    <div class="input-container">
+      <input v-model="scaleValue" type="text" @input="onChange" @keydown.enter="changeScale" @blur="handleBlur" :style="{ width: inputWidth + 'px' }" />
+      <div class="drop-down-zoom">
+        <span>%</span>
+        <!-- <svg data-status="normal" width="16" height="16" viewBox="0 0 16 16"><g fill="none" fill-rule="evenodd"><path d="M0 0h16v16H0z"></path><path fill="#757780" d="M11.06 5.909l.88.815-3.69 3.983-3.69-3.983.88-.815 2.81 3.032z"></path></g></svg> -->
+      </div>
+    </div>
+    <Button
+      img="icon-header-zoom-out"
+      @click="zoomOut"
+      title="Zoom Out"
+      dataElement="zoomOutButton"
+    >
+      <ZoomOut />
+    </Button>
+    <Button
+      img="icon-header-zoom-in"
+      @click="zoomIn"
+      title="Zoom In"
+      dataElement="zoomInButton"
+    >
+      <ZoomIn />
+    </Button>
+  </div>
+</template>
+
+<script setup>
+  import core from '@/core'
+  import { useViewerStore } from '@/stores/modules/viewer'
+  import { computed, getCurrentInstance } from 'vue';
+
+  const { proxy: { $forceUpdate } } = getCurrentInstance()
+
+  const useViewer = useViewerStore()
+
+  const scaleValue = computed(() => useViewer.getScale)
+
+  const zoomOut = () => {
+    if (scaleValue.value <= 50) return
+    core.zoomOut()
+  }
+  const zoomIn = () => {
+    if (scaleValue.value > 1000) return
+    core.zoomIn()
+  }
+
+  const onChange = (event) => {
+    const re = /^(\d){0,4}$/;
+    const value = event.target.value
+    if (re.test(value) || value === '') {
+      useViewer.setCurrentScale(value / 100);
+    } else {
+      useViewer.setCurrentScale(useViewer.getScale / 100)
+      $forceUpdate()
+    }
+  }
+
+  const changeScale = (event) => {
+    const value = event.target.value
+    if (!value) {
+      scaleValue.value = useViewer.getScale
+      return
+    }
+    const re = /^(\d){0,4}$/;
+    if (re.test(value)) {
+      let scale = Math.max(50, value)
+      scale = Math.min(1000, scale)
+      useViewer.setCurrentScale(scale / 100);
+      core.scaleChanged(scale / 100)
+    } else {
+      useViewer.setCurrentScale(useViewer.getScale / 100)
+      $forceUpdate()
+    }
+  }
+
+  const inputWidth = computed(() => {
+    return scaleValue.value ? scaleValue.value.toString().length * 9 : 0
+
+  })
+
+  const handleBlur = (event) => {
+    changeScale(event)
+  }
+</script>
+
+<style lang="scss">
+  .zoom-overlay {
+    display: flex;
+    align-items: center;
+    margin-right: 8px;
+    .button {
+      margin-right: 4px!important;
+    }
+    .input-container {
+      box-sizing: border-box;
+      display: inline-flex;
+      -webkit-box-align: center;
+      align-items: center;
+      height: 30px;
+      margin-right: 4px;
+      border: 1px solid var(--c-header-input-border);
+      border-radius: 2px;
+      padding: 5px 8px;
+      transition: background-color 200ms cubic-bezier(0, 0, 0.2, 1) 0ms;
+      outline: none;
+      font-size: 14px;
+      .drop-down-zoom {
+        font-size: 0;
+        color: var(--c-text);
+      }
+      span {
+        margin-left: 2px;
+        font-size: 14px;
+      }
+      input {
+        background-color: transparent;
+        outline: none;
+        border: none;
+        min-width: 27px;
+        padding: 0px;
+        color: var(--c-text);
+        text-align: right;
+        -moz-appearance: textfield;
+        &::-webkit-inner-spin-button {
+          -webkit-appearance: none;
+        }
+      }
+    }
+  }
+  @media screen and (max-width: 575px) {
+    .input-container {
+      display: none;
+    }
+  }
+</style>

+ 3 - 0
packages/webview/src/components/toolButton/toolButton.vue

@@ -0,0 +1,3 @@
+<template>
+  <div></div>
+</template>

+ 3 - 0
packages/webview/src/core/addEvent.js

@@ -0,0 +1,3 @@
+import core from '@/core'
+
+export default (eventName, fn) => core.getDocumentViewer().addEvent(eventName, fn)

+ 18 - 0
packages/webview/src/core/documentViewers.js

@@ -0,0 +1,18 @@
+const documentViewerMap = new Map()
+
+export const setDocumentViewer = (number, documentViewer) => {
+  documentViewerMap.set(number, documentViewer)
+  return documentViewer
+}
+
+export const deleteDocumentViewer = (number) => {
+  documentViewerMap.delete(number)
+}
+
+export const getDocumentViewer = (number = 1) => {
+  return documentViewerMap.get(number)
+}
+
+export const getDocumentViewers = () => {
+  return Array.from(documentViewerMap.values())
+}

+ 3 - 0
packages/webview/src/core/download.js

@@ -0,0 +1,3 @@
+import core from '@/core'
+
+export default () => core.getDocumentViewer().download()

+ 3 - 0
packages/webview/src/core/eventBus.js

@@ -0,0 +1,3 @@
+import core from '@/core'
+
+export default () => core.getDocumentViewer().eventBus

+ 3 - 0
packages/webview/src/core/getCurrentPage.js

@@ -0,0 +1,3 @@
+import core from '@/core'
+
+export default () => core.getDocumentViewer().page

+ 4 - 0
packages/webview/src/core/getPagesCount.js

@@ -0,0 +1,4 @@
+import core from '@/core'
+
+export default () => core.getDocumentViewer().pagesCount
+

+ 3 - 0
packages/webview/src/core/getScale.js

@@ -0,0 +1,3 @@
+import core from '@/core'
+
+export default () => core.getDocumentViewer().scale

+ 69 - 0
packages/webview/src/core/index.js

@@ -0,0 +1,69 @@
+import { getDocumentViewer, setDocumentViewer, getDocumentViewers, deleteDocumentViewer } from './documentViewers'
+import loadDocument from './loadDocument'
+import initializeViewer from './initializeViewer'
+import toggleSidebar from './toggleSidebar'
+import getPagesCount from './getPagesCount'
+import nextPage from './nextPage'
+import previousPage from './previousPage'
+import getCurrentPage from './getCurrentPage'
+import rotateClockwise from './rotateClockwise'
+import rotateCounterclockwise from './rotateCounterclockwise'
+import zoomIn from './zoomIn'
+import zoomOut from './zoomOut'
+import pageNumberChanged from './pageNumberChanged'
+import scaleChanged from './scaleChanged'
+import getScale from './getScale'
+import switchAnnotationEditorMode from './switchAnnotationEditorMode'
+import switchTool from './switchTool'
+import requestFullScreenMode from './requestFullScreenMode'
+import saveAnnotations from './saveAnnotations'
+import removeAllAnnotations from './removeAllAnnotations'
+import getOptionUrl from './getOptionUrl'
+import importAnnotations from './importAnnotations'
+import switchSpreadMode from './switchSpreadMode'
+import webViewerNamedAction from './webViewerNamedAction'
+import switchScrollMode from './switchScrollMode'
+import initConfig from './initConfig'
+import download from './download'
+import addEvent from './addEvent'
+import setTool from './setTool'
+import downloadXfdf from './downloadXfdf'
+import webViewerPageMode from './webViewerPageMode'
+
+
+export default {
+  getDocumentViewer,
+  setDocumentViewer,
+  getDocumentViewers,
+  deleteDocumentViewer,
+  loadDocument,
+  initializeViewer,
+  toggleSidebar,
+  getPagesCount,
+  nextPage,
+  previousPage,
+  getCurrentPage,
+  rotateClockwise,
+  rotateCounterclockwise,
+  zoomIn,
+  zoomOut,
+  pageNumberChanged,
+  scaleChanged,
+  getScale,
+  switchAnnotationEditorMode,
+  switchTool,
+  requestFullScreenMode,
+  saveAnnotations,
+  removeAllAnnotations,
+  getOptionUrl,
+  importAnnotations,
+  switchSpreadMode,
+  webViewerNamedAction,
+  switchScrollMode,
+  initConfig,
+  download,
+  addEvent,
+  setTool,
+  downloadXfdf,
+  webViewerPageMode
+}

+ 3 - 0
packages/webview/src/core/initConfig.js

@@ -0,0 +1,3 @@
+import core from '@/core'
+
+export default (options) => core.getDocumentViewer().initConfig(options)

+ 19 - 0
packages/webview/src/core/initializeViewer.js

@@ -0,0 +1,19 @@
+import core from '@/core'
+
+export default ({
+  container,
+  viewer,
+  thumbnailView,
+  layerView,
+  annotationView,
+  outlineView,
+  toggleButton
+}) => core.getDocumentViewer().initializeViewer({
+  container,
+  viewer,
+  thumbnailView,
+  layerView,
+  annotationView,
+  outlineView,
+  toggleButton
+})

+ 3 - 0
packages/webview/src/core/loadDocument.js

@@ -0,0 +1,3 @@
+import core from '@/core'
+
+export default (src, options) => core.getDocumentViewer().loadDocument(src, options)

+ 3 - 0
packages/webview/src/core/nextPage.js

@@ -0,0 +1,3 @@
+import core from '@/core'
+
+export default () => core.getDocumentViewer().nextPage()

+ 3 - 0
packages/webview/src/core/pageNumberChanged.js

@@ -0,0 +1,3 @@
+import core from '@/core'
+
+export default (target) => core.getDocumentViewer().pageNumberChanged(target.value)

+ 3 - 0
packages/webview/src/core/previousPage.js

@@ -0,0 +1,3 @@
+import core from '@/core'
+
+export default () => core.getDocumentViewer().previousPage()

+ 3 - 0
packages/webview/src/core/requestFullScreenMode.js

@@ -0,0 +1,3 @@
+import core from '@/core'
+
+export default () => core.getDocumentViewer().requestFullScreenMode()

+ 3 - 0
packages/webview/src/core/rotateClockwise.js

@@ -0,0 +1,3 @@
+import core from '@/core'
+
+export default () => core.getDocumentViewer().webViewerRotateCw()

+ 3 - 0
packages/webview/src/core/rotateCounterclockwise.js

@@ -0,0 +1,3 @@
+import core from '@/core'
+
+export default () => core.getDocumentViewer().webViewerRotateCcw()

+ 3 - 0
packages/webview/src/core/scaleChanged.js

@@ -0,0 +1,3 @@
+import core from '@/core'
+
+export default (scale) => core.getDocumentViewer().webViewerScaleChanged(scale)

+ 9 - 0
packages/webview/src/core/setTool.js

@@ -0,0 +1,9 @@
+import core from '@/core'
+
+export default ({
+  tool,
+  color
+}) => core.getDocumentViewer().setTool({
+  tool,
+  color
+})

+ 3 - 0
packages/webview/src/core/switchScrollMode.js

@@ -0,0 +1,3 @@
+import core from '@/core'
+
+export default (mode) => core.getDocumentViewer().webViewerSwitchScrollMode(mode)

+ 3 - 0
packages/webview/src/core/switchSpreadMode.js

@@ -0,0 +1,3 @@
+import core from '@/core'
+
+export default (mode) => core.getDocumentViewer().webViewerSwitchSpreadMode(mode)

+ 3 - 0
packages/webview/src/core/switchTool.js

@@ -0,0 +1,3 @@
+import core from '@/core'
+
+export default (mode) => core.getDocumentViewer().switchTool(mode)

+ 3 - 0
packages/webview/src/core/toggleSidebar.js

@@ -0,0 +1,3 @@
+import core from '@/core'
+
+export default () => core.getDocumentViewer().toggleSidebar()

+ 3 - 0
packages/webview/src/core/webViewerNamedAction.js

@@ -0,0 +1,3 @@
+import core from '@/core'
+
+export default (action, close) => core.getDocumentViewer().webViewerNamedAction({ action, close })

+ 3 - 0
packages/webview/src/core/webViewerPageMode.js

@@ -0,0 +1,3 @@
+import core from '@/core'
+
+export default (mode) => core.getDocumentViewer().webViewerPageMode({ mode })

+ 3 - 0
packages/webview/src/core/zoomIn.js

@@ -0,0 +1,3 @@
+import core from '@/core'
+
+export default () => core.getDocumentViewer().zoomIn()

+ 3 - 0
packages/webview/src/core/zoomOut.js

@@ -0,0 +1,3 @@
+import core from '@/core'
+
+export default () => core.getDocumentViewer().zoomOut()

+ 33 - 0
packages/webview/src/helpers/device.js

@@ -0,0 +1,33 @@
+export const isDesktop = () => window.innerWidth > 900;
+export const isTabletOrMobile = () => window.innerWidth <= 900;
+export const isMobile = () => window.innerWidth < 640;
+export const isIEEdge = navigator.userAgent.indexOf('Edge') > -1;
+export const isIEEdgeChromium = navigator.userAgent.indexOf('Edg/') > -1;
+export const isIE11 = navigator.userAgent.indexOf('Trident/7.0') > -1;
+export const isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1;
+export const isIE = isIEEdge || isIE11;
+// https://stackoverflow.com/a/58064481
+export const isIEEdgeLegacy = isIEEdge && !isIEEdgeChromium;
+const checkForIOS13 = (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
+export const isIOS = window.navigator.userAgent.match(/(iPad|iPhone|iPod)/i) || checkForIOS13;
+export const isAndroid = window.navigator.userAgent.match(/Android/i);
+export const isMobileDevice = isIOS || isAndroid || window.navigator.userAgent.match(/webOS|BlackBerry|IEMobile|Opera Mini/i);
+export const isMobileDeviceFunc = () => window.navigator.userAgent.match(/(iPad|iPhone|iPod)/i) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1) || window.navigator.userAgent.match(/Android/i) || window.navigator.userAgent.match(/webOS|BlackBerry|IEMobile|Opera Mini/i);
+export const isMac = navigator.appVersion.indexOf('Mac') > -1;
+export const isWindows = navigator.appVersion.indexOf('Windows') > -1;
+
+
+export const isChrome = (function() {
+  // opera, edge, and maxthon have chrome in their useragent string so we need to be careful!
+  const opera = window.navigator.userAgent.match(/OPR/);
+  const maxthon = window.navigator.userAgent.match(/Maxthon/);
+  const edge = window.navigator.userAgent.match(/Edge/);
+
+  return (window.navigator.userAgent.match(/Chrome\/(.*?) /) && !opera && !maxthon && !edge);
+})();
+
+export const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent) || (/^((?!chrome|android).)*$/.test(navigator.userAgent) && isIOS);
+
+export const isChromeOniOS = window.navigator.userAgent.match(/CriOS\/(.*?) /);
+
+export const isFirefoxOniOS = window.navigator.userAgent.match(/(FxiOS)\/(.*?) /);

+ 22 - 0
packages/webview/src/helpers/getHashParameters.js

@@ -0,0 +1,22 @@
+function getHash () {
+  let url = window.location.href
+  let index = url ? url.indexOf("#") : -1
+  return 0 <= index ? url.substring(index + 1) : ""
+}
+
+export default function getHashParameters (hash, value) {
+  let type = typeof value
+  let hashParameters = getHash().split("&").reduce(function(hashObject, hash) {
+    hash = hash.split("=")
+    hashObject[decodeURIComponent(hash[0])] = decodeURIComponent(hash[1])
+    return hashObject
+  }, {})
+  if ("boolean" === type && "undefined" !== typeof hashParameters[hash]) {
+    type = hashParameters[hash]
+    if ("true" === type || "1" === type)
+        return !0
+    if ("false" === type || "0" === type)
+        return !1
+  }
+  return hashParameters[hash] || value
+}

+ 7 - 0
packages/webview/src/helpers/initDocument.js

@@ -0,0 +1,7 @@
+import core from '@/core'
+import ComPDFKitViewer from '../../lib/webview.min.js'
+export default () => {
+   const documentViewer = new ComPDFKitViewer()
+   window.instance = documentViewer
+   core.setDocumentViewer(1, documentViewer)
+}

+ 16 - 0
packages/webview/src/helpers/loadDocument.js

@@ -0,0 +1,16 @@
+import core from '@/core'
+import { useDocumentStore } from '@/stores/modules/document'
+const useDocument = useDocumentStore()
+
+export default (src, options = {}) => {
+  const documentViewer = core.getDocumentViewer()
+  documentViewer.init()
+  documentViewer.close()
+  documentViewer.load()
+  options.docId = options.documentId || null;
+  options.progress = (percent) => useDocument.setLoadingProgress(percent);
+
+  // ignore caught errors because they are already being handled in the onError callback
+  core.loadDocument(src, options, documentViewerKey).catch(() => {});
+  dispatch(actions.openElement('progressModal'));
+}