Sfoglia il codice sorgente

add: 添加 annotation 基础功能

liutian 2 anni fa
parent
commit
15fa7d982f

+ 12 - 1
packages/core/package.json

@@ -14,7 +14,18 @@
   "author": "",
   "license": "ISC",
   "dependencies": {
-    "pdfjs-dist": "^3.3.122"
+    "crypto-js": "^4.1.1",
+    "dayjs": "^1.11.6",
+    "lodash": "^4.17.21",
+    "nanoid": "^4.0.2",
+    "open-color": "^1.9.1",
+    "pdfjs-dist": "^3.3.122",
+    "perfect-freehand": "^1.2.0",
+    "points-on-curve": "^1.0.0",
+    "print-js": "^1.6.0",
+    "roughjs": "^4.5.2",
+    "uuid": "^9.0.0",
+    "uuidv4": "^6.2.13"
   },
   "devDependencies": {
     "@babel/core": "^7.19.6",

File diff suppressed because it is too large
+ 382 - 3
packages/core/src/index.js


+ 508 - 0
packages/core/src/pdf_annotation.js

@@ -0,0 +1,508 @@
+import { bindEvents } from './ui_utils.js'
+import { KeyboardManager } from "./tools.js";
+
+class PDFAnnotation {
+
+  static _zIndex = 1;
+  static _defaultLineColor = "#ff0000"
+
+  constructor (options) {
+    this._boundFocusin = this.focusin.bind(this);
+  
+    this._boundFocusout = this.focusout.bind(this);
+  
+    this._hasBeenSelected = false;
+  
+    this._isEditing = false;
+  
+    this._isInEditMode = false;
+    this.annotation = options.annotation || null;
+    this.parent = options.parent
+    this.id = options.id;
+    this.width = this.height = null;
+    this.pageIndex = options.parent.pageIndex;
+    this.name = options.name;
+    this.div = null;
+
+    this.viewport = this.parent.viewport
+    const [width, height] = this.parent.viewportBaseDimensions;
+    this.x = options.x / width;
+    this.y = options.y / height;
+    this.rotation = this.viewport.rotation;
+
+    this.isAttachedToDOM = false;
+  }
+
+  /**
+   * 设置zIndex在其他之下
+   */
+  setInBackground() {
+    this.div.style.zIndex = 0;
+  }
+
+  /**
+   * onfocus 事件触发函数
+   */
+  focusin() {
+    if (!this._hasBeenSelected) {
+      this.parent.setSelected(this);
+    } else {
+      this._hasBeenSelected = false;
+    }
+  }
+
+  /**
+   * onblur 事件触发函数
+   * @param {FocusEvent} event
+   */
+  focusout(event) {
+    if (!this.isAttachedToDOM) {
+      return;
+    }
+
+    // In case of focusout, the relatedTarget is the element which
+    // is grabbing the focus.
+    // So if the related target is an element under the div for this
+    // editor, then the editor isn't unactive.
+    const target = event.relatedTarget;
+    if (target?.closest(`#${this.id}`)) {
+      return;
+    }
+
+    event.preventDefault();
+
+    if (!this.parent.isMultipleSelection) {
+      this.commitOrRemove();
+    }
+    this.parent.unselect(this);
+  }
+
+  commitOrRemove() {
+    if (this.isEmpty()) {
+      this.remove();
+    } else {
+      this.commit();
+    }
+  }
+
+  fixDims() {
+    const { style } = this.div;
+    const { height, width } = style;
+    const widthPercent = width.endsWith("%");
+    const heightPercent = height.endsWith("%");
+    if (widthPercent && heightPercent) {
+      return;
+    }
+
+    const [parentWidth, parentHeight] = this.parent.viewportBaseDimensions;
+    if (!widthPercent) {
+      style.width = `${(100 * parseFloat(width)) / parentWidth}%`;
+    }
+    if (!heightPercent) {
+      style.height = `${(100 * parseFloat(height)) / parentHeight}%`;
+    }
+  }
+
+  /**
+   * 提交注释数据
+   */
+   commit() {
+    this.parent.addToAnnotationStorage(this);
+  }
+
+  /**
+   * 移动注释
+   * @param {DragEvent} event
+   */
+   dragstart(event) {
+    const rect = this.parent.div.getBoundingClientRect();
+    this.startX = event.clientX - rect.x;
+    this.startY = event.clientY - rect.y;
+    event.dataTransfer.setData("text/plain", this.id);
+    event.dataTransfer.effectAllowed = "move";
+  }
+
+  /**
+   * 设置注释位置
+   * @param {number} x
+   * @param {number} y
+   * @param {number} tx - x-translation in screen coordinates.
+   * @param {number} ty - y-translation in screen coordinates.
+   */
+  setAt(x, y, tx, ty) {
+    const [width, height] = this.parent.viewportBaseDimensions;
+    [tx, ty] = this.screenToPageTranslation(tx, ty);
+
+    this.x = (x + tx) / width;
+    this.y = (y + ty) / height;
+
+    this.div.style.left = `${100 * this.x}%`;
+    this.div.style.top = `${100 * this.y}%`;
+  }
+
+  /**
+   * 转换基于父级的位置
+   * @param {number} x - x-translation in screen coordinates.
+   * @param {number} y - y-translation in screen coordinates.
+   */
+  translate(x, y) {
+    const [width, height] = this.parent.viewportBaseDimensions;
+    [x, y] = this.screenToPageTranslation(x, y);
+
+    this.x += x / width;
+    this.y += y / height;
+    this.div.style.left = `${100 * this.x}%`;
+    this.div.style.top = `${100 * this.y}%`;
+  }
+
+  /**
+   * Convert a screen translation into a page one.
+   * @param {number} x
+   * @param {number} y
+   */
+  screenToPageTranslation(x, y) {
+    const { rotation } = this.parent.viewport;
+    switch (rotation) {
+      case 90:
+        return [y, -x];
+      case 180:
+        return [-x, -y];
+      case 270:
+        return [-y, x];
+      default:
+        return [x, y];
+    }
+  }
+
+  /**
+   * 设置注释的尺寸
+   * @param {number} width
+   * @param {number} height
+   */
+  setDims(width, height) {
+    const [parentWidth, parentHeight] = this.parent.viewportBaseDimensions;
+    this.div.style.width = `${(100 * width) / parentWidth}%`;
+    this.div.style.height = `${(100 * height) / parentHeight}%`;
+  }
+
+  /**
+   * Get the translation used to position this editor when it's created.
+   * @returns {Array<number>}
+   */
+   getInitialTranslation() {
+    return [0, 0];
+  }
+
+  /**
+   * Render this editor in a div.
+   * @returns {HTMLDivElement}
+   */
+  render() {
+    this.div = document.createElement("div");
+    this.div.setAttribute("data-editor-rotation", (360 - this.rotation) % 360);
+    this.div.className = this.name;
+    this.div.setAttribute("id", this.id);
+    this.div.setAttribute("tabIndex", 0);
+
+    // this.setInForeground();
+
+    this.div.addEventListener("focusin", this._boundFocusin);
+    this.div.addEventListener("focusout", this._boundFocusout);
+
+    if (this.annotation) {
+      const annotRect = this.rectCalc(position, this.parent.viewport.height, scale);
+      this.div.style.top = annotRect.top + 'px'
+      this.div.style.left = annotRect.left + 'px'
+    } else {
+      const [tx, ty] = this.getInitialTranslation();
+      this.translate(tx, ty);
+    }
+
+    bindEvents(this, this.div, ["dragstart", "pointerdown"]);
+
+    return this.div;
+  }
+
+  rectCalc (
+    position,
+    viewHeight,
+    scale
+  ) {
+    return {
+      top: viewHeight - position.top * scale,
+      left: position.left * scale,
+      width: (position.right - position.left) * scale,
+      height: (position.top - position.bottom) * scale,
+    }
+  }
+
+  /**
+   * Onpointerdown callback.
+   * @param {PointerEvent} event
+   */
+   pointerdown(event) {
+    const isMac = KeyboardManager.platform.isMac;
+    if (event.button !== 0 || (event.ctrlKey && isMac)) {
+      // Avoid to focus this editor because of a non-left click.
+      event.preventDefault();
+      return;
+    }
+
+    if (
+      (event.ctrlKey && !isMac) ||
+      event.shiftKey ||
+      (event.metaKey && isMac)
+    ) {
+      this.parent.toggleSelected(this);
+    } else {
+      this.parent.setSelected(this);
+    }
+
+    this._hasBeenSelected = true;
+  }
+
+  getRect(tx, ty) {
+    const [parentWidth, parentHeight] = this.parent.viewportBaseDimensions;
+    const [pageWidth, pageHeight] = this.parent.pageDimensions;
+    const shiftX = (pageWidth * tx) / parentWidth;
+    const shiftY = (pageHeight * ty) / parentHeight;
+    const x = this.x * pageWidth;
+    const y = this.y * pageHeight;
+    const width = this.width * pageWidth;
+    const height = this.height * pageHeight;
+
+    switch (this.rotation) {
+      case 0:
+        return [
+          x + shiftX,
+          pageHeight - y - shiftY - height,
+          x + shiftX + width,
+          pageHeight - y - shiftY,
+        ];
+      case 90:
+        return [
+          x + shiftY,
+          pageHeight - y + shiftX,
+          x + shiftY + height,
+          pageHeight - y + shiftX + width,
+        ];
+      case 180:
+        return [
+          x - shiftX - width,
+          pageHeight - y + shiftY,
+          x - shiftX,
+          pageHeight - y + shiftY + height,
+        ];
+      case 270:
+        return [
+          x - shiftY - height,
+          pageHeight - y - shiftX - width,
+          x - shiftY,
+          pageHeight - y - shiftX,
+        ];
+      default:
+        throw new Error("Invalid rotation");
+    }
+  }
+
+  getRectInCurrentCoords(rect, pageHeight) {
+    const [x1, y1, x2, y2] = rect;
+
+    const width = x2 - x1;
+    const height = y2 - y1;
+
+    switch (this.rotation) {
+      case 0:
+        return [x1, pageHeight - y2, width, height];
+      case 90:
+        return [x1, pageHeight - y1, height, width];
+      case 180:
+        return [x2, pageHeight - y1, width, height];
+      case 270:
+        return [x2, pageHeight - y2, height, width];
+      default:
+        throw new Error("Invalid rotation");
+    }
+  }
+
+  /**
+   * Enable edit mode.
+   */
+  enableEditMode() {
+    this._isInEditMode = true;
+  }
+
+  /**
+   * Disable edit mode.
+   */
+  disableEditMode() {
+    this._isInEditMode = false;
+  }
+
+  /**
+   * Check if the editor is edited.
+   * @returns {boolean}
+   */
+  isInEditMode() {
+    return this._isInEditMode;
+  }
+
+  /**
+   * If it returns true, then this editor handle the keyboard
+   * events itself.
+   * @returns {boolean}
+   */
+  shouldGetKeyboardEvents() {
+    return false;
+  }
+
+  /**
+   * Check if this editor needs to be rebuilt or not.
+   * @returns {boolean}
+   */
+  needsToBeRebuilt() {
+    return this.div && !this.isAttachedToDOM;
+  }
+
+  /**
+   * Rebuild the editor in case it has been removed on undo.
+   *
+   * To implement in subclasses.
+   */
+  rebuild() {
+    this.div?.addEventListener("focusin", this._boundFocusin);
+  }
+
+  /**
+   * Remove this editor.
+   * It's used on ctrl+backspace action.
+   */
+  remove() {
+    this.div.removeEventListener("focusin", this._boundFocusin);
+    this.div.removeEventListener("focusout", this._boundFocusout);
+
+    if (!this.isEmpty()) {
+      // The editor is removed but it can be back at some point thanks to
+      // undo/redo so we must commit it before.
+      this.commit();
+    }
+    this.parent.remove(this);
+  }
+
+  /**
+   * Select this editor.
+   */
+  select() {
+    this.div?.classList.add("selectedAnnotation");
+  }
+
+  /**
+   * Unselect this editor.
+   */
+  unselect() {
+    this.div?.classList.remove("selectedAnnotation");
+  }
+
+  /**
+   * Update some parameters which have been changed through the UI.
+   * @param {number} type
+   * @param {*} value
+   */
+  updateParams(type, value) {}
+
+  /**
+   * When the user disables the editing mode some editors can change some of
+   * their properties.
+   */
+  disableEditing() {}
+
+  /**
+   * When the user enables the editing mode some editors can change some of
+   * their properties.
+   */
+  enableEditing() {}
+
+  /**
+   * Get some properties to update in the UI.
+   * @returns {Object}
+   */
+  get propertiesToUpdate() {
+    return {};
+  }
+
+  /**
+   * Get the div which really contains the displayed content.
+   */
+  get contentDiv() {
+    return this.div;
+  }
+
+  /**
+   * If true then the editor is currently edited.
+   * @type {boolean}
+   */
+  get isEditing() {
+    return this._isEditing;
+  }
+
+  /**
+   * When set to true, it means that this editor is currently edited.
+   * @param {boolean} value
+   */
+  set isEditing(value) {
+    this._isEditing = value;
+    if (value) {
+      this.parent.setSelected(this);
+      this.parent.setActiveEditor(this);
+    } else {
+      this.parent.setActiveEditor(null);
+    }
+  }
+  /**
+   * Deserialize the editor.
+   * The result of the deserialization is a new editor.
+   *
+   * @param {Object} data
+   * @param {AnnotationEditorLayer} parent
+   * @returns {AnnotationEditor}
+   */
+  static deserialize(data, parent) {
+    const editor = new this.prototype.constructor({
+      parent,
+      id: parent.getNextId(),
+    });
+    editor.rotation = data.rotation;
+
+    const [pageWidth, pageHeight] = parent.pageDimensions;
+    const [x, y, width, height] = editor.getRectInCurrentCoords(
+      data.rect,
+      pageHeight
+    );
+    editor.x = x / pageWidth;
+    editor.y = y / pageHeight;
+    editor.width = width / pageWidth;
+    editor.height = height / pageHeight;
+
+    return editor;
+  }
+
+  static renderImportAnnotations(data, parent) {
+    const editor = new this.prototype.constructor({
+      parent,
+      id: parent.getNextId(),
+    });
+    editor.rotation = data.rotation;
+    const [pageWidth, pageHeight] = parent.pageDimensions;
+
+    
+    const annotRect = this.prototype.rectCalc(data.position, parent.viewport.height, parent.scaleFactor);
+    editor.x = annotRect.top / pageWidth;
+    editor.y = annotRect.left / pageHeight;
+    editor.width = annotRect.width / pageWidth;
+    editor.height = annotRect.height / pageHeight;
+
+    return editor;
+  }
+}
+
+export { PDFAnnotation };

+ 350 - 0
packages/core/src/pdf_annotation_editor.js

@@ -0,0 +1,350 @@
+import { bindEvents } from './ui_utils.js'
+
+export class PDFAnnotationEditor {
+  constructor (options) {
+    this.boundEditorDivBlur = this.editorDivBlur.bind(this);
+  
+    this.boundEditorDivFocus = this.editorDivFocus.bind(this);
+  
+    this.boundFocusin = this.focusin.bind(this);
+
+    this.boundFocusout = this.focusout.bind(this);
+    this._isEditing = false
+    this.boundEditorDivInput = this.editorDivInput.bind(this);
+    this.isAttachedToDOM = false;
+    this.hasBeenSelected = false
+    this.parent = options.parent
+    this.id = options.id;
+    this.width = this.height = null;
+    this.pageDiv = options.pageDiv
+    this.pageIndex = options.parent.pageIndex;
+    this.name = options.name;
+    this.div = null
+    this.hasAlreadyBeenCommitted = false
+    this.annotationObj = null
+
+    const [width, height] = this.parent.viewportBaseDimensions;
+    this.x = options.x / width;
+    this.y = options.y / height;
+    this.rotation = this.parent.viewport.rotation;
+
+    this.isAttachedToDOM = false;
+    this.content = ""
+  }
+
+  rectCalc (
+    position,
+    viewHeight,
+    scale
+  ) {
+    return {
+      top: viewHeight - position.top * scale,
+      left: position.left * scale,
+      width: (position.right - position.left) * scale,
+      height: (position.top - position.bottom) * scale,
+    }
+  }
+
+  editorDivBlur(event) {
+    this.isEditing = false;
+  }
+  editorDivFocus(event) {
+    this.isEditing = true;
+  }
+
+  editorDivInput(event) {
+    this.parent.div.classList.toggle("freeTextEditing", this.isEmpty());
+  }
+
+  /** @inheritdoc */
+  isEmpty() {
+    return !this.editorDiv || this.editorDiv.innerText.trim() === "";
+  }
+
+  commit() {
+    this.parent.addToAnnotationStorage(this);
+    if (!this.hasAlreadyBeenCommitted) {
+      // This editor has something and it's the first time
+      // it's commited so we can add it in the undo/redo stack.
+      this.hasAlreadyBeenCommitted = true;
+    }
+
+    this.disableEditMode();
+    this.content = this.extractText().trimEnd();
+  }
+
+  extractText() {
+    const divs = this.editorDiv.getElementsByTagName("div");
+    if (divs.length === 0) {
+      return this.editorDiv.innerText;
+    }
+    const buffer = [];
+    for (const div of divs) {
+      const first = div.firstChild;
+      if (first?.nodeName === "#text") {
+        buffer.push(first.data);
+      } else {
+        buffer.push("");
+      }
+    }
+    return buffer.join("\n");
+  }
+
+  render() {
+    if (this.div) {
+      return this.div;
+    }
+    this.div = document.createElement("div");
+    this.div.setAttribute("data-editor-rotation", (360 - this.rotation) % 360);
+    this.div.setAttribute("tabIndex", 0);
+
+    bindEvents(this, this.div, ["dragstart"]);
+
+    if (this.annotation) {
+      const annotRect = this.rectCalc(position, this.parent.viewport.height, scale);
+      this.div.style.top = annotRect.top + 'px'
+      this.div.style.left = annotRect.left + 'px'
+    } else {
+      const [tx, ty] = this.getInitialTranslation();
+      this.translate(tx, ty);
+    }
+    this.div.classList = "icon-wrapper"
+    this.div.innerHTML= `<svg data-status="normal" width="32" height="32" viewBox="0 0 32 32"><g fill="none" fill-rule="evenodd"><path d="M0 0h32v32H0z"></path><path d="M28 5.333V24H14.667L8 28v-4H4V5.333h24zM18.667 18H9.333v1.333h9.334V18zm4-4H9.333v1.333h13.334V14zm0-4H9.333v1.333h13.334V10z" fill="#BE0C0E"></path></g></svg>`
+    if (!this.pageDiv) {
+      this.pageDiv = this.parent.createWrapper()
+    }
+    this.pageDiv.append(this.div)
+    this.editorDiv = document.createElement("div");
+    this.editorDiv.className = "editor-container";
+
+    this.editorDiv.setAttribute("id", this.id);
+    this.editorDiv.addEventListener("focusin", this.boundFocusin);
+    this.editorDiv.addEventListener("focusout", this.boundFocusout);
+
+    this.editorDiv.contentEditable = true;
+
+    const { style } = this.editorDiv;
+    if (this.annotation) {
+      const annotRect = this.rectCalc(position, this.parent.viewport.height, scale);
+      style.top = annotRect.top + 36 + 'px'
+      style.left = annotRect.left - 106 + 'px'
+    } else {
+      style.top = `calc(${100 * this.y}% + 36px`
+      style.left = `calc(${100 * this.x}% - 134px)`
+    }
+
+    this.pageDiv.append(this.editorDiv);
+
+    // TODO: implement paste callback.
+    // The goal is to sanitize and have something suitable for this
+    // editor.
+    bindEvents(this, this.div, ["dblclick"]);
+
+    for (const line of this.content.split("\n")) {
+      const div = document.createElement("div");
+      div.append(
+        line ? document.createTextNode(line) : document.createElement("br")
+      );
+      this.editorDiv.append(div);
+    }
+
+    this.div.draggable = true;
+    this.editorDiv.contentEditable = false;
+  }
+
+  /** @inheritdoc */
+  getInitialTranslation() {
+    // The start of the base line is where the user clicked.
+    return [0, 0];
+  }
+
+  /**
+   * onfocus callback.
+   */
+  focusin(event) {
+    if (!this.hasBeenSelected) {
+      this.parent.setSelected(this);
+    } else {
+      this.hasBeenSelected = false;
+    }
+  }
+
+  /**
+   * onblur callback.
+   * @param {FocusEvent} event
+   */
+  focusout(event) {
+    if (!this.isAttachedToDOM) {
+      return;
+    }
+
+    // In case of focusout, the relatedTarget is the element which
+    // is grabbing the focus.
+    // So if the related target is an element under the div for this
+    // editor, then the editor isn't unactive.
+    const target = event.relatedTarget;
+    event.preventDefault();
+
+    this.commitOrRemove();
+  }
+  
+  commitOrRemove() {
+    if (this.isEmpty()) {
+      this.remove();
+    } else {
+      this.commit();
+    }
+  }
+
+  /**
+   * Select this editor.
+   */
+  select() {
+    this.div?.classList.add("selectedEditor");
+  }
+
+  /**
+   * Unselect this editor.
+   */
+  unselect() {
+    this.div?.classList.remove("selectedEditor");
+  }
+
+  /**
+   * Remove this editor.
+   * It's used on ctrl+backspace action.
+   */
+  remove() {
+    this.div.removeEventListener("focusin", this.boundFocusin);
+    this.div.removeEventListener("focusout", this.boundFocusout);
+
+    if (!this.isEmpty()) {
+      // The editor is removed but it can be back at some point thanks to
+      // undo/redo so we must commit it before.
+      this.commit();
+    }
+    this.parent.remove(this);
+  }
+  
+  /**
+   * Translate the editor position within its parent.
+   * @param {number} x - x-translation in screen coordinates.
+   * @param {number} y - y-translation in screen coordinates.
+   */
+   translate(x, y) {
+    const [width, height] = this.parent.viewportBaseDimensions;
+    [x, y] = this.screenToPageTranslation(x, y);
+
+    this.x += x / width;
+    this.y += y / height;
+
+    this.div.style.left = `${100 * this.x}%`;
+    this.div.style.top = `${100 * this.y}%`;
+  }
+
+  /**
+   * Convert a screen translation into a page one.
+   * @param {number} x
+   * @param {number} y
+   */
+  screenToPageTranslation(x, y) {
+    const { rotation } = this.parent.viewport;
+    switch (rotation) {
+      case 90:
+        return [y, -x];
+      case 180:
+        return [-x, -y];
+      case 270:
+        return [-y, x];
+      default:
+        return [x, y];
+    }
+  }
+
+  onceAdded() {
+    if (this.width) {
+      // The editor was created in using ctrl+c.
+      return;
+    }
+    this.enableEditMode();
+    this.editorDiv.focus();
+  }
+
+  dblclick(event) {
+    this.enableEditMode();
+    this.editorDiv.focus();
+  }
+
+  /**
+   * We use drag-and-drop in order to move an editor on a page.
+   * @param {DragEvent} event
+   */
+  dragstart(event) {
+    const rect = this.parent.div.getBoundingClientRect();
+    this.startX = event.clientX - rect.x;
+    this.startY = event.clientY - rect.y;
+    event.dataTransfer.setData("text/plain", this.id);
+    event.dataTransfer.effectAllowed = "move";
+  }
+
+  /** @inheritdoc */
+  enableEditMode() {
+    if (this.isInEditMode) {
+      return;
+    }
+
+    this.isInEditMode = true;
+    this.overlayDiv?.classList.remove("enabled");
+    this.editorDiv.contentEditable = true;
+    this.editorDiv.style.display = 'initial';
+    this.div.draggable = false;
+    this.div.removeAttribute("aria-activedescendant");
+    this.editorDiv.addEventListener("focus", this.boundEditorDivFocus);
+    this.editorDiv.addEventListener("blur", this.boundEditorDivBlur);
+    this.editorDiv.addEventListener("input", this.boundEditorDivInput);
+  }
+
+  /** @inheritdoc */
+  disableEditMode() {
+    if (!this.isInEditMode) {
+      return;
+    }
+
+    this.isInEditMode = false;
+    this.editorDiv.contentEditable = false;
+    this.div.draggable = true;
+    this.editorDiv.removeEventListener("focus", this.boundEditorDivFocus);
+    this.editorDiv.removeEventListener("blur", this.boundEditorDivBlur);
+    this.editorDiv.removeEventListener("input", this.boundEditorDivInput);
+    this.editorDiv.style.display = "none"
+    // On Chrome, the focus is given to <body> when contentEditable is set to
+    // false, hence we focus the div.
+    this.div.focus();
+
+    // In case the blur callback hasn't been called.
+    this.isEditing = false;
+    this.parent.div.classList.add("freeTextEditing");
+  }
+
+  /**
+   * If true then the editor is currently edited.
+   * @type {boolean}
+   */
+   get isEditing() {
+    return this._isEditing;
+  }
+
+  /**
+   * When set to true, it means that this editor is currently edited.
+   * @param {boolean} value
+   */
+  set isEditing(value) {
+    this._isEditing = value;
+    if (value) {
+      this.parent.setSelected(this);
+      this.parent.setActiveEditor(this);
+    } else {
+      this.parent.setActiveEditor(null);
+    }
+  }
+}

+ 621 - 0
packages/core/src/pdf_annotation_layer.js

@@ -0,0 +1,621 @@
+import { KeyboardManager } from "./tools.js";
+import { AnnotationEditorType } from "../constants"
+import { bindEvents } from "./ui_utils.js";
+import { PDFAnnotationSticky } from "./pdf_annotation_sticky.js";
+import { InkEditor } from "./pdf_measure_editor.js";
+
+/**
+ * Manage all the different editors on a page.
+ */
+class PDFAnnotationLayer {
+
+  /**
+   * @param {AnnotationEditorLayerOptions} options
+   */
+  constructor(options) {
+    this._cancelled = false;
+
+    this._allowClick = false;
+
+    this._boundPointerup = this.pointerup.bind(this);
+  
+    this._boundPointerdown = this.pointerdown.bind(this);
+  
+    this._editors = new Map();
+  
+    this._hadPointerDown = false;
+  
+    this._isCleaningUp = false;
+
+    this._uiManager = options.uiManager;
+
+    this.pageDiv = options.pageDiv;
+    this.div = null;
+    this.scale = options.scale;
+    this._accessibilityManager = options.accessibilityManager
+    options.uiManager.registerEditorTypes([PDFAnnotationSticky, InkEditor]);
+    this.annotationStorage = options.annotationStorage || null;
+    this.pageIndex = options.pageIndex;
+  }
+
+  /**
+   * Update the toolbar if it's required to reflect the tool currently used.
+   * @param {number} mode
+   */
+  updateToolbar(mode) {
+    this._uiManager.updateToolbar(mode);
+  }
+
+  /**
+   * The mode has changed: it must be updated.
+   * @param {number} mode
+   */
+  updateMode(mode = this._uiManager.getMode()) {
+    this._cleanup();
+    if (mode === AnnotationEditorType.INK) {
+      // We always want to an ink editor ready to draw in.
+      this.addInkEditorIfNeeded(false);
+      this.disableClick();
+    } else {
+      this.enableClick();
+    }
+    this._uiManager.unselectAll();
+
+    this.div.classList.toggle(
+      "freeTextEditing",
+      mode === AnnotationEditorType.FREETEXT
+    );
+    this.div.classList.toggle("inkEditing", mode === AnnotationEditorType.INK);
+  }
+
+  moveEditorInDOM(editor) {
+    this._accessibilityManager?.moveElementInDOM(
+      this.div,
+      editor.div,
+      editor.contentDiv,
+      /* isRemovable = */ true
+    );
+  }
+
+  addInkEditorIfNeeded(isCommitting) {
+    if (
+      !isCommitting &&
+      this._uiManager.getMode() !== AnnotationEditorType.INK
+    ) {
+      return;
+    }
+
+    if (!isCommitting) {
+      // We're removing an editor but an empty one can already exist so in this
+      // case we don't need to create a new one.
+      for (const editor of this._editors.values()) {
+        if (editor.isEmpty()) {
+          editor.setInBackground();
+          return;
+        }
+      }
+    }
+
+    const editor = this._createAndAddNewEditor({ offsetX: 0, offsetY: 0 });
+    editor.setInBackground();
+  }
+
+  /**
+   * Set the editing state.
+   * @param {boolean} isEditing
+   */
+  setEditingState(isEditing) {
+    this._uiManager.setEditingState(isEditing);
+  }
+
+  /**
+   * Add some commands into the CommandManager (undo/redo stuff).
+   * @param {Object} params
+   */
+  addCommands(params) {
+    this._uiManager.addCommands(params);
+  }
+
+  /**
+   * Enable pointer events on the main div in order to enable
+   * editor creation.
+   */
+  enable() {
+    this.div.style.pointerEvents = "auto";
+    for (const editor of this._editors.values()) {
+      editor.enableEditing();
+    }
+  }
+
+  /**
+   * Disable editor creation.
+   */
+  disable() {
+    this.div.style.pointerEvents = "none";
+    for (const editor of this._editors.values()) {
+      editor.disableEditing();
+    }
+  }
+
+  /**
+   * Set the current editor.
+   * @param {AnnotationEditor} editor
+   */
+  setActiveEditor(editor) {
+    const currentActive = this._uiManager.getActive();
+    if (currentActive === editor) {
+      return;
+    }
+
+    this._uiManager.setActiveEditor(editor);
+  }
+
+  enableClick() {
+    this.div.addEventListener("pointerdown", this._boundPointerdown);
+    this.div.addEventListener("pointerup", this._boundPointerup);
+  }
+
+  disableClick() {
+    this.div.removeEventListener("pointerdown", this._boundPointerdown);
+    this.div.removeEventListener("pointerup", this._boundPointerup);
+  }
+
+  attach(editor) {
+    this._editors.set(editor.id, editor);
+  }
+
+  detach(editor) {
+    this._editors.delete(editor.id);
+  }
+
+  /**
+   * Remove an editor.
+   * @param {AnnotationEditor} editor
+   */
+  remove(editor) {
+    // Since we can undo a removal we need to keep the
+    // parent property as it is, so don't null it!
+
+    this._uiManager.removeEditor(editor);
+    this.detach(editor);
+    this.annotationStorage.remove(editor.id);
+    editor.div.style.display = "none";
+    setTimeout(() => {
+      // When the div is removed from DOM the focus can move on the
+      // document.body, so we just slightly postpone the removal in
+      // order to let an element potentially grab the focus before
+      // the body.
+      editor.div.style.display = "";
+      editor.div.remove();
+      editor.isAttachedToDOM = false;
+      if (document.activeElement === document.body) {
+        this._uiManager.focusMainContainer();
+      }
+    }, 0);
+
+    if (!this._isCleaningUp) {
+      this.addInkEditorIfNeeded(/* isCommitting = */ false);
+    }
+  }
+
+  /**
+   * An editor can have a different parent, for example after having
+   * being dragged and droped from a page to another.
+   * @param {AnnotationEditor} editor
+   */
+  _changeParent(editor) {
+    if (editor.parent === this) {
+      return;
+    }
+
+    this.attach(editor);
+    editor.pageIndex = this.pageIndex;
+    editor.parent?.detach(editor);
+    editor.parent = this;
+    if (editor.div && editor.isAttachedToDOM) {
+      editor.div.remove();
+      this.div.append(editor.div);
+    }
+  }
+
+  /**
+   * Add a new editor in the current view.
+   * @param {AnnotationEditor} editor
+   */
+  add(editor) {
+    this._changeParent(editor);
+    this._uiManager.addEditor(editor);
+    this.attach(editor);
+
+    if (!editor.isAttachedToDOM) {
+      const div = editor.render();
+      this.div.append(div);
+      editor.isAttachedToDOM = true;
+    }
+
+    editor.onceAdded();
+    this.addToAnnotationStorage(editor);
+  }
+
+  /**
+   * Add an editor in the annotation storage.
+   * @param {AnnotationEditor} editor
+   */
+  addToAnnotationStorage(editor) {
+    if (!editor.isEmpty() && !this.annotationStorage.has(editor.id)) {
+      this.annotationStorage.setValue(editor.id, editor);
+    }
+  }
+
+  updateAnnotationStorage(key) {
+    
+  }
+
+  /**
+   * Add or rebuild depending if it has been removed or not.
+   * @param {AnnotationEditor} editor
+   */
+  addOrRebuild(editor) {
+    if (editor.needsToBeRebuilt()) {
+      editor.rebuild();
+    } else {
+      this.add(editor);
+    }
+  }
+
+  /**
+   * Add a new editor and make this addition undoable.
+   * @param {AnnotationEditor} editor
+   */
+  addUndoableEditor(editor) {
+    this.addOrRebuild(editor);
+  }
+
+  /**
+   * Get an id for an editor.
+   * @returns {string}
+   */
+  getNextId() {
+    return this._uiManager.getId();
+  }
+
+  /**
+   * Create a new editor
+   * @param {Object} params
+   * @returns {AnnotationEditor}
+   */
+  _createNewEditor(params) {
+    switch (this._uiManager.getMode()) {
+      case AnnotationEditorType.FREETEXT:
+        return new PDFAnnotationSticky(params);
+      case AnnotationEditorType.INK:
+        return new InkEditor(params);
+    }
+    return null;
+  }
+
+  /**
+   * Create a new editor
+   * @param {Object} data
+   * @returns {AnnotationEditor}
+   */
+  deserialize(data) {
+    switch (data.annotationType) {
+      case AnnotationEditorType.FREETEXT:
+        return PDFAnnotationSticky.deserialize(data, this);
+      case AnnotationEditorType.INK:
+        return InkEditor.deserialize(data, this);
+    }
+    return null;
+  }
+
+  /**
+   * Create and add a new editor.
+   * @param {PointerEvent} event
+   * @returns {AnnotationEditor}
+   */
+  _createAndAddNewEditor(event) {
+    const id = this.getNextId();
+    const editor = this._createNewEditor({
+      parent: this,
+      id,
+      x: event.offsetX,
+      y: event.offsetY,
+    });
+    if (editor) {
+      this.add(editor);
+    }
+
+    return editor;
+  }
+
+  /**
+   * Set the last selected editor.
+   * @param {AnnotationEditor} editor
+   */
+  setSelected(editor) {
+    this._uiManager.setSelected(editor);
+  }
+
+  /**
+   * Add or remove an editor the current selection.
+   * @param {AnnotationEditor} editor
+   */
+  toggleSelected(editor) {
+    this._uiManager.toggleSelected(editor);
+  }
+
+  /**
+   * Check if the editor is selected.
+   * @param {AnnotationEditor} editor
+   */
+  isSelected(editor) {
+    return this._uiManager.isSelected(editor);
+  }
+
+  /**
+   * Unselect an editor.
+   * @param {AnnotationEditor} editor
+   */
+  unselect(editor) {
+    this._uiManager.unselect(editor);
+  }
+
+  /**
+   * Pointerup callback.
+   * @param {PointerEvent} event
+   */
+  pointerup(event) {
+    const isMac = KeyboardManager.platform.isMac;
+    if (event.button !== 0 || (event.ctrlKey && isMac)) {
+      // Don't create an editor on right click.
+      return;
+    }
+
+    if (event.target !== this.div) {
+      return;
+    }
+
+    if (!this._hadPointerDown) {
+      // It can happen when the user starts a drag inside a text editor
+      // and then releases the mouse button outside of it. In such a case
+      // we don't want to create a new editor, hence we check that a pointerdown
+      // occured on this div previously.
+      return;
+    }
+    this._hadPointerDown = false;
+
+    if (!this._allowClick) {
+      this._allowClick = true;
+      return;
+    }
+
+    this._createAndAddNewEditor(event);
+  }
+
+  /**
+   * Pointerdown callback.
+   * @param {PointerEvent} event
+   */
+  pointerdown(event) {
+    const isMac = KeyboardManager.platform.isMac;
+    if (event.button !== 0 || (event.ctrlKey && isMac)) {
+      // Do nothing on right click.
+      return;
+    }
+
+    if (event.target !== this.div) {
+      return;
+    }
+
+    this._hadPointerDown = true;
+
+    const editor = this._uiManager.getActive();
+    this._allowClick = !editor || editor.isEmpty();
+  }
+
+  /**
+   * Drag callback.
+   * @param {DragEvent} event
+   */
+  drop(event) {
+    const id = event.dataTransfer.getData("text/plain");
+    const editor = this._uiManager.getEditor(id);
+    if (!editor) {
+      return;
+    }
+
+    event.preventDefault();
+    event.dataTransfer.dropEffect = "move";
+
+    this._changeParent(editor);
+
+    const rect = this.div.getBoundingClientRect();
+    const endX = event.clientX - rect.x;
+    const endY = event.clientY - rect.y;
+
+    editor.translate(endX - editor.startX, endY - editor.startY);
+    editor.div.focus();
+  }
+
+  /**
+   * Dragover callback.
+   * @param {DragEvent} event
+   */
+  dragover(event) {
+    event.preventDefault();
+  }
+
+  _cleanup() {
+    // When we're cleaning up, some editors are removed but we don't want
+    // to add a new one which will induce an addition in this._editors, hence
+    // an infinite loop.
+    this._isCleaningUp = true;
+    for (const editor of this._editors.values()) {
+      if (editor.isEmpty()) {
+        editor.remove();
+      }
+    }
+    this._isCleaningUp = false;
+  }
+
+  _removeAll() {
+    // When we're cleaning up, some editors are removed but we don't want
+    // to add a new one which will induce an addition in this._editors, hence
+    // an infinite loop.
+    this._isCleaningUp = true;
+    for (const editor of this._editors.values()) {
+      editor.remove();
+    }
+    this._isCleaningUp = false;
+  }
+
+  /**
+   * Render the main editor.
+   * @param {Object} parameters
+   */
+  render(viewport) {
+    if (this._cancelled) {
+      return;
+    }
+
+    const clonedViewport = viewport.clone({ dontFlip: true });
+    if (this.div) {
+      this.update({ viewport: clonedViewport });
+      this.show();
+      return;
+    }
+
+    // Create an AnnotationEditor layer div
+    this.div = document.createElement("div");
+    this.div.className = "annotationLayer";
+    this.div.tabIndex = 0;
+    this.pageDiv.append(this.div);
+
+    this._uiManager.addLayer(this);
+
+    this.viewport = clonedViewport
+    bindEvents(this, this.pageDiv, ["dragover", "drop"]);
+
+    this.setDimensions();
+    for (const editor of this._uiManager.getEditors(this.pageIndex)) {
+      this.add(editor);
+    }
+    this.updateMode();
+  }
+
+  cancel() {
+    this._cancelled = true;
+    this.destroy();
+  }
+
+  hide() {
+    if (!this.div) {
+      return;
+    }
+    this.div.hidden = true;
+  }
+
+  show() {
+    if (!this.div) {
+      return;
+    }
+    this.div.hidden = false;
+  }
+
+  /**
+   * Destroy the main editor.
+   */
+  destroy() {
+    if (!this.div) {
+      return;
+    }
+    this.pageDiv = null;
+    if (this._uiManager.getActive()?.parent === this) {
+      this._uiManager.setActiveEditor(null);
+    }
+
+    for (const editor of this._editors.values()) {
+      editor.isAttachedToDOM = false;
+      editor.div.remove();
+      editor.parent = null;
+    }
+    this.div.remove();
+    this.div = null;
+    this._editors.clear();
+    this._uiManager.removeLayer(this);
+  }
+
+  /**
+   * Remove an editor.
+   * @param {AnnotationEditor} editor
+   */
+   removeAllAnnotations() {
+    // Since we can undo a removal we need to keep the
+    // parent property as it is, so don't null it!
+    
+    if (this._uiManager.getActive()?.parent === this) {
+      this._uiManager.setActiveEditor(null);
+    }
+    for (const editor of this._editors.values()) {
+      this.remove(editor)
+    }
+  }
+
+
+  /**
+   * Update the main editor.
+   * @param {Object} parameters
+   */
+  update(parameters) {
+    // Editors have their dimensions/positions in percent so to avoid any
+    // issues (see #15582), we must commit the current one before changing
+    // the viewport.
+    this._uiManager.commitOrRemove();
+
+    this.viewport = parameters.viewport;
+    this.setDimensions();
+    this.updateMode();
+  }
+
+  /**
+   * Get the scale factor from the viewport.
+   * @returns {number}
+   */
+  get scaleFactor() {
+    return this.viewport.scale;
+  }
+
+  /**
+   * Get page dimensions.
+   * @returns {Object} dimensions.
+   */
+  get pageDimensions() {
+    const [pageLLx, pageLLy, pageURx, pageURy] = this.viewport.viewBox;
+    const width = pageURx - pageLLx;
+    const height = pageURy - pageLLy;
+
+    return [width, height];
+  }
+
+  get viewportBaseDimensions() {
+    const { width, height, rotation } = this.viewport;
+    return rotation % 180 === 0 ? [width, height] : [height, width];
+  }
+
+  /**
+   * Set the dimensions of the main div.
+   */
+  setDimensions() {
+    const { width, height, rotation } = this.viewport;
+
+    const flipOrientation = rotation % 180 !== 0,
+      widthStr = Math.floor(width) + "px",
+      heightStr = Math.floor(height) + "px";
+
+    this.div.style.width = flipOrientation ? heightStr : widthStr;
+    this.div.style.height = flipOrientation ? widthStr : heightStr;
+  }
+}
+
+export { PDFAnnotationLayer };

+ 454 - 0
packages/core/src/pdf_annotation_sticky.js

@@ -0,0 +1,454 @@
+import dayjs from 'dayjs'
+import { v4 as uuidv4 } from 'uuid';
+import { KeyboardManager } from "./tools.js";
+import { bindEvents } from './ui_utils.js'
+import { AnnotationEditorType, AnnotationEditorParamsType } from '../constants';
+import { PDFAnnotation } from "./pdf_annotation.js";
+
+class PDFAnnotationSticky extends PDFAnnotation {
+
+  static _defaultColor = "#000";
+
+  static _defaultFontSize = 10;
+
+  static _defaultInternalPadding = 2;
+
+  static _keyboardManager = new KeyboardManager([
+    [
+      ["ctrl+Enter", "mac+meta+Enter", "Escape", "mac+Escape"],
+      PDFAnnotationSticky.prototype.commitOrRemove,
+    ],
+  ]);
+  constructor (options) {
+    super({ ...options, name: "stickyAnnotation" });
+    this._boundEditorDivBlur = this.editorDivBlur.bind(this);
+
+    this._boundEditorDivFocus = this.editorDivFocus.bind(this);
+  
+    this._boundEditorDivInput = this.editorDivInput.bind(this);
+  
+    this._boundEditorDivKeydown = this.editorDivKeydown.bind(this);
+  
+    this._content = "";
+  
+    this._editorDivId = `${this.id}-editor`;
+  
+    this._hasAlreadyBeenCommitted = false;
+  
+    this.annotationObj = null
+
+    this._color = options.color || PDFAnnotationSticky._defaultColor;
+    this._fontSize = options.fontSize || PDFAnnotationSticky._defaultFontSize;
+    this._internalPadding = options.internalPadding || PDFAnnotationSticky._defaultInternalPadding;
+    this._stickyWidth = 20;
+  }
+
+  static updateDefaultParams(type, value) {
+    switch (type) {
+      case AnnotationEditorParamsType.FREETEXT_SIZE:
+        PDFAnnotationSticky._defaultFontSize = value;
+        break;
+      case AnnotationEditorParamsType.FREETEXT_COLOR:
+        PDFAnnotationSticky._defaultColor = value;
+        break;
+    }
+  }
+
+  /** @inheritdoc */
+  updateParams(type, value) {
+    switch (type) {
+      case AnnotationEditorParamsType.FREETEXT_SIZE:
+        this._updateFontSize(value);
+        break;
+      case AnnotationEditorParamsType.FREETEXT_COLOR:
+        this._updateColor(value);
+        break;
+    }
+  }
+
+  static get defaultPropertiesToUpdate() {
+    return [
+      [
+        AnnotationEditorParamsType.FREETEXT_SIZE,
+        PDFAnnotationSticky._defaultFontSize,
+      ],
+      [
+        AnnotationEditorParamsType.FREETEXT_COLOR,
+        PDFAnnotationSticky._defaultColor || PDFAnnotationSticky._defaultLineColor,
+      ],
+    ];
+  }
+
+  get propertiesToUpdate() {
+    return [
+      [AnnotationEditorParamsType.FREETEXT_SIZE, this._fontSize],
+      [AnnotationEditorParamsType.FREETEXT_COLOR, this._color],
+    ];
+  }
+
+  /**
+   * Update the font size and make this action as undoable.
+   * @param {number} fontSize
+   */
+  _updateFontSize(fontSize) {
+    this.editorDiv.style.fontSize = `${fontSize}px`;
+    this.translate(0, -(fontSize - this._fontSize) * this.parent.scaleFactor);
+    this._fontSize = fontSize;
+    this._setEditorDimensions();
+  }
+
+  /**
+   * Update the color and make this action undoable.
+   * @param {string} color
+   */
+  _updateColor(color) {
+    this._color = color;
+    this.editorDiv.style.color = color;
+  }
+
+  /** @inheritdoc */
+  getInitialTranslation() {
+    // The start of the base line is where the user clicked.
+    return [
+      -this._internalPadding * this.parent.scaleFactor,
+      -(this._internalPadding + this._fontSize) *
+        this.parent.scaleFactor,
+    ];
+  }
+
+  /** @inheritdoc */
+  rebuild() {
+    super.rebuild();
+    if (this.div === null) {
+      return;
+    }
+
+    if (!this.isAttachedToDOM) {
+      // At some point this editor was removed and we're rebuilting it,
+      // hence we must add it to its parent.
+      this.parent.add(this);
+    }
+  }
+
+  /** @inheritdoc */
+  enableEditMode() {
+    if (this.isInEditMode()) {
+      return;
+    }
+
+    this.parent.setEditingState(false);
+    super.enableEditMode();
+    this.overlayDiv.classList.remove("enabled");
+    this.editorDiv.style.display = 'initial';
+    // this.editorDiv.contentEditable = true;
+    this.div.draggable = false;
+    this.div.removeAttribute("aria-activedescendant");
+    // this.editorDiv.addEventListener("keydown", this._boundEditorDivKeydown);
+    this.editorDiv.addEventListener("focus", this._boundEditorDivFocus);
+    this.editorDiv.addEventListener("blur", this._boundEditorDivBlur);
+    this.editorDiv.addEventListener("input", this._boundEditorDivInput);
+  }
+
+  /** @inheritdoc */
+  disableEditMode() {
+    if (!this.isInEditMode()) {
+      return;
+    }
+
+    this.parent.setEditingState(true);
+    super.disableEditMode();
+    this.overlayDiv.classList.add("enabled");
+    this.editorDiv.contentEditable = false;
+    this.div.setAttribute("aria-activedescendant", this._editorDivId);
+    this.editorDiv.style.display = "none"
+    this.div.draggable = true;
+    this.editorDiv.removeEventListener("keydown", this._boundEditorDivKeydown);
+    this.editorDiv.removeEventListener("focus", this._boundEditorDivFocus);
+    this.editorDiv.removeEventListener("blur", this._boundEditorDivBlur);
+    this.editorDiv.removeEventListener("input", this._boundEditorDivInput);
+
+    // On Chrome, the focus is given to <body> when contentEditable is set to
+    // false, hence we focus the div.
+    this.div.focus();
+
+    // In case the blur callback hasn't been called.
+    this.isEditing = false;
+    this.parent.div.classList.add("freeTextEditing");
+  }
+
+  /** @inheritdoc */
+  focusin(event) {
+    super.focusin(event);
+    if (event.target !== this.editorDiv) {
+      this.editorDiv.focus();
+    }
+  }
+
+  /** @inheritdoc */
+  onceAdded() {
+    if (this.width) {
+      // The editor was created in using ctrl+c.
+      return;
+    }
+    this.enableEditMode();
+    this.editorDiv.focus();
+  }
+
+  /** @inheritdoc */
+  isEmpty() {
+    return !this.editorDiv || this.editorDiv.innerText.trim() === "";
+  }
+
+  /** @inheritdoc */
+  remove() {
+    this.isEditing = false;
+    this.parent.setEditingState(true);
+    this.parent.div.classList.add("freeTextEditing");
+    super.remove();
+  }
+
+  /**
+   * Extract the text from this editor.
+   * @returns {string}
+   */
+  _extractText() {
+    const divs = this.editorDiv.getElementsByTagName("div");
+    if (divs.length === 0) {
+      return this.editorDiv.innerText;
+    }
+    const buffer = [];
+    if (this.editorDiv.firstChild?.nodeName === "#text") {
+      buffer.push(this.editorDiv.firstChild.data)
+    }
+    for (const div of divs) {
+      const first = div.firstChild;
+      if (first?.nodeName === "#text") {
+        buffer.push(first.data);
+      } else {
+        buffer.push("");
+      }
+    }
+    return buffer.join("\n");
+  }
+
+  _setEditorDimensions() {
+    const [parentWidth, parentHeight] = this.parent.viewportBaseDimensions;
+    const rect = this.div.getBoundingClientRect();
+
+    this.width = rect.width / parentWidth;
+    this.height = rect.height / parentHeight;
+  }
+
+  /**
+   * Commit the content we have in this editor.
+   * @returns {undefined}
+   */
+  commit() {
+    this._content = this._extractText().trimEnd();
+    const [width, height] = this.parent.viewportBaseDimensions;
+    const position = {
+      left: this.x * width,
+      bottom: this.y * height + this._stickyWidth,
+      right: this.x * width + this._stickyWidth,
+      top: this.y * height
+    }
+    this.annotationObj = {
+      id: uuidv4(),
+      obj_type: 'Text',
+      obj_attr: {
+        bdcolor: "#FF2600",
+        content: this._content || undefined,
+        date: dayjs().format('YYYY-MM-DD_HH:mm:ss'),
+        ftransparency: 1,
+        page: this.pageIndex,
+        position: this.parsePositionForBackend(position, this.viewport.height, this.parent.scale),
+        transparency: 1
+      }
+    }
+    super.commit();
+    if (!this._hasAlreadyBeenCommitted) {
+      // This editor has something and it's the first time
+      // it's commited so we can add it in the undo/redo stack.
+      this._hasAlreadyBeenCommitted = true;
+      this.parent.addUndoableEditor(this);
+    }
+
+    this.disableEditMode();
+
+    this._setEditorDimensions();
+  }
+
+  parsePositionForBackend (position, pageHeight, scale) {
+    return {
+      left: Math.round(position.left * 1000 / scale) / 1000,
+      bottom: Math.round((pageHeight - position.bottom) * 1000 / scale) / 1000,
+      right: Math.round(position.right * 1000 / scale) / 1000,
+      top: Math.round((pageHeight - position.top) * 1000 / scale) / 1000,
+    };
+  }
+
+  /** @inheritdoc */
+  shouldGetKeyboardEvents() {
+    return this.isInEditMode();
+  }
+
+  /**
+   * ondblclick callback.
+   * @param {MouseEvent} event
+   */
+  dblclick() {
+    this.enableEditMode();
+    this.editorDiv.focus();
+  }
+
+  /**
+   * onkeydown callback.
+   * @param {KeyboardEvent} event
+   */
+  keydown(event) {
+    if (event.target === this.div && event.key === "Enter") {
+      this.enableEditMode();
+      this.editorDiv.focus();
+    }
+  }
+
+  editorDivKeydown(event) {
+    PDFAnnotationSticky._keyboardManager.exec(this, event);
+  }
+
+  editorDivFocus() {
+    this.isEditing = true;
+  }
+
+  editorDivBlur() {
+    this.isEditing = false;
+  }
+
+  editorDivInput(event) {
+    this.parent.div.classList.toggle("freeTextEditing", this.isEmpty());
+  }
+
+  /** @inheritdoc */
+  disableEditing() {
+    this.editorDiv.setAttribute("role", "comment");
+    this.editorDiv.removeAttribute("aria-multiline");
+  }
+
+  /** @inheritdoc */
+  enableEditing() {
+    this.editorDiv.setAttribute("role", "textbox");
+    this.editorDiv.setAttribute("aria-multiline", true);
+  }
+
+  /** @inheritdoc */
+  render() {
+    if (this.div) {
+      return this.div;
+    }
+
+    let baseX, baseY;
+    if (this.width) {
+      baseX = this.x;
+      baseY = this.y;
+    }
+    super.render();
+    this.iconDiv = document.createElement("div");
+    this.iconDiv.classList = "icon-wrapper";
+    this.iconDiv.innerHTML= `<svg data-status="normal" width="32" height="32" viewBox="0 0 32 32"><g fill="none" fill-rule="evenodd"><path d="M0 0h32v32H0z"></path><path d="M28 5.333V24H14.667L8 28v-4H4V5.333h24zM18.667 18H9.333v1.333h9.334V18zm4-4H9.333v1.333h13.334V14zm0-4H9.333v1.333h13.334V10z" fill="#BE0C0E"></path></g></svg>`;
+    this.div.append(this.iconDiv);
+
+    this.editorDiv = document.createElement("div");
+    this.editorDiv.className = "internal editor-container";
+
+    this.editorDiv.setAttribute("id", this._editorDivId);
+    this.enableEditing();
+
+    this.editorDiv.contentEditable = true;
+
+    const { style } = this.editorDiv;
+    style.fontSize = `${this._fontSize}px`;
+    style.color = this._color;
+
+    style.top = 36 + 'px';
+    style.left = "50%";
+
+    this.div.append(this.editorDiv);
+
+    this.overlayDiv = document.createElement("div");
+    this.overlayDiv.classList.add("overlay", "enabled");
+    this.div.append(this.overlayDiv);
+
+    // TODO: implement paste callback.
+    // The goal is to sanitize and have something suitable for this
+    // editor.
+    bindEvents(this, this.div, ["dblclick", "keydown"]);
+
+    if (this.width) {
+      // This editor was created in using copy (ctrl+c).
+      this.setAt(
+        baseX,
+        baseY,
+        this.width,
+        this.height
+      );
+
+      for (const line of this._content.split("\n")) {
+        const div = document.createElement("div");
+        div.append(
+          line ? document.createTextNode(line) : document.createElement("br")
+        );
+        this.editorDiv.append(div);
+      }
+
+      this.div.draggable = true;
+      this.editorDiv.contentEditable = false;
+    } else {
+      this.div.draggable = false;
+      this.editorDiv.contentEditable = true;
+    }
+
+    return this.div;
+  }
+
+  get contentDiv() {
+    return this.editorDiv;
+  }
+
+  /** @inheritdoc */
+  static deserialize(data, parent) {
+    let editor;
+    if (data.type === 'custom') {
+      editor = super.renderImportAnnotations(data, parent);
+    } else {
+      editor = super.deserialize(data, parent);
+    } 
+
+    editor._fontSize = data.fontSize;
+    editor._color = this._color;
+    editor._content = data.value;
+
+    return editor;
+  }
+
+  /** @inheritdoc */
+  serialize() {
+    if (this.isEmpty()) {
+      return null;
+    }
+
+    const padding = this._internalPadding * this.parent.scaleFactor;
+    const rect = this.getRect(padding, padding);
+
+    return {
+      annotationType: AnnotationEditorType.FREETEXT,
+      color: this._color,
+      fontSize: this._fontSize,
+      value: this._content,
+      pageIndex: this.parent.pageIndex,
+      rect,
+      rotation: this.rotation,
+    };
+  }
+}
+
+export { PDFAnnotationSticky };

+ 179 - 0
packages/core/src/pdf_annotation_storage.js

@@ -0,0 +1,179 @@
+import { objectFromMap } from "./ui_utils.js";
+
+class PDFAnnotationStorage {
+  constructor(annotationView, numPages, eventBus) {
+    this._storage = new Map();
+    this._modified = false;
+    this.annotationPages = Array(numPages)
+    this.annotationView = annotationView
+    this.numPages = numPages
+    this._eventBus = eventBus
+  }
+
+  /**
+   * 获取指定注释key的值或返回默认值
+   *
+   * @public
+   * @memberof PDFAnnotationStorage
+   * @param {string} key
+   * @param {Object} defaultValue
+   * @returns {Object}
+   */
+  getValue(key, defaultValue) {
+    const value = this._storage.get(key);
+    if (value === undefined) {
+      return defaultValue;
+    }
+
+    return Object.assign(defaultValue, value);
+  }
+  /**
+   * 获取指定注释key的值
+   *
+   * @public
+   * @memberof PDFAnnotationStorage
+   * @param {string} key
+   * @returns {Object}
+   */
+  getRawValue(key) {
+    return this._storage.get(key);
+  }
+  /**
+   * 从storage中删除指定的key值
+   * @param {string} key
+   */
+  remove(key) {
+    const obj = this.getRawValue(key);
+    if (!obj) return
+    if (this.annotationPages[obj.pageIndex]) {
+      const boxObj = this.annotationPages[obj.pageIndex].annotations.get(key);
+      this.annotationPages[obj.pageIndex].annotations.delete(key);
+      boxObj?.annotationBox.remove();
+      if (this.annotationPages[obj.pageIndex].annotations.size === 0) {
+        this.annotationPages[obj.pageIndex].annotationPage.remove();
+        this.annotationPages[obj.pageIndex] = undefined
+      }
+    }
+    this._storage.delete(key);
+    this._eventBus.dispatch("annotationsCountChanged", {
+      annotationsCount: this.size
+    })
+    if (this._storage.size === 0) {
+      this.resetModified();
+    }
+  }
+
+  cleanup() {
+    const editor = this._storage.values().next().value
+    if (editor) {
+      editor.parent._uiManager.removeAllAnnotations()
+    }
+  }
+
+  /**
+   * 设置指定的key值
+   *
+   * @public
+   * @memberof PDFAnnotationStorage
+   * @param {string} key
+   * @param {Object} value
+   */
+  setValue(key, value) {
+    const obj = this._storage.get(key);
+    let modified = false;
+    if (obj !== undefined) {
+      obj.add(value)
+    } else {
+      modified = true;
+      this._storage.set(key, value);
+      if (value.name === 'stickyAnnotation') {
+        this._eventBus.dispatch("annotationsCountChanged", {
+          annotationsCount: this.size
+        })
+      }
+    }
+    if (modified) {
+      this.setModified();
+      if (value.name !== "inkEditor") {
+        if (!this.annotationPages[value.pageIndex]) {
+          this.annotationPages[value.pageIndex] = {}
+          this.annotationPages[value.pageIndex].annotations = new Map()
+          const pageNumber = document.createElement("div");
+          pageNumber.className = "pageNumber";
+          pageNumber.innerText = `Page ${value.pageIndex + 1}`;
+          const annotationPage = document.createElement("div");
+          annotationPage.className = "annotationPage";
+          this.annotationPages[value.pageIndex].annotationPage = annotationPage;
+          annotationPage.append(pageNumber)
+          this.annotationView.append(annotationPage);
+        }
+        const annotationDetail = document.createElement('div');
+        annotationDetail.className = "annotationBox"
+        annotationDetail.innerHTML = `
+          <div class="annotationGroup">
+            <div class="annotationContent">
+              <div color="#FF2600" class="iconWrapper" style="margin-right: 8px;">
+                <svg width="24" height="24" viewBox="0 0 24 24"><g fill="none" fill-rule="evenodd"><path d="M0 0h24v24H0z"></path><path d="M21.6 3.6V18H12l-6 3.6V18H2.4V3.6h19.2zM18 11.4H6v1.8h12v-1.8zm-4.8-4.2H6V9h7.2V7.2z" fill="#273C62"></path></g></svg>
+              </div>
+              <div class="contentBox">${value._content}</div>
+            </div>
+            <span>${value.annotationObj.obj_attr.date.replace('_', ' ')}</span>
+          </div>
+          <div title="Delete" class="iconWrapper delete" style="width: 16px; height: 16px; position: absolute; top: 4px; right: 4px;">
+            <svg width="16" height="16" viewBox="0 0 16 16"><g fill="none" fill-rule="evenodd"><path d="M0 0h16v16H0z"></path><path d="M13.303 3.404L8.707 8l4.596 4.596-.707.707L8 8.706l-4.595 4.597-.707-.707L7.292 8 2.697 3.404l.707-.707L8 7.293l4.596-4.596.707.707z" fill="#757780"></path></g></svg>
+          </div>`;
+        value.annotationBox = annotationDetail;
+        this.annotationPages[value.pageIndex].annotations.set(key, value);
+        const deleteButton = annotationDetail.querySelector('.delete');
+        deleteButton.addEventListener('click', value.remove.bind(value))
+        this.annotationPages[value.pageIndex].annotationPage.append(annotationDetail)
+      }
+    }
+  }
+  /**
+   * 判断storage中是否存在指定key值
+   * @param {string} key
+   * @returns {boolean}
+   */
+  has(key) {
+    return this._storage.has(key);
+  }
+
+  getAll() {
+    return this._storage.size > 0 ? objectFromMap(this._storage) : null;
+  }
+
+  get size() {
+    return this._storage.size;
+  }
+
+  setModified() {
+    if (!this._modified) {
+      this._modified = true;
+    }
+  }
+
+  resetModified() {
+    if (this._modified) {
+      this._modified = false;
+    }
+  }
+
+  /**
+   * PLEASE NOTE: Only intended for usage within the API itself.
+   * @ignore
+   */
+  static getHash(map) {
+    if (!map) {
+      return "";
+    }
+    const hash = new MurmurHash3_64();
+
+    for (const [key, val] of map) {
+      hash.update(`${key}:${JSON.stringify(val)}`);
+    }
+    return hash.hexdigest();
+  }
+}
+
+export { PDFAnnotationStorage };

File diff suppressed because it is too large
+ 1150 - 0
packages/core/src/pdf_measure_editor.js


+ 364 - 0
packages/core/src/pdf_measure_layer.js

@@ -0,0 +1,364 @@
+import { v4 as uuidv4 } from 'uuid';
+import { bindEvents } from "./ui_utils.js";
+import { PDFMeasureEditor } from "./pdf_measure_editor.js";
+
+class PDFMeasureLayer {
+
+  constructor(options) {
+    this.allowClick = false;
+    this.boundPointerup = this.pointerup.bind(this);
+    this.boundPointerdown = this.pointerdown.bind(this);
+    this.editors = new Map();
+    this.hadPointerDown = false;
+    this.isCleaningUp = false;
+    this.uiManager = options.uiManager
+    this.viewport = options.viewport
+
+    this.annotationStorage = options.annotationStorage;
+    this.pageIndex = options.pageIndex;
+    this.pageDiv = options.pageDiv;
+    this.div = null;
+    this.render({
+      annotations: options.annotations,
+      viewport: this.viewport,
+    })
+    this.uiManager.addLayer(this)
+  }
+
+  /**
+   * The mode has changed: it must be updated.
+   * @param {number} mode
+   */
+  updateMode() {
+    // this.cleanup();
+    this.enableClick();
+  }
+
+  /**
+   * Enable pointer events on the main div in order to enable
+   * editor creation.
+   */
+  enable() {
+    this.div.style.pointerEvents = "auto";
+    for (const editor of this.editors.values()) {
+      editor.enableEditing();
+    }
+  }
+
+  /**
+   * Disable editor creation.
+   */
+  disable() {
+    this.div.style.pointerEvents = "none";
+    for (const editor of this.editors.values()) {
+      editor.disableEditing();
+    }
+  }
+
+  /**
+   * Set the current editor.
+   * @param {AnnotationEditor} editor
+   */
+   setActiveEditor(editor) {
+    const currentActive = this.uiManager.getActive();
+    if (currentActive === editor) {
+      return;
+    }
+
+    this.uiManager.setActiveEditor(editor);
+  }
+
+  enableClick() {
+    this.canvas.addEventListener("pointerdown", this.boundPointerdown);
+    this.canvas.addEventListener("pointerup", this.boundPointerup);
+  }
+
+  disableClick() {
+    this.canvas.removeEventListener("pointerdown", this.boundPointerdown);
+    this.canvas.removeEventListener("pointerup", this.boundPointerup);
+  }
+
+  /**
+   * Remove an editor.
+   * @param {AnnotationEditor} editor
+   */
+  remove(editor) {
+    // Since we can undo a removal we need to keep the
+    // parent property as it is, so don't null it!
+
+    this.annotationStorage.remove(editor.id);
+    editor.div.style.display = "none";
+    setTimeout(() => {
+      // When the div is removed from DOM the focus can move on the
+      // document.body, so we just slightly postpone the removal in
+      // order to let an element potentially grab the focus before
+      // the body.
+      editor.div.style.display = "";
+      editor.div.remove();
+      editor.editorDiv.remove();
+      editor.isAttachedToDOM = false;
+    }, 0);
+  }
+  
+  /**
+   * Set the last selected editor.
+   * @param {AnnotationEditor} editor
+   */
+  setSelected(editor) {
+    this.uiManager.setSelected(editor);
+  }
+
+  /**
+   * Add a new editor in the current view.
+   * @param {AnnotationEditor} editor
+   */
+  add(editor) {
+    if (!editor.isAttachedToDOM) {
+      editor.render();
+      editor.isAttachedToDOM = true;
+    }
+
+    editor.onceAdded();
+    this.addToAnnotationStorage(editor);
+  }
+
+  /**
+   * Add an editor in the annotation storage.
+   * @param {AnnotationEditor} editor
+   */
+  addToAnnotationStorage(editor) {
+    if (!editor.isEmpty() && !this.annotationStorage.has(editor.id)) {
+      this.annotationStorage.setValue(editor.id, editor);
+    }
+  }
+
+  /**
+   * Get an id for an editor.
+   * @returns {string}
+   */
+  getUuid() {
+    return uuidv4();
+  }
+
+  /**
+   * Create a new editor
+   * @param {Object} params
+   * @returns {AnnotationEditor}
+   */
+  createNewEditor(params) {
+    return new PDFAnnotationEditor(params);
+  }
+
+  /**
+   * Create and add a new editor.
+   * @param {PointerEvent} event
+   * @returns {AnnotationEditor}
+   */
+  createAndAddNewEditor(event) {
+    const id = this.getUuid();
+    const editor = this.createNewEditor({
+      parent: this,
+      pageDiv: this.div,
+      id,
+      x: event.offsetX,
+      y: event.offsetY,
+    });
+    if (editor) {
+      this.add(editor);
+    }
+
+    return editor;
+  }
+
+  /**
+   * Pointerup callback.
+   * @param {PointerEvent} event
+   */
+  pointerup(event) {
+    if (event.target !== this.div) {
+      return;
+    }
+
+    if (!this.hadPointerDown) {
+      // It can happen when the user starts a drag inside a text editor
+      // and then releases the mouse button outside of it. In such a case
+      // we don't want to create a new editor, hence we check that a pointerdown
+      // occured on this div previously.
+      return;
+    }
+    this.hadPointerDown = false;
+
+    if (!this.allowClick) {
+      this.allowClick = true;
+      return;
+    }
+
+    this.createAndAddNewEditor(event);
+  }
+
+  /**
+   * Pointerdown callback.
+   * @param {PointerEvent} event
+   */
+  pointerdown(event) {
+    if (event.target !== this.div) {
+      return;
+    }
+
+    this.hadPointerDown = true;
+    this.allowClick = true
+    const editor = this.uiManager.getActive();
+    this.allowClick = !editor || editor.isEmpty();
+  }
+
+  /**
+   * Drag callback.
+   * @param {DragEvent} event
+   */
+  drop(event) {
+    const id = event.dataTransfer.getData("text/plain");
+    const editor = this.uiManager.getEditor(id);
+    if (!editor) {
+      return;
+    }
+
+    event.preventDefault();
+    event.dataTransfer.dropEffect = "move";
+
+    const rect = this.div.getBoundingClientRect();
+    const endX = event.clientX - rect.x;
+    const endY = event.clientY - rect.y;
+
+    editor.translate(endX - editor.startX, endY - editor.startY);
+    editor.div.focus();
+  }
+
+  /**
+   * Dragover callback.
+   * @param {DragEvent} event
+   */
+  dragover(event) {
+    event.preventDefault();
+  }
+
+  /**
+   * Destroy the main editor.
+   */
+  destroy() {
+    for (const editor of this.editors.values()) {
+      editor.isAttachedToDOM = false;
+      editor.div.remove();
+      editor.parent = null;
+    }
+    this.div = null;
+    this.editors.clear();
+    this.uiManager.removeLayer(this);
+  }
+
+  cleanup() {
+    // When we're cleaning up, some editors are removed but we don't want
+    // to add a new one which will induce an addition in this.editors, hence
+    // an infinite loop.
+    this.isCleaningUp = true;
+    for (const editor of this.editors.values()) {
+      if (editor.isEmpty()) {
+        editor.remove();
+      }
+    }
+    this.isCleaningUp = false;
+  }
+
+  /**
+   * Render the main editor.
+   * @param {Object} parameters
+   */
+  render(parameters) {
+    this.div = document.createElement("div");
+    this.div.className = "annotationLayer";
+    this.div.tabIndex = 0;
+    this.pageDiv.append(this.div);
+    this.viewport = parameters.viewport;
+    bindEvents(this, this.div, ["dragover", "drop"]);
+    this.setDimensions();
+    for (const editor of this.getEditors(this.pageIndex, parameters.annotations)) {
+      this.add(editor);
+    }
+    this.updateMode();
+  }
+
+  getEditors(pageIndex, annotations) {
+    const editors = [];
+    if (!annotations) {
+      return [];
+    }
+    for (const annotation of annotations) {
+      if (annotation.obj_attr.page === pageIndex) {
+        const editor = this.createNewEditor({
+          parent: this,
+          id: annotation.id,
+          pageDiv: this.div,
+          annotationObj: annotation
+        })
+        editors.push(editor);
+      }
+    }
+    return editors;
+  }
+
+  /**
+   * Update the main editor.
+   * @param {Object} parameters
+   */
+  update(parameters) {
+    // Editors have their dimensions/positions in percent so to avoid any
+    // issues (see 15582), we must commit the current one before changing
+    // the viewport.
+    this.uiManager.commitOrRemove();
+
+    this.viewport = parameters.viewport;
+    this.setDimensions();
+    this.updateMode();
+  }
+
+  /**
+   * Get the scale factor from the viewport.
+   * @returns {number}
+   */
+  get scaleFactor() {
+    return this.viewport.scale;
+  }
+
+  /**
+   * Get page dimensions.
+   * @returns {Object} dimensions.
+   */
+  get pageDimensions() {
+    const [pageLLx, pageLLy, pageURx, pageURy] = this.viewport.viewBox;
+    const width = pageURx - pageLLx;
+    const height = pageURy - pageLLy;
+
+    return [width, height];
+  }
+
+  get viewportBaseDimensions() {
+    const { width, height, rotation } = this.viewport;
+    return rotation % 180 === 0 ? [width, height] : [height, width];
+  }
+
+  /**
+   * Set the dimensions of the main div.
+   */
+  setDimensions() {
+    const { width, height, rotation } = this.viewport;
+
+    const flipOrientation = rotation % 180 !== 0,
+      widthStr = Math.floor(width) + "px",
+      heightStr = Math.floor(height) + "px";
+
+    this.div.style.width = flipOrientation ? heightStr : widthStr;
+    this.div.style.height = flipOrientation ? widthStr : heightStr;
+    this.div.setAttribute("data-main-rotation", rotation);
+  }
+}
+
+export { PDFMeasureLayer };

+ 129 - 1
packages/core/src/pdf_viewer.js

@@ -1,4 +1,7 @@
 import {
+  AnnotationEditorType,
+  AnnotationEditorUIManager,
+  AnnotationMode,
   createPromiseCapability,
   PermissionFlag,
 } from "pdfjs-dist/legacy/build/pdf";
@@ -47,6 +50,13 @@ const PagesCountLimit = {
   PAUSE_EAGER_PAGE_INIT: 250,
 };
 
+function isValidAnnotationEditorMode(mode) {
+  return (
+    Object.values(AnnotationEditorType).includes(mode) &&
+    mode !== AnnotationEditorType.DISABLE
+  );
+}
+
 class PDFPageViewBuffer {
   // Here we rely on the fact that `Set`s preserve the insertion order.
   #buf = new Set();
@@ -118,6 +128,8 @@ class PDFPageViewBuffer {
 class PDFViewer {
   #buffer = null;
 
+  #annotationMode = AnnotationMode.ENABLE_FORMS;
+
   #containerTopLeft = null;
 
   #enablePermissions = false;
@@ -137,6 +149,7 @@ class PDFViewer {
    */
   constructor(options) {
     this.eventBus = options.eventBus;
+    this._annotationEditorUIManager = null;
     this.container = options.container;
     this.viewer = options.viewer || options.container.firstElementChild;
 
@@ -158,6 +171,10 @@ class PDFViewer {
     this.removePageBorders = false;
     this._scriptingManager = options.scriptingManager || null;
     this.textLayerMode = options.textLayerMode ?? TextLayerMode.ENABLE;
+    this.#annotationMode =
+      options.annotationMode ?? AnnotationMode.ENABLE_FORMS;
+    this._annotationEditorMode =
+      options.annotationEditorMode ?? AnnotationEditorType.NONE;
     this.imageResourcesPath = options.imageResourcesPath || "";
     this.enablePrintAutoRotate = options.enablePrintAutoRotate || false;
     this.renderer = options.renderer || RendererType.CANVAS;
@@ -226,6 +243,13 @@ class PDFViewer {
     });
   }
 
+  /**
+   * @type {boolean}
+   */
+  get renderForms() {
+    return this.#annotationMode === AnnotationMode.ENABLE_FORMS;
+  }
+
   /**
    * @type {boolean}
    */
@@ -417,6 +441,12 @@ class PDFViewer {
   #layerProperties() {
     const self = this;
     return {
+      get annotationEditorUIManager() {
+        return self._annotationEditorUIManager;
+      },
+      get annotationStorage() {
+        return self.pdfDocument?.annotationStorage;
+      },
       get downloadManager() {
         return self.downloadManager;
       },
@@ -444,6 +474,8 @@ class PDFViewer {
    */
   #initializePermissions(permissions) {
     const params = {
+      annotationEditorMode: this._annotationEditorMode,
+      annotationMode: this.#annotationMode,
       textLayerMode: this.textLayerMode,
     };
     if (!permissions) {
@@ -454,6 +486,18 @@ class PDFViewer {
       this.viewer.classList.add(ENABLE_PERMISSIONS_CLASS);
     }
 
+    if (!permissions.includes(PermissionFlag.MODIFY_CONTENTS)) {
+      params.annotationEditorMode = AnnotationEditorType.DISABLE;
+    }
+
+    if (
+      !permissions.includes(PermissionFlag.MODIFY_ANNOTATIONS) &&
+      !permissions.includes(PermissionFlag.FILL_INTERACTIVE_FORMS) &&
+      this.#annotationMode === AnnotationMode.ENABLE_FORMS
+    ) {
+      params.annotationMode = AnnotationMode.ENABLE;
+    }
+
     return params;
   }
 
@@ -503,7 +547,7 @@ class PDFViewer {
   /**
    * @param {PDFDocumentProxy} pdfDocument
    */
-  setDocument(pdfDocument) {
+  setDocument(pdfDocument, annotations) {
     if (this.pdfDocument) {
       this.eventBus.dispatch("pagesdestroy", { source: this });
 
@@ -512,6 +556,11 @@ class PDFViewer {
 
       this.findController?.setDocument(null);
       this._scriptingManager?.setDocument(null);
+
+      if (this._annotationEditorUIManager) {
+        this._annotationEditorUIManager.destroy();
+        this._annotationEditorUIManager = null;
+      }
     }
 
     this.pdfDocument = pdfDocument;
@@ -585,6 +634,27 @@ class PDFViewer {
         this._firstPageCapability.resolve(firstPdfPage);
         this._optionalContentConfigPromise = optionalContentConfigPromise;
 
+        const { annotationEditorMode, annotationMode, textLayerMode } =
+          this.#initializePermissions(permissions);
+
+        if (annotationEditorMode !== AnnotationEditorType.DISABLE) {
+          const mode = annotationEditorMode;
+
+          if (pdfDocument.isPureXfa) {
+            console.warn("Warning: XFA-editing is not implemented.");
+          } else if (isValidAnnotationEditorMode(mode)) {
+            this._annotationEditorUIManager = new AnnotationEditorUIManager(
+              this.container,
+              this.eventBus,
+              pdfDocument?.annotationStorage
+            );
+            if (mode !== AnnotationEditorType.NONE) {
+              this._annotationEditorUIManager.updateMode(mode);
+            }
+          } else {
+            console.error(`Invalid AnnotationEditor mode: ${mode}`);
+          }
+        }
         const layerProperties = this.#layerProperties.bind(this);
 
         const viewerElement =
@@ -603,10 +673,12 @@ class PDFViewer {
             eventBus: this.eventBus,
             id: pageNum,
             scale,
+            annotations: annotations && annotations[pageNum - 1] || null,
             defaultViewport: viewport.clone(),
             optionalContentConfigPromise,
             renderingQueue: this.renderingQueue,
             textLayerMode,
+            annotationMode,
             imageResourcesPath: this.imageResourcesPath,
             renderer:
               typeof PDFJSDev === "undefined" ||
@@ -645,6 +717,14 @@ class PDFViewer {
           this.findController?.setDocument(pdfDocument); // Enable searching.
           this._scriptingManager?.setDocument(pdfDocument); // Enable scripting.
 
+          if (this._annotationEditorUIManager) {
+            // Ensure that the Editor buttons, in the toolbar, are updated.
+            this.eventBus.dispatch("annotationeditormodechanged", {
+              source: this,
+              mode: this._annotationEditorMode,
+            });
+          }
+
           // In addition to 'disableAutoFetch' being set, also attempt to reduce
           // resource usage when loading *very* long/large documents.
           if (
@@ -735,6 +815,11 @@ class PDFViewer {
     }
   }
 
+  renderAnnotation (annotation) {
+    if (!this._pages[annotation.page]) return
+    this._pages[annotation.page].compdfAnnotationLayer.renderAnnotation(annotation);
+  }
+
   _resetView() {
     this._pages = [];
     this._currentPageNumber = 1;
@@ -1920,6 +2005,49 @@ class PDFViewer {
     ]);
   }
 
+  /**
+   * @type {number}
+   */
+  get annotationEditorMode() {
+    return this._annotationEditorUIManager
+      ? this._annotationEditorMode
+      : AnnotationEditorType.DISABLE;
+  }
+
+  /**
+   * @param {number} mode - AnnotationEditor mode (None, FreeText, Ink, ...)
+   */
+  set annotationEditorMode(mode) {
+    if (!this._annotationEditorUIManager) {
+      throw new Error(`The AnnotationEditor is not enabled.`);
+    }
+    if (this._annotationEditorMode === mode) {
+      return; // The AnnotationEditor mode didn't change.
+    }
+    if (!isValidAnnotationEditorMode(mode)) {
+      throw new Error(`Invalid AnnotationEditor mode: ${mode}`);
+    }
+    if (!this.pdfDocument) {
+      return;
+    }
+    this._annotationEditorMode = mode;
+
+    this.eventBus.dispatch("annotationeditormodechanged", {
+      source: this,
+      mode,
+    });
+
+    this._annotationEditorUIManager.updateMode(mode);
+  }
+
+  // eslint-disable-next-line accessor-pairs
+  set annotationEditorParams({ type, value }) {
+    if (!this._annotationEditorUIManager) {
+      throw new Error(`The AnnotationEditor is not enabled.`);
+    }
+    this._annotationEditorUIManager.updateParams(type, value);
+  }
+
   refresh(noUpdate = false, updateArgs = Object.create(null)) {
     if (!this.pdfDocument) {
       return;

+ 300 - 0
packages/core/src/text_highlighter.js

@@ -0,0 +1,300 @@
+/* Copyright 2021 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("./event_utils").EventBus} EventBus */
+// eslint-disable-next-line max-len
+/** @typedef {import("./pdf_find_controller").PDFFindController} PDFFindController */
+
+/**
+ * @typedef {Object} TextHighlighterOptions
+ * @property {PDFFindController} findController
+ * @property {EventBus} eventBus - The application event bus.
+ * @property {number} pageIndex - The page index.
+ */
+
+/**
+ * TextHighlighter handles highlighting matches from the FindController in
+ * either the text layer or XFA layer depending on the type of document.
+ */
+class TextHighlighter {
+  /**
+   * @param {TextHighlighterOptions} options
+   */
+  constructor({ findController, eventBus, pageIndex }) {
+    this.findController = findController;
+    this.matches = [];
+    this.eventBus = eventBus;
+    this.pageIdx = pageIndex;
+    this._onUpdateTextLayerMatches = null;
+    this.textDivs = null;
+    this.textContentItemsStr = null;
+    this.enabled = false;
+  }
+
+  /**
+   * Store two arrays that will map DOM nodes to text they should contain.
+   * The arrays should be of equal length and the array element at each index
+   * should correspond to the other. e.g.
+   * `items[0] = "<span>Item 0</span>" and texts[0] = "Item 0";
+   *
+   * @param {Array<Node>} divs
+   * @param {Array<string>} texts
+   */
+  setTextMapping(divs, texts) {
+    this.textDivs = divs;
+    this.textContentItemsStr = texts;
+  }
+
+  /**
+   * Start listening for events to update the highlighter and check if there are
+   * any current matches that need be highlighted.
+   */
+  enable() {
+    if (!this.textDivs || !this.textContentItemsStr) {
+      throw new Error("Text divs and strings have not been set.");
+    }
+    if (this.enabled) {
+      throw new Error("TextHighlighter is already enabled.");
+    }
+    this.enabled = true;
+    if (!this._onUpdateTextLayerMatches) {
+      this._onUpdateTextLayerMatches = evt => {
+        if (evt.pageIndex === this.pageIdx || evt.pageIndex === -1) {
+          this._updateMatches();
+        }
+      };
+      this.eventBus._on(
+        "updatetextlayermatches",
+        this._onUpdateTextLayerMatches
+      );
+    }
+    this._updateMatches();
+  }
+
+  disable() {
+    if (!this.enabled) {
+      return;
+    }
+    this.enabled = false;
+    if (this._onUpdateTextLayerMatches) {
+      this.eventBus._off(
+        "updatetextlayermatches",
+        this._onUpdateTextLayerMatches
+      );
+      this._onUpdateTextLayerMatches = null;
+    }
+    this._updateMatches(/* reset = */ true);
+  }
+
+  _convertMatches(matches, matchesLength) {
+    // Early exit if there is nothing to convert.
+    if (!matches) {
+      return [];
+    }
+    const { textContentItemsStr } = this;
+
+    let i = 0,
+      iIndex = 0;
+    const end = textContentItemsStr.length - 1;
+    const result = [];
+
+    for (let m = 0, mm = matches.length; m < mm; m++) {
+      // Calculate the start position.
+      let matchIdx = matches[m];
+
+      // Loop over the divIdxs.
+      while (i !== end && matchIdx >= iIndex + textContentItemsStr[i].length) {
+        iIndex += textContentItemsStr[i].length;
+        i++;
+      }
+
+      if (i === textContentItemsStr.length) {
+        console.error("Could not find a matching mapping");
+      }
+
+      const match = {
+        begin: {
+          divIdx: i,
+          offset: matchIdx - iIndex,
+        },
+      };
+
+      // Calculate the end position.
+      matchIdx += matchesLength[m];
+
+      // Somewhat the same array as above, but use > instead of >= to get
+      // the end position right.
+      while (i !== end && matchIdx > iIndex + textContentItemsStr[i].length) {
+        iIndex += textContentItemsStr[i].length;
+        i++;
+      }
+
+      match.end = {
+        divIdx: i,
+        offset: matchIdx - iIndex,
+      };
+      result.push(match);
+    }
+    return result;
+  }
+
+  _renderMatches(matches) {
+    // Early exit if there is nothing to render.
+    if (matches.length === 0) {
+      return;
+    }
+    const { findController, pageIdx } = this;
+    const { textContentItemsStr, textDivs } = this;
+
+    const isSelectedPage = pageIdx === findController.selected.pageIdx;
+    const selectedMatchIdx = findController.selected.matchIdx;
+    const highlightAll = findController.state.highlightAll;
+    let prevEnd = null;
+    const infinity = {
+      divIdx: -1,
+      offset: undefined,
+    };
+
+    function beginText(begin, className) {
+      const divIdx = begin.divIdx;
+      textDivs[divIdx].textContent = "";
+      return appendTextToDiv(divIdx, 0, begin.offset, className);
+    }
+
+    function appendTextToDiv(divIdx, fromOffset, toOffset, className) {
+      let div = textDivs[divIdx];
+      if (div.nodeType === Node.TEXT_NODE) {
+        const span = document.createElement("span");
+        div.before(span);
+        span.append(div);
+        textDivs[divIdx] = span;
+        div = span;
+      }
+      const content = textContentItemsStr[divIdx].substring(
+        fromOffset,
+        toOffset
+      );
+      const node = document.createTextNode(content);
+      if (className) {
+        const span = document.createElement("span");
+        span.className = `${className} appended`;
+        span.append(node);
+        div.append(span);
+        return className.includes("selected") ? span.offsetLeft : 0;
+      }
+      div.append(node);
+      return 0;
+    }
+
+    let i0 = selectedMatchIdx,
+      i1 = i0 + 1;
+    if (highlightAll) {
+      i0 = 0;
+      i1 = matches.length;
+    } else if (!isSelectedPage) {
+      // Not highlighting all and this isn't the selected page, so do nothing.
+      return;
+    }
+
+    for (let i = i0; i < i1; i++) {
+      const match = matches[i];
+      const begin = match.begin;
+      const end = match.end;
+      const isSelected = isSelectedPage && i === selectedMatchIdx;
+      const highlightSuffix = isSelected ? " selected" : "";
+      let selectedLeft = 0;
+
+      // Match inside new div.
+      if (!prevEnd || begin.divIdx !== prevEnd.divIdx) {
+        // If there was a previous div, then add the text at the end.
+        if (prevEnd !== null) {
+          appendTextToDiv(prevEnd.divIdx, prevEnd.offset, infinity.offset);
+        }
+        // Clear the divs and set the content until the starting point.
+        beginText(begin);
+      } else {
+        appendTextToDiv(prevEnd.divIdx, prevEnd.offset, begin.offset);
+      }
+
+      if (begin.divIdx === end.divIdx) {
+        selectedLeft = appendTextToDiv(
+          begin.divIdx,
+          begin.offset,
+          end.offset,
+          "highlight" + highlightSuffix
+        );
+      } else {
+        selectedLeft = appendTextToDiv(
+          begin.divIdx,
+          begin.offset,
+          infinity.offset,
+          "highlight begin" + highlightSuffix
+        );
+        for (let n0 = begin.divIdx + 1, n1 = end.divIdx; n0 < n1; n0++) {
+          textDivs[n0].className = "highlight middle" + highlightSuffix;
+        }
+        beginText(end, "highlight end" + highlightSuffix);
+      }
+      prevEnd = end;
+
+      if (isSelected) {
+        // Attempt to scroll the selected match into view.
+        findController.scrollMatchIntoView({
+          element: textDivs[begin.divIdx],
+          selectedLeft,
+          pageIndex: pageIdx,
+          matchIndex: selectedMatchIdx,
+        });
+      }
+    }
+
+    if (prevEnd) {
+      appendTextToDiv(prevEnd.divIdx, prevEnd.offset, infinity.offset);
+    }
+  }
+
+  _updateMatches(reset = false) {
+    if (!this.enabled && !reset) {
+      return;
+    }
+    const { findController, matches, pageIdx } = this;
+    const { textContentItemsStr, textDivs } = this;
+    let clearedUntilDivIdx = -1;
+
+    // Clear all current matches.
+    for (const match of matches) {
+      const begin = Math.max(clearedUntilDivIdx, match.begin.divIdx);
+      for (let n = begin, end = match.end.divIdx; n <= end; n++) {
+        const div = textDivs[n];
+        div.textContent = textContentItemsStr[n];
+        div.className = "";
+      }
+      clearedUntilDivIdx = match.end.divIdx + 1;
+    }
+
+    if (!findController?.highlightMatches || reset) {
+      return;
+    }
+    // Convert the matches on the `findController` into the match format
+    // used for the textLayer.
+    const pageMatches = findController.pageMatches[pageIdx] || null;
+    const pageMatchesLength = findController.pageMatchesLength[pageIdx] || null;
+
+    this.matches = this._convertMatches(pageMatches, pageMatchesLength);
+    this._renderMatches(this.matches);
+  }
+}
+
+export { TextHighlighter };

+ 224 - 0
packages/core/src/text_layer_builder.js

@@ -0,0 +1,224 @@
+import { renderTextLayer, updateTextLayer, OPS, } from "pdfjs-dist/legacy/build/pdf";
+
+/**
+ * @typedef {Object} TextLayerBuilderOptions
+ * @property {TextHighlighter} highlighter - Optional object that will handle
+ *   highlighting text from the find controller.
+ * @property {TextAccessibilityManager} [accessibilityManager]
+ * @property {boolean} [isOffscreenCanvasSupported] - Allows to use an
+ *   OffscreenCanvas if needed.
+ */
+
+/**
+ * The text layer builder provides text selection functionality for the PDF.
+ * It does this by creating overlay divs over the PDF's text. These divs
+ * contain text that matches the PDF text they are overlaying.
+ */
+class TextLayerBuilder {
+  constructor({
+    pdfPage,
+    highlighter = null,
+    accessibilityManager = null,
+    isOffscreenCanvasSupported = true,
+  }) {
+    this.pdfPage = pdfPage;
+    this.rotation = 0
+    this.scale = 0;
+    this.textContentSource = null;
+    this.textContentItemsStr = [];
+    this.renderingDone = false;
+    this.textDivs = [];
+    this.textDivProperties = new WeakMap();
+    this.textLayerRenderTask = null;
+    this.highlighter = highlighter;
+    this.accessibilityManager = accessibilityManager;
+    this.isOffscreenCanvasSupported = isOffscreenCanvasSupported;
+
+    this.div = document.createElement("div");
+    this.div.className = "textLayer";
+    this.hide();
+    
+  }
+
+  #finishRendering() {
+    this.renderingDone = true;
+
+    const endOfContent = document.createElement("div");
+    endOfContent.className = "endOfContent";
+    this.div.append(endOfContent);
+
+    this.#bindMouse();
+  }
+
+  get numTextDivs() {
+    return this.textDivs.length;
+  }
+
+  /**
+   * Renders the text layer.
+   * @param {PageViewport} viewport
+   */
+  async render(viewport) {
+    if (!this.textContentSource) {
+      throw new Error('No "textContentSource" parameter specified.');
+    }
+
+    const scale = viewport.scale * (globalThis.devicePixelRatio || 1);
+    const { rotation } = viewport;
+    if (this.renderingDone) {
+      const mustRotate = rotation !== this.rotation;
+      const mustRescale = scale !== this.scale;
+      if (mustRotate || mustRescale) {
+        this.hide();
+        updateTextLayer({
+          container: this.div,
+          viewport,
+          textDivs: this.textDivs,
+          textDivProperties: this.textDivProperties,
+          isOffscreenCanvasSupported: this.isOffscreenCanvasSupported,
+          mustRescale,
+          mustRotate,
+        });
+        this.scale = scale;
+        this.rotation = rotation;
+      }
+      this.show();
+      return;
+    }
+
+    this.cancel();
+    this.highlighter?.setTextMapping(this.textDivs, this.textContentItemsStr);
+    this.accessibilityManager?.setTextMapping(this.textDivs);
+
+    this.textLayerRenderTask = renderTextLayer({
+      textContentSource: this.textContentSource,
+      container: this.div,
+      viewport,
+      textDivs: this.textDivs,
+      textDivProperties: this.textDivProperties,
+      textContentItemsStr: this.textContentItemsStr,
+      isOffscreenCanvasSupported: this.isOffscreenCanvasSupported,
+    });
+    
+    this.pdfPage.getOperatorList().then(opList => {
+      // let textContent = opList.getTextContent();
+      var textContent = '';
+      // 遍历操作符列表
+      for (var i = 0; i < opList.fnArray.length; i++) {
+        var fn = opList.fnArray[i];
+        var args = opList.argsArray[i];
+
+        // 如果是文本操作符
+        if (fn === OPS.showText) {
+          // 遍历参数数组,获取每个字符的Unicode编码值和字形信息
+          for (var j = 0; j < args.length; j++) {
+            if (typeof args[j].unicode === 'string') {
+              // 文本参数
+              textContent += args[j];
+            } else if (typeof args[j].unicode === 'number') {
+              // 字形参数
+              var glyph = pdfPage.commonObjs.get(args[j]);
+              var charCode = glyph.unicode.charCodeAt(0);
+
+              // 处理每个字符的信息
+              // console.log('Char code: ' + charCode + ', glyph info: ', glyph);
+            }
+          }
+        }
+      }
+    })
+
+    await this.textLayerRenderTask.promise;
+    this.#finishRendering();
+    this.scale = scale;
+    this.rotation = rotation;
+    this.show();
+    this.accessibilityManager?.enable();
+  }
+
+  hide() {
+    if (!this.div.hidden) {
+      // We turn off the highlighter in order to avoid to scroll into view an
+      // element of the text layer which could be hidden.
+      this.highlighter?.disable();
+      this.div.hidden = true;
+    }
+  }
+
+  show() {
+    if (this.div.hidden && this.renderingDone) {
+      this.div.hidden = false;
+      this.highlighter?.enable();
+    }
+  }
+
+  /**
+   * Cancel rendering of the text layer.
+   */
+  cancel() {
+    if (this.textLayerRenderTask) {
+      this.textLayerRenderTask.cancel();
+      this.textLayerRenderTask = null;
+    }
+    this.highlighter?.disable();
+    this.accessibilityManager?.disable();
+    this.textContentItemsStr.length = 0;
+    this.textDivs.length = 0;
+    this.textDivProperties = new WeakMap();
+  }
+
+  /**
+   * @param {ReadableStream | TextContent} source
+   */
+  setTextContentSource(source) {
+    this.cancel();
+    this.textContentSource = source;
+  }
+
+  /**
+   * Improves text selection by adding an additional div where the mouse was
+   * clicked. This reduces flickering of the content if the mouse is slowly
+   * dragged up or down.
+   */
+  #bindMouse() {
+    const { div } = this;
+
+    div.addEventListener("mousedown", evt => {
+      const end = div.querySelector(".endOfContent");
+      if (!end) {
+        return;
+      }
+      if (typeof PDFJSDev === "undefined" || !PDFJSDev.test("MOZCENTRAL")) {
+        // On non-Firefox browsers, the selection will feel better if the height
+        // of the `endOfContent` div is adjusted to start at mouse click
+        // location. This avoids flickering when the selection moves up.
+        // However it does not work when selection is started on empty space.
+        let adjustTop = evt.target !== div;
+        if (typeof PDFJSDev === "undefined" || PDFJSDev.test("GENERIC")) {
+          adjustTop &&=
+            getComputedStyle(end).getPropertyValue("-moz-user-select") !==
+            "none";
+        }
+        if (adjustTop) {
+          const divBounds = div.getBoundingClientRect();
+          const r = Math.max(0, (evt.pageY - divBounds.top) / divBounds.height);
+          end.style.top = (r * 100).toFixed(2) + "%";
+        }
+      }
+      end.classList.add("active");
+    });
+
+    div.addEventListener("mouseup", () => {
+      const end = div.querySelector(".endOfContent");
+      if (!end) {
+        return;
+      }
+      if (typeof PDFJSDev === "undefined" || !PDFJSDev.test("MOZCENTRAL")) {
+        end.style.top = "";
+      }
+      end.classList.remove("active");
+    });
+  }
+}
+
+export { TextLayerBuilder };

+ 147 - 32
packages/webview/src/assets/base.css

@@ -1,3 +1,150 @@
+/* color palette from <https://github.com/vuejs/theme> */
+:root {
+  --c-white: #ffffff;
+
+  --c-black: #000000;
+  --c-black-1: #000000CC;
+  --c-black-2: #00000033;
+  --c-black-3: #222429;
+  --c-black-4: #303238;
+  --c-black-5: #43474D;
+  --c-black-6: #42464D;
+  --c-black-7: #414347;
+  --c-black-8: #666666;
+  --c-black-9: #606773;
+  --c-black-10: #F2F3F5;
+  --c-black-11: #F2F2F2;
+  --c-black-12: #999999;
+
+  --c-blue-1: #1460F3;
+  --c-blue-2: #2D77FA;
+  --c-blue-3: #6499FF;
+  --c-blue-4: #DDE9FF;
+  --c-blue-5: #FAFCFF;
+  
+  --c-bg: var(--c-black-11);
+
+  --c-text: var(--c-black-5);
+  
+  --c-divider: rgba(0, 0, 0, 0.12);
+
+  --c-header-bg: var(--c-white);
+
+  --c-header-border: rgba(0, 0, 0, 0.12);
+
+  --c-header-text: var(--c-black-5);
+
+  --c-header-text-theme-active: var(--c-blue-1);
+
+  --c-header-button-hover: var(--c-black-10);
+  --c-header-button-active: var(--c-blue-4);
+  --c-header-input-border: #D9D9D9;
+
+  --c-popup-text: var(--c-black-5);
+
+  --c-popup-text-hover: var(--c-white);
+  --c-popup-text-active: var(--c-white);
+  --c-popup-bg-hover: rgba(20, 96, 243, 0.8);
+  --c-popup-bg-active: #1460F3;
+
+  --c-side-header-text: var(--c-black-5);
+
+  --c-side-header-bg: var(--c-white);
+  --c-side-header-active: var(--c-blue-4);
+
+  --c-side-header-border: rgba(0, 0, 0, 0.12);
+
+  --c-side-text: var(--c-black-5);
+
+  --c-side-bg: var(--c-blue-5);
+
+  --c-side-title: var(--c-black-6);
+
+  --c-side-thumbnails: var(--c-black-5);
+  --c-side-thumbnails-active: var(--c-blue-1);
+
+  --c-side-outline-text: var(--c-black-8);
+  --c-side-outline-bg-active: var(--c-black-10);
+
+  --c-findbar-text: var(--c-black-5);
+  --c-findbar-bg: var(--c-white);
+  --c-findbar-input-bg: var(--c-white);
+  --c-findbar-input-border: #D9D9D9;
+
+  --c-scrollbar-bg: var(--c-black-8);
+
+  --c-side-layers-not-checked-bg: var(--c-white);
+  --c-side-layers-not-checked-border: var(--c-black-9);
+  --c-side-layers-checked-bg: var(--c-blue-2);
+
+  --c-side-annotation-bg: var(--c-black-10);
+  --c-side-annotation-text: var(--c-black-8);
+
+  --c-header-circle-bg: var(--c-blue-1);
+}
+
+html.dark {
+  --c-bg: var(--c-black-3);
+  
+  --c-text: var(--c-white);
+
+  --c-divider: rgba(255, 255, 255, 0.2);
+
+  --c-header-bg: var(--c-black-7);
+
+  --c-header-border: rgba(255, 255, 255, 0.2);
+
+  --c-header-text: var(--c-white);
+
+  --c-header-text-theme-active: var(--c-blue-3);
+
+  --c-header-button-hover: var(--c-black-8);
+  --c-header-button-active: var(--c-black-9);
+  --c-header-input-border: var(--c-black-9);
+
+  --c-popup-text: var(--c-white);
+
+  --c-popup-text-hover: var(--c-white);
+  --c-popup-text-active: var(--c-white);
+  --c-popup-bg-hover: rgba(20, 96, 243, 0.8);
+  --c-popup-bg-active: var(--c-blue-3);
+
+  --c-side-header-text: var(--c-white);
+
+  --c-side-header-bg: var(--c-black-9);
+  --c-side-header-active: var(--c-blue-3);
+
+  --c-side-header-border: rgba(255, 255, 255, 0.2);
+
+  --c-side-text: var(--c-white);
+
+  --c-side-bg: var(--c-black-7);
+
+  --c-side-title: var(--c-white);
+
+  --c-side-thumbnails: var(--c-white);
+  --c-side-thumbnails-active: var(--c-blue-3);
+
+  --c-side-outline-text: var(--c-white);
+  --c-side-outline-bg-active: var(--c-black-9);
+
+  --c-findbar-text: var(--c-black-12);
+  --c-findbar-bg: var(--c-black-4);
+  --c-findbar-input-bg: var(--c-black-3);
+  --c-findbar-input-border: var(--c-black-9);
+
+  --c-scrollbar-bg: var(--c-black-12);
+
+  --c-side-layers-not-checked-bg: var(--c-black-9);
+  --c-side-layers-not-checked-border: var(--c-white);
+  --c-side-layers-checked-bg: var(--c-blue-3);
+
+  --c-side-annotation-bg: var(--c-black-9);
+  --c-side-annotation-text: var(--c-black-12);
+  
+  --c-header-circle-bg: var(--c-white)
+}
+
 body {
   min-height: 100vh;
   color: var(--c-text);
@@ -68,35 +215,3 @@ body {
     direction: ltr;
   }
 }
-
-.annotationContainer > div {
-  position: absolute;
-  z-index: 1;
-  overflow: hidden;
-  cursor: pointer;
-  -webkit-user-select: none;
-  -moz-user-select: none;
-  -ms-user-select: none;
-  user-select: none;
-  pointer-events: none;
-}
-.annotationContainer {
-  position: absolute;
-  left: 0;
-  top: 0;
-  z-index: 3;
-  pointer-events: auto;
-}
-.annotationContainer > div line {
-  pointer-events: painted;
-  pointer-events: painted;
-  -webkit-user-select: auto;
-  -moz-user-select: auto;
-  -ms-user-select: auto;
-  user-select: auto;
-  stroke-linecap: butt;
-  stroke-linejoin: miter;
-}
-.point-none {
-  pointer-events: auto;
-}

+ 168 - 0
packages/webview/src/assets/main.scss

@@ -29,6 +29,10 @@
   border-right: 3px solid transparent;
 }
 
+p {
+  margin: 0;
+}
+
 html, body , #app {
   width: 100%;
   max-width: 100vw;
@@ -92,3 +96,167 @@ input, textarea, select {
     user-select: text !important;
   }
 }
+
+.freetext-editor .ql-editor {
+  padding: 0px;
+}
+
+.drop-down {
+  padding: 8px;
+  text-align: left;
+  cursor: default;
+  color: var(--c-text);
+  background: #FFFFFF;
+  box-shadow: 2px 6px 18px rgba(0, 0, 0, 0.2);
+  border-radius: 2px;
+}
+.drop-item {
+  width: 100%;
+  display: flex;
+  align-items: center;
+  padding: 12px 8px;
+  border-radius: 2px;
+  font-size: 16px;
+  line-height: 18px;
+  cursor: pointer;
+  white-space: nowrap;
+  &:hover {
+    color: var(--c-popup-text-hover);
+    background-color: var(--c-popup-bg-hover);
+    .button {
+      color: var(--c-popup-text-hover);
+    }
+  }
+  &.active {
+    color: var(--c-popup-text-active);
+    background-color: var(--c-popup-bg-active);
+    .button {
+      color: var(--c-popup-text-active);
+    }
+  }
+  .button {
+    margin-right: 8px;
+    padding: 0;
+    &:not(.disabled):hover {
+      background-color: transparent;
+    }
+  }
+}
+.drop-down-divider {
+  width: 100%;
+  height: 1px;
+  margin: 4px 0;
+  background-color: var(--c-divider);
+}
+
+.annotation {
+  position: absolute;
+  z-index: 5;
+  > .freetext {
+    padding: 2px 4px;
+    pointer-events: auto;
+    color: #000;
+    font-size: 16px;
+    outline: none;
+    &:focus {
+      border: 1px solid #1460F3;
+    }
+  }
+}
+
+.annotationContainer > div {
+  position: absolute;
+  cursor: pointer;
+  -webkit-user-select: none;
+  -moz-user-select: none;
+  -ms-user-select: none;
+  user-select: none;
+  pointer-events: none;
+  z-index: 5;
+  &.annotation {
+    z-index: 5;
+    > div {
+      pointer-events: auto;
+    }
+  }
+  .freetext {
+    padding: 2px 4px;
+    pointer-events: auto;
+    color: #000;
+    font-size: 16px;
+    outline: none;
+    white-space: pre-wrap;
+    word-break: break-word;
+    &:focus {
+      border: 1px solid #1460F3;
+    }
+  }
+  > svg {
+    width: 100%;
+    height: 100%;
+    position: absolute;
+    left: 0;
+    right: 0;
+  }
+  line, rect, ellipse, path {
+    pointer-events: painted;
+    -webkit-user-select: auto;
+    -moz-user-select: auto;
+    -ms-user-select: auto;
+    user-select: auto;
+    stroke-linecap: round;
+    stroke-linejoin: miter;
+  }
+}
+
+.no-select .annotationContainer > div {
+  line, rect, ellipse, path {
+    pointer-events: none;
+  }
+}
+
+.annotationContainer > .outline-container svg {
+  pointer-events: none;
+  &.delete-button {
+    pointer-events: auto;
+  }
+  line {
+    pointer-events: painted;
+    cursor: all-scroll;
+  }
+  circle {
+    pointer-events: auto;
+    cursor: all-scroll;
+  }
+  .move {
+    cursor: all-scroll;
+  }
+  .nw-resize {
+    cursor: nw-resize;
+  }
+  .w-resize {
+    cursor: w-resize;
+  }
+  .sw-resize {
+    cursor: sw-resize;
+  }
+  .s-resize {
+    cursor: s-resize;
+  }
+  .se-resize {
+    cursor: se-resize;
+  }
+  .e-resize {
+    cursor: e-resize;
+  }
+  .ne-resize {
+    cursor: ne-resize;
+  }
+  .n-resize {
+    cursor: n-resize;
+  }
+}
+.point-none {
+  pointer-events: auto;
+}
+

+ 1 - 0
packages/webview/src/helpers/utils.js

@@ -9,6 +9,7 @@ const uploadFile = (extension) =>
         reader.onload = () => {
           const contents = reader.result;
           resolve(contents);
+          fileInput.remove()
         };
 
         if (extension.includes('xfdf')) {

+ 96 - 0
packages/webview/src/stores/modules/viewer.js

@@ -76,6 +76,62 @@ export const useViewerStore = defineStore({
         type: 'themeMode',
         dataElement: 'themeMode',
         element: 'themeMode'
+      },
+      {
+        type: 'stickyNoteButton',
+        dataElement: 'stickyNoteButton',
+        element: 'stickyNoteButton',
+        hidden: false,
+      },
+      {
+        type: 'markup',
+        dataElement: 'markup',
+        element: 'markup',
+        hidden: false,
+      },
+      {
+        type: 'measureButton',
+        dataElement: 'measureButton',
+        element: 'measureButton',
+        hidden: true,
+      },
+      {
+        type: 'compareButton',
+        dataElement: 'compareButton',
+        element: 'compareButton',
+        hidden: true,
+      },
+      {
+        type: 'saveButton',
+        dataElement: 'saveButton',
+        element: 'saveButton',
+        hidden: true,
+      }
+    ],
+    rightHeaders: [
+      {
+        type: 'openFileButton',
+        dataElement: 'openFileButton',
+        element: 'openFileButton',
+        title: 'Open File'
+      },
+      {
+        type: 'searchButton',
+        dataElement: 'searchButton',
+        element: 'searchButton',
+        title: 'Search'
+      },
+      {
+        type: 'downloadButton',
+        dataElement: 'downloadButton',
+        element: 'downloadButton',
+        title: 'Download'
+      },
+      {
+        type: 'printButton',
+        dataElement: 'printButton',
+        element: 'printButton',
+        title: 'Print'
       }
     ],
     disabledElements: null
@@ -114,6 +170,9 @@ export const useViewerStore = defineStore({
     getScale () {
       return Math.round(this.scale * 100)
     },
+    getActiveStickNote () {
+      return this.activeStickNote
+    },
     getActiveMeasure () {
       return this.activeActiveMeasure
     },
@@ -125,6 +184,22 @@ export const useViewerStore = defineStore({
     }
   },
   actions: {
+    resetSetting () {
+      this.fullMode = false
+      this.currentPage = 0
+      this.scale = '',
+      this.themeMode = 'Light'
+      this.pageMode = 0
+      this.scrollMode = 'Vertical'
+      this.activeTab = 0
+      this.activeLeftPanel = 'THUMBS',
+      this.activeLeftPanelTab = 'THUMBS'
+      this.searchStatus = false
+      this.openElements = {
+        header: true,
+        leftPanel: false
+      }
+    },
     setFullMode (fullMode) {
       return this.fullMode = fullMode
     },
@@ -164,6 +239,27 @@ export const useViewerStore = defineStore({
     },
     setActiveLeftPanelTab (dataElement) {
       this.activeLeftPanelTab = dataElement
+    },
+    toggleActiveStickNote () {
+      this.activeStickNote = !this.activeStickNote
+    },
+    closeActiveStickNote () {
+      this.activeStickNote = false
+    },
+    toggleActiveMeasure () {
+      this.activeActiveMeasure = !this.activeActiveMeasure
+    },
+    closeActiveMeasure () {
+      this.activeActiveMeasure = false
+    },
+    toggleActiveHand (value) {
+      this.activeHand = value
+    },
+    closeActiveHand () {
+      this.activeHand = false
+    },
+    setSearchStatus (value) {
+      this.searchStatus = value
     }
   }
 })

File diff suppressed because it is too large
+ 4958 - 0
pnpm-lock.yaml