浏览代码

add: 添加FreeText功能

liutian 1 年之前
父节点
当前提交
5397d8ad15
共有 2 个文件被更改,包括 1045 次插入0 次删除
  1. 848 0
      packages/core/src/annotation/freetext.js
  2. 197 0
      packages/core/src/annotation/paint/freetext.js

+ 848 - 0
packages/core/src/annotation/freetext.js

@@ -0,0 +1,848 @@
+import Base from './base';
+import { ALIGN } from '../../constants'
+import { getActualPoint, getClickPoint, createSvg, keepLastIndex, getInitialPoint, getHtmlToText } from './utils';
+import { onClickOutside } from '../ui_utils'
+
+export default class Shape extends Base {
+  
+  constructor ({
+    container,
+    annotation = null,
+    page,
+    viewport,
+    scale,
+    eventBus,
+    layer,
+    show = false
+  }) {
+    super({
+      container,
+      annotation,
+      page,
+      viewport,
+      scale,
+      eventBus,
+      show
+    })
+
+    this.layer = layer
+    this.hidden = true
+    this.initial = false
+    this.outline = null
+
+    this.leftTop = null
+    this.rightBottom = null
+
+    this.newLeftTop = null
+    this.newRightBottom = null
+
+    this.startCircle = null
+    this.endCircle = null
+
+    this.show = show
+
+    this.onContainerClick = this.handleContainerClick.bind(this)
+    this.onMousedown = this.handleMouseDown.bind(this)
+    this.onMousemove = this.handleMouseMove.bind(this)
+    this.onMouseup = this.handleMouseUp.bind(this)
+    this.onDelete = this.handleDelete.bind(this)
+    this.onBlur = this.handleFreetextEditElementBlur.bind(this)
+    this.onDbclick = this.handleElementDbClick.bind(this)
+    this.render()
+  }
+
+  setCss (ele, cssText) {
+    if (!ele) return
+    if (cssText) {
+      for (let key in cssText) {
+        ele.style[key] = cssText[key]
+      }
+    }
+  }
+
+  render () {
+    const { width, height } = this.viewport
+    const annotation = this.annotation
+
+    const { leftTop, rightBottom } = this.getActualRect(
+      this.viewport,
+      this.viewport.scale
+    )
+    this.initialRect = {
+      width: Math.abs(leftTop.x - rightBottom.x),
+      height: Math.abs(leftTop.y - rightBottom.y)
+    }
+
+    this.leftTop = leftTop
+    this.rightBottom = rightBottom
+
+    const rect = this.calculate(this.leftTop, this.rightBottom)
+
+    const annotationContainer = document.createElement('div')
+    annotationContainer.className = 'annotation'
+    this.setCss(annotationContainer, {
+      top: rect.top - 2 + 'px',
+      left: rect.left - 4 + 'px',
+    })
+    this.annotationContainer = annotationContainer
+
+    let freetextEditElement = document.createElement('div')
+    freetextEditElement.setAttribute('role', 'textbox')
+    freetextEditElement.setAttribute('contenteditable', true)
+    freetextEditElement.setAttribute('spellcheck', false)
+    freetextEditElement.className = 'freetext'
+    
+    const maxWidth = width - this.leftTop.x
+    const maxHeight = height - this.leftTop.y
+
+    const align = (annotation.justification && ALIGN[annotation.justification]) || 'left'
+
+    this.setCss(freetextEditElement, {
+      maxWidth: maxWidth+ 'px',
+      maxHeight: maxHeight + 'px',
+      fontSize: '16px',
+      color: '#000',
+      textAlign: align,
+      lineHeight: '1.2em',
+      padding: '2px 4px',
+      overflow: 'auto'
+    })
+
+    this.freetextEditElement = freetextEditElement
+
+    let freetextElement = document.createElement('div')
+    freetextElement.className = 'freetext'
+
+    this.setCss(freetextElement, {
+      width: rect.width+ 'px',
+      height: rect.height + 'px',
+      fontSize: '16px',
+      color: '#000',
+      textAlign: align,
+      lineHeight: '1.2em',
+      padding: '2px 4px',
+      overflow: 'hidden'
+    })
+    if (annotation.content) {
+      freetextElement.innerHTML = annotation.content
+      freetextEditElement.innerHTML = annotation.content
+    } else if (annotation.contents) {
+      freetextElement.innerHTML = annotation.contents
+      freetextEditElement.innerHTML = annotation.contents
+    } else if (annotation['contents-richtext']) {
+      const parser = new window.DOMParser();
+      const xmlDoc = parser.parseFromString(annotation['contents-richtext'], 'text/xml');
+      const error = xmlDoc.getElementsByTagName("parsererror")
+      if (error.length > 0) {
+        freetextElement.innerHTML = annotation['contents-richtext']
+        freetextEditElement.innerHTML = annotation['contents-richtext']
+      } else {
+        freetextElement.innerHTML = xmlDoc.firstElementChild.innerText
+        freetextEditElement.innerHTML = xmlDoc.firstElementChild.innerText
+      }
+    }
+
+    this.freetextElement = freetextElement
+
+    this.appendOrRemoveFreetextElement('append')
+
+    this.container.append(this.annotationContainer)
+
+    this.outerLineContainer = document.createElement('div')
+    this.outerLineContainer.className = 'outline-container'
+
+    this.deletetButton = createSvg(
+      "svg",
+      {
+        width: "30",
+        height: "30",
+        viewBox: "0 0 30 30",
+        fill: "none",
+        class: 'delete-button'
+      },
+      {
+        width: '30px',
+        height: '30px'
+      }
+    );
+    this.deletetButton.addEventListener('click', this.onDelete)
+    this.deletetButton.innerHTML = this.deleteSvgStr
+
+    const left = (rect.left + rect.width - 30) + 'px'
+    const top = (rect.top + rect.height + 12) + 'px'
+    this.deletetButton.style.left = left
+    this.deletetButton.style.top = top
+
+    const outerLine = createSvg('svg', null, {
+      position: 'absolute',
+      zIndex: 1,
+      left: `${rect.left - 12}px`,
+      top: `${rect.top - 9}px`,
+      width: `${rect.width + 16}px`,
+      height: `${rect.height + 15}px`,
+    })
+
+    this.outerLine = outerLine
+
+    this.moveRect = createSvg(
+      "rect",
+      {
+        class: "move",
+        'data-id': "move",
+        stroke: "#1460F3",
+        'stroke-width': 1,
+        'fill-opacity': 0,
+        width: rect.width + 10,
+        height: rect.height + 9,
+        x: 3,
+        y: 3
+      }
+    );
+
+    this.topLeftRect = createSvg(
+      "rect",
+      {
+        class: "nw-resize",
+        'data-id': "nw-resize",
+        fill: "#1460F3",
+        stroke: "none",
+        width: 6,
+        height: 6,
+        x: 0,
+        y: 0
+      }
+    );
+
+    this.LeftRect = createSvg(
+      "rect",
+      {
+        class: "w-resize",
+        'data-id': "w-resize",
+        fill: "#1460F3",
+        stroke: "none",
+        width: 6,
+        height: 6,
+        x: 0,
+        y: rect.height / 2 + 6
+      }
+    );
+
+    this.bottomLeftRect = createSvg(
+      "rect",
+      {
+        class: "sw-resize",
+        'data-id': "sw-resize",
+        fill: "#1460F3",
+        stroke: "none",
+        width: 6,
+        height: 6,
+        x: 0,
+        y: rect.height + 9
+      }
+    );
+
+    this.bottomRect = createSvg(
+      "rect",
+      {
+        class: "s-resize",
+        'data-id': "s-resize",
+        fill: "#1460F3",
+        stroke: "none",
+        width: 6,
+        height: 6,
+        x: rect.width / 2 + 9,
+        y: rect.height + 9
+      }
+    );
+    
+    this.bottomRightRect = createSvg(
+      "rect",
+      {
+        class: "se-resize",
+        'data-id': "se-resize",
+        fill: "#1460F3",
+        stroke: "none",
+        width: 6,
+        height: 6,
+        x: rect.width + 10,
+        y: rect.height + 9
+      }
+    );
+
+    this.rightRect = createSvg(
+      "rect",
+      {
+        class: "e-resize",
+        'data-id': "e-resize",
+        fill: "#1460F3",
+        stroke: "none",
+        width: 6,
+        height: 6,
+        x: rect.width + 10,
+        y: rect.height / 2 + 6
+      }
+    );
+
+    this.topRightRect = createSvg(
+      "rect",
+      {
+        class: "ne-resize",
+        'data-id': "ne-resize",
+        fill: "#1460F3",
+        stroke: "none",
+        width: 6,
+        height: 6,
+        x: rect.width + 10,
+        y: 0
+      }
+    );
+
+    this.topRect = createSvg(
+      "rect",
+      {
+        class: "n-resize",
+        'data-id': "n-resize",
+        fill: "#1460F3",
+        stroke: "none",
+        width: 6,
+        height: 6,
+        x: rect.width / 2 + 9,
+        y: 0
+      }
+    );
+
+    this.outerLine.append(this.moveRect)
+    this.outerLine.append(this.topLeftRect)
+    this.outerLine.append(this.LeftRect)
+    this.outerLine.append(this.bottomLeftRect)
+    this.outerLine.append(this.bottomRect)
+    this.outerLine.append(this.bottomRightRect)
+    this.outerLine.append(this.rightRect)
+    this.outerLine.append(this.topRightRect)
+    this.outerLine.append(this.topRect)
+    this.outerLineContainer.append(this.outerLine)
+    this.outerLineContainer.append(this.deletetButton)
+    this.initial = true
+    this.handleElementSelect()
+  }
+
+  handleElementDbClick() {
+    const freetextEditElement = this.freetextEditElement
+    if (freetextEditElement) {
+      this.outerLine.removeEventListener('mousedown', this.onMousedown)
+      this.outerLine.removeEventListener('touchstart', this.onMousedown)
+      this.outerLine.removeEventListener('dblclick', this.onDbclick)
+      this.outerLineContainer.remove()
+
+      this.appendOrRemoveFreetextElement('remove')
+
+      this.appendOrRemoveFreetextEditElement('append')
+    }
+  }
+
+  getFreetextRectPoint () {
+    const rect = this.freetextEditElement.getBoundingClientRect()
+    const { width, height } = rect
+    const leftTop = this.leftTop
+    this.rightBottom = {
+      x: leftTop.x + width,
+      y: leftTop.y + height
+    }
+  }
+
+  getFreetextRectString () {
+    const viewport = this.viewport
+    const { scale } = this.viewport
+    this.getFreetextRectPoint()
+    const actualLeftTopPoint = getInitialPoint(this.leftTop, viewport, scale)
+    const actualRightBottomPoint = getInitialPoint(this.rightBottom, viewport, scale)
+    return `${actualLeftTopPoint.x},${actualRightBottomPoint.y},${actualRightBottomPoint.x},${actualLeftTopPoint.y}`
+  }
+
+  handleFreetextEditElementBlur () {
+    const freetextEditElement = this.freetextEditElement
+    if (freetextEditElement && freetextEditElement.innerText !== this.annotation.content) {
+      const freetextRectString = this.getFreetextRectString()
+
+      const text = getHtmlToText(freetextEditElement)
+      this.eventBus.dispatch('annotationChange', {
+        type: 'modify',
+        annotation: {
+          operate: "mod-annot",
+          name: this.annotation.name,
+          page: this.page,
+          content: text,
+          textColor: '#000000',
+          fillColor: '#FFFFFF',
+          fontSize: 16,
+          fillTransparency: 0,
+          alignment: 0,
+          color: 'transparent',
+          fontName: 'Helvetica',
+          transparency: 1,
+          fillTransparency: 0,
+          rect: freetextRectString
+        }
+      })
+      this.update({
+        leftTop: this.leftTop,
+        rightBottom: this.rightBottom
+      })
+      this.annotation.content = text
+      this.freetextElement.innerText = text
+    }
+
+    this.appendOrRemoveFreetextEditElement('remove')
+    this.appendOrRemoveFreetextElement('append')
+  }
+
+  // 处理显示节点显示和隐藏
+  appendOrRemoveFreetextElement (type) {
+    const freetextElement = this.freetextElement
+    if (type === 'append') {
+      freetextElement.addEventListener('click', this.onContainerClick)
+
+      this.annotationContainer.append(freetextElement)
+    } else {
+      freetextElement.removeEventListener('click', this.onContainerClick)
+
+      freetextElement.remove()
+    }
+  }
+
+  // 处理编辑框显示和隐藏
+  appendOrRemoveFreetextEditElement (type) {
+    const freetextEditElement = this.freetextEditElement
+    if (type === 'append') {
+      freetextEditElement.addEventListener('blur', this.onBlur)
+      
+      this.annotationContainer.append(freetextEditElement)
+      freetextEditElement.focus()
+      keepLastIndex(freetextEditElement)
+    } else {
+      freetextEditElement.removeEventListener('blur', this.onBlur)
+
+      freetextEditElement.remove()
+    }
+  }
+
+  handleElementSelect () {
+    if (this.show) {
+      this.show = false
+      this.hidden = false
+      this.updateTool()
+      onClickOutside([this.freetextElement, this.outerLine, this.deletetButton], this.handleOutside.bind(this))
+    }
+  }
+
+  getActualRect (viewport, s,) {
+    const annotation = this.annotation
+    const [x1, y1, x2, y2] = annotation.rect.split(',')
+
+    const start = getActualPoint(
+      {
+        x: x1,
+        y: y1
+      },
+      viewport,
+      s
+    )
+    const end = getActualPoint(
+      {
+        x: x2,
+        y: y2
+      },
+      viewport,
+      s
+    )
+    return {
+      leftTop: {
+        x: Math.min(start.x, end.x),
+        y: Math.min(start.y, end.y)
+      },
+      rightBottom: {
+        x: Math.max(start.x, end.x),
+        y: Math.max(start.y, end.y)
+      }
+    }
+  }
+
+  rectCalc ({ leftTop, rightBottom }) {
+    return {
+      top: leftTop.y < rightBottom.y ? leftTop.y : rightBottom.y,
+      left: leftTop.x < rightBottom.x ? leftTop.x : rightBottom.x,
+      width: Math.abs(rightBottom.x - leftTop.x),
+      height: Math.abs(rightBottom.y - leftTop.y),
+    };
+  };
+
+  calculate (leftTop, rightBottom) {
+    const initRect = this.rectCalc({ leftTop, rightBottom });
+
+    return {
+      top: initRect.top,
+      left: initRect.left,
+      width: initRect.width,
+      height: initRect.height,
+    }
+  }
+
+  getInitialPoint () {
+    const left = this.leftTop.x
+    const top = this.leftTop.y
+    const right = this.rightBottom.x
+    const bottom = this.rightBottom.y
+
+    const { width, height, rotation, scale: s  } = this.viewport;
+    let x1, y1, x2, y2;
+    if (rotation === 0) {
+      x1 = left / s;
+      y1 = (height - top) / s; 
+      x2 = right / s;
+      y2 = (height - bottom) / s;
+    } else if (rotation === 90) {
+      x1 = bottom / s;
+      y1 = right / s;
+      x2 = top / s;
+      y2 = left / s;
+    } else if (rotation === 180) {
+      x1 = (width - left) / s;
+      y1 = top / s;
+      x2 = (width - right) / s;
+      y2 = bottom / s;
+    } else {
+      x1 = (height - bottom) / s
+      y1 = (width - left) / s
+      x2 = (height - top) / s
+      y2 = (width - right)  / s
+    }
+    return {
+      leftTop: {
+        x: x1,
+        y: y1,
+      },
+      rightBottom: {
+        x: x2,
+        y: y2,
+      },
+    };
+  }
+
+  updateTool () {
+    if (!this.initial) return
+    
+    // debugger
+    if (this.hidden) {
+      this.outerLineContainer.remove()
+      if (this.layer.selectedElementName === this.annotation.name) {
+        this.layer.selectedElementName = null
+      }
+    } else {
+      if (this.layer.selectedElementName !== this.annotation.name) {
+        this.layer.selectedElementName = this.annotation.name
+      }
+      this.container.append(this.outerLineContainer)
+      this.outerLine.addEventListener('mousedown', this.onMousedown)
+      this.outerLine.addEventListener('dblclick', this.onDbclick)
+      this.outerLine.addEventListener('touchstart', this.onMousedown)
+    }
+  }
+
+  handleContainerClick (event) {
+    if (!this.hidden) return
+    this.hidden = false
+    this.updateTool()
+    onClickOutside([this.outerLine, this.freetextElement, this.deletetButton], this.handleOutside.bind(this))
+  }
+
+  handleOutside () {
+    this.hidden = !this.hidden
+
+    if (this.layer.selectedElementName === this.annotation.name) {
+      this.layer.selectedElementName = null
+    }
+    this.outerLine.removeEventListener('mousedown', this.onMousedown)
+    this.outerLine.removeEventListener('touchstart', this.onMousedown)
+    this.outerLine.removeEventListener('dblclick', this.onDbclick)
+    this.outerLineContainer.remove()
+
+    if (this.layer.selectedElementName === this.annotation.name) {
+      this.layer.selectedElementName = null
+    }
+  }
+
+  handleMouseDown (event) {
+    if (this.layer.tool) {
+      event.stopPropagation()
+    }
+    const operatorId = event.target.getAttribute('data-id')
+    const { pageX, pageY } = getClickPoint(event)
+    this.startState = {
+      operator: operatorId,
+      clickX: pageX,
+      clickY: pageY,
+    }
+
+    document.addEventListener('mousemove', this.onMousemove)
+    document.addEventListener('mouseup', this.onMouseup)
+    document.addEventListener('touchmove', this.onMousemove)
+    document.addEventListener('touchend', this.onMouseup)
+  };
+
+  handleMouseMove (event) {
+    if (event.type === 'touchmove') {
+      document.body.style.overscrollBehavior = 'none';
+    }
+    this.moving = true
+    const { pageX, pageY } = getClickPoint(event)
+    const leftTop = this.leftTop
+    const rightBottom = this.rightBottom
+    const startState = this.startState
+
+    const { width, height } = this.viewport
+
+    const rect = {
+      width: 10,
+      height: 10
+    }
+    if (startState.operator === 'nw-resize') {
+      let left = pageX - (startState.clickX - leftTop.x);
+      let top = pageY - (startState.clickY - leftTop.y);
+      left = Math.min(rightBottom.x - rect.width, left)
+      left = Math.max(0, left)
+      top = Math.min(rightBottom.y - rect.height, top)
+      top = Math.max(0, top)
+
+      this.update({
+        leftTop: { x: left, y: top },
+        rightBottom,
+      });
+    } else if (startState.operator === 'w-resize') {
+      let left = pageX - (startState.clickX - leftTop.x);
+      left = Math.min(rightBottom.x - rect.width, left)
+      left = Math.max(0, left)
+
+      this.update({
+        leftTop: { x: left, y: leftTop.y },
+        rightBottom,
+      });
+    } else if (startState.operator === 'sw-resize') {
+      let left = pageX - (startState.clickX - leftTop.x);
+      let bottom = pageY - (startState.clickY - rightBottom.y);
+      left = Math.min(rightBottom.x - rect.width, left)
+      left = Math.max(0, left)
+      bottom = Math.min(height, bottom)
+      bottom = Math.max(leftTop.y + rect.height, bottom)
+
+      this.update({
+        leftTop: { x: left, y: leftTop.y },
+        rightBottom: { x: rightBottom.x, y: bottom },
+      });
+    } else if (startState.operator === 's-resize') {
+      let bottom = pageY - (startState.clickY - rightBottom.y);
+      bottom = Math.min(height, bottom)
+      bottom = Math.max(leftTop.y + rect.height, bottom)
+      this.update({
+        leftTop,
+        rightBottom: { x: rightBottom.x, y: bottom },
+      });
+    } else if (startState.operator === 'se-resize') {
+      let right = pageX - (startState.clickX - rightBottom.x);
+      let bottom = pageY - (startState.clickY - rightBottom.y);
+      right = Math.min(width, right)
+      right = Math.max(leftTop.x + rect.width, right)
+      bottom = Math.min(height, bottom)
+      bottom = Math.max(leftTop.y + rect.height, bottom)
+      this.update({
+        leftTop,
+        rightBottom: { x: right, y: bottom },
+      });
+    } else if (startState.operator === 'e-resize') {
+      let right = pageX - (startState.clickX - rightBottom.x);
+      right = Math.min(width, right)
+      right = Math.max(leftTop.x + rect.width, right)
+      this.update({
+        leftTop,
+        rightBottom: { x: right, y: rightBottom.y },
+      });
+    } else if (startState.operator === 'ne-resize') {
+      let right = pageX - (startState.clickX - rightBottom.x);
+      let top = pageY - (startState.clickY - leftTop.y);
+      right = Math.min(width, right)
+      right = Math.max(leftTop.x + rect.width, right)
+      top = Math.min(rightBottom.y - rect.height, top)
+      top = Math.max(0, top)
+      this.update({
+        leftTop: { x: leftTop.x, y: top },
+        rightBottom: { x: right, y: rightBottom.y },
+      });
+    } else if (startState.operator === 'n-resize') {
+      let top = pageY - (startState.clickY - leftTop.y);
+      top = Math.min(rightBottom.y - rect.height, top)
+      top = Math.max(0, top)
+      this.update({
+        leftTop: { x: leftTop.x, y: top },
+        rightBottom,
+      });
+    } else if (startState.operator === 'move') {
+      let left = pageX - (startState.clickX - leftTop.x);
+      let top = pageY - (startState.clickY - leftTop.y);
+      let right = pageX - (startState.clickX - rightBottom.x);
+      let bottom = pageY - (startState.clickY - rightBottom.y);
+
+      const rect = {
+        width: Math.abs(left - right),
+        height: Math.abs(bottom - top)
+      }
+      if (left < right) {
+        left = Math.max(0, left)
+        left = Math.min(width - rect.width, left)
+        right = Math.max(rect.width, right)
+        right = Math.min(width, right)
+      } else {
+        right = Math.max(0, right)
+        right = Math.min(width - rect.width, right)
+        left = Math.max(rect.width, left)
+        left = Math.min(width, left)
+      }
+
+      if (bottom < top) {
+        bottom = Math.max(0, bottom)
+        bottom = Math.min(height - rect.height, bottom)
+        top = Math.max(rect.height, top)
+        top = Math.min(height, top)
+      } else {
+        top = Math.max(0, top)
+        top = Math.min(height - rect.height, top)
+        bottom = Math.max(rect.height, bottom)
+        bottom = Math.min(height, bottom)
+      }
+
+      this.update({
+        leftTop: { x: left, y: top },
+        rightBottom: { x: right, y: bottom },
+      });
+    }
+  }
+
+  handleMouseUp (event) {
+    if (this.layer.tool) {
+      event.stopPropagation()
+    }
+    if (event.type === 'touchend') {
+      document.body.style.overscrollBehavior = 'auto';
+    }
+    this.moving = false
+    document.removeEventListener('mousemove', this.onMousemove)
+    document.removeEventListener('mouseup', this.onMouseup)
+    document.removeEventListener('touchmove', this.onMousemove)
+    document.removeEventListener('touchend', this.onMouseup)
+
+    const { pageX, pageY } = getClickPoint(event)
+    if (pageX === this.startState.clickX && pageY === this.startState.clickY) return
+
+    this.leftTop = this.newStart
+    this.rightBottom = this.newEnd
+
+    const annotation = this.annotation
+    const { leftTop, rightBottom } = this.getInitialPoint()
+
+    const rect = leftTop.x + ',' + rightBottom.y + ',' + rightBottom.x + ',' + leftTop.y
+    annotation.rect = rect
+    this.eventBus.dispatch('annotationChange', {
+      type: 'modify',
+      annotation: {
+        operate: "mod-annot",
+        name: annotation.name,
+        page: this.page,
+        rect,
+        textColor: '#000000',
+        fillColor: '#FFFFFF',
+        fontSize: 16,
+        fillTransparency: 0,
+        alignment: 0,
+        color: 'transparent',
+        fontName: 'Helvetica',
+        transparency: 1,
+        fillTransparency: 0
+      }
+    })
+  }
+
+  update ({ leftTop, rightBottom }) {
+    const { width, height } = this.viewport
+    const rect = this.calculate(leftTop, rightBottom)
+
+    this.newStart = leftTop
+    this.newEnd = rightBottom
+
+    this.setCss(this.annotationContainer, {
+      top: rect.top - 2 + 'px',
+      left: rect.left - 4 + 'px',
+    })
+
+    const maxWidth = width - leftTop.x
+    const maxHeight = height - leftTop.y
+    this.setCss(this.freetextEditElement, {
+      maxWidth: maxWidth+ 'px',
+      maxHeight: maxHeight + 'px',
+    })
+
+    this.setCss(this.freetextElement, {
+      width: rect.width+ 'px',
+      height: rect.height + 'px'
+    })
+
+    this.setCss(this.outerLine, {
+      left: `${rect.left - 12}px`,
+      top: `${rect.top - 9}px`,
+      width: `${rect.width + 12 * 2}px`,
+      height: `${rect.height + 15}px`,
+    })
+
+    const left = (rect.left + rect.width - 30) + 'px'
+    const top = (rect.top + rect.height + 8) + 'px'
+    this.setCss(this.deletetButton, {
+      left,
+      top,
+    })
+    
+    
+    this.moveRect.setAttribute('width',  rect.width + 10)
+    this.moveRect.setAttribute('height',  rect.height + 9)
+    
+    this.LeftRect.setAttribute("y", rect.height / 2 + 6)
+
+    this.bottomLeftRect.setAttribute("y", rect.height + 9)
+
+    this.bottomRect.setAttribute("x", rect.width / 2 + 9)
+    this.bottomRect.setAttribute("y", rect.height + 9)
+
+    this.bottomRightRect.setAttribute("x", rect.width + 10)
+    this.bottomRightRect.setAttribute("y", rect.height + 9)
+
+    this.rightRect.setAttribute("x", rect.width + 10,)
+    this.rightRect.setAttribute("y", rect.height / 2 + 6)
+    
+    this.topRightRect.setAttribute("x", rect.width + 10)
+
+    this.topRect.setAttribute("x", rect.width / 2 + 9)
+  }
+
+  handleDelete (event) {
+    if (this.layer.tool) {
+      event.stopPropagation()
+    }
+    this.handleOutside()
+    this.annotationContainer.remove()
+    this.annotation.isDelete = true
+    this.eventBus.dispatch('annotationChange', {
+      type: 'delete',
+      annotation: {
+        operate: "del-annot",
+        name: this.annotation.name,
+        page: this.page,
+      }
+    })
+  }
+}

+ 197 - 0
packages/core/src/annotation/paint/freetext.js

@@ -0,0 +1,197 @@
+import dayjs from 'dayjs'
+import { getActualPoint, getAbsoluteCoordinate, getInitialPoint, getHtmlToText } from '../utils';
+
+export default class Freetext {
+  
+  constructor ({
+    tool,
+    color,
+    svgElement,
+    layer,
+    container,
+    viewport,
+    page,
+    eventBus
+  }) {
+    this.layer = layer
+    this._tool = tool
+    this.color = color
+    this.svgElement = svgElement
+    this.container = container
+    this.viewport = viewport
+    this.page = page
+    this.eventBus = eventBus
+
+    this.freetextElement = null
+    this.start = null
+    this.end = null
+
+    this.onClick = this.handleClick.bind(this)
+    this.onBlur = this.handleBlur.bind(this)
+    this.init()
+  }
+
+  get tool () {
+    return this._tool
+  }
+
+  set tool (toolType) {
+    if (toolType === this._tool) return
+    if (!toolType) {
+      this.reset()
+      this.svgElement.style.cursor = 'default'
+
+      this.container.removeEventListener('click', this.onClick)
+    }
+    this._tool = toolType
+  }
+
+  reset () {
+    const freetextElement = this.freetextElement
+    this.start = null
+    this.end = null
+
+    this.container.removeEventListener('click', this.onClick)
+    if (freetextElement) {
+      freetextElement.remove()
+      freetextElement.removeEventListener('blur', this.onBlur)
+      this.freetextElement = null
+    }
+  }
+
+  init () {
+    this.container.addEventListener('click', this.onClick)
+    this.svgElement.style.cursor = 'crosshair'
+  }
+
+  getFreetextRectPoint () {
+    const start = this.start
+    const end = this.end
+    const startX = start.x
+    const startY = end.y
+    const endX = end.x
+    const endY = start.y
+    return {
+      start: {
+        x: startX,
+        y: startY
+      },
+      end: {
+        x: endX,
+        y: endY
+      }
+    }
+  }
+
+  getFreetextRectString () {
+    const viewport = this.viewport
+    const { scale } = this.viewport
+    const freetextRectPoint = this.getFreetextRectPoint()
+    const actualStartPoint = getInitialPoint(freetextRectPoint.start, viewport, scale)
+    const actualEndPoint = getInitialPoint(freetextRectPoint.end, viewport, scale)
+    return `${actualStartPoint.x},${actualStartPoint.y},${actualEndPoint.x},${actualEndPoint.y}`
+  }
+
+  handleFreetext () {
+    const freetextElement = this.freetextElement
+    if (freetextElement.innerText) {
+      const rect = freetextElement.getBoundingClientRect()
+      this.end = {
+        x: this.start.x + rect.width,
+        y: this.start.y + rect.height
+      }
+      const freetextRectString = this.getFreetextRectString()
+
+      const text = getHtmlToText(freetextElement)
+
+      const annotation = {
+        operate: "add-annot",
+        type: 'freetext',
+        page: this.page,
+        textColor: '#000000',
+        fillColor: '#FFFFFF',
+        fontSize: 16,
+        fillTransparency: 0,
+        color: 'transparent',
+        fontName: 'Helvetica',
+        transparency: 1,
+        alignment: 0,
+        width: 0,
+        fillTransparency: 0,
+        content: text,
+        rect: freetextRectString,
+        modified: dayjs(new Date()).format('YYYYMMDDHHmmss')
+      }
+
+      this.eventBus.dispatch('annotationChange', {
+        type: 'add',
+        annotation
+      })
+      this.layer.renderAnnotation(annotation)
+    }
+
+    freetextElement.remove()
+    freetextElement.removeEventListener('blur', this.onBlur)
+    this.freetextElement = null
+  }
+
+  handleBlur () {
+    this.handleFreetext()
+  }
+
+  handleClick (event) {
+    if (this.layer.selectedElementName) return
+    const freetextElement = this.freetextElement
+    if (freetextElement && event.target === freetextElement) return
+
+    if (!freetextElement && !this.layer.selectedElementName) {
+      this.renderFreetext(event)
+    }
+  }
+
+  setCss (ele, cssText) {
+    if (!ele) return
+    if (cssText) {
+      for (let key in cssText) {
+        ele.style[key] = cssText[key]
+      }
+    }
+  }
+
+  renderFreetext (event) {
+    const start = getAbsoluteCoordinate(this.container, event)
+    this.start = start
+    if (!this.freetextElement) {
+      const { width, height } = this.viewport
+      const maxWidth = width - this.start.x
+      const maxHeight = height - this.start.y
+
+      let freetextElement = document.createElement('div')
+      freetextElement.setAttribute('role', 'textbox')
+      freetextElement.setAttribute('contenteditable', true)
+      freetextElement.setAttribute('spellcheck', false)
+      this.setCss(freetextElement, {
+        position: 'absolute',
+        left: start.x - 4 + 'px',
+        top: start.y - 2 + 'px',
+        maxWidth: maxWidth+ 'px',
+        maxHeight: maxHeight + 'px',
+        minWidth: '20px',
+        fontSize: '16px',
+        textAlign: 'left',
+        whiteSpace: 'pre-wrap',
+        color: '#000',
+        lineHeight: '1.2em',
+        padding: '2px 4px',
+        border: '1px solid #1460F3',
+        overflow: 'auto'
+      })
+      freetextElement.className = 'freetext'
+      freetextElement.addEventListener('blur', this.onBlur)
+
+      this.freetextElement = freetextElement
+      this.container.append(freetextElement)
+      freetextElement.focus()
+    }
+  }
+}