Browse Source

add: server-backed模式串接;在注释上补充悬浮回复按钮;
fix: 修复一轮测试问题

wzl 1 year ago
parent
commit
68dc8a9a3b

+ 3 - 0
packages/core/src/annotation/base.js

@@ -21,6 +21,9 @@ export default class BaseAnnotation {
     this.deleteSvgStr = `<rect width="30" height="30" rx="2" fill="#DDE9FF"/>
       <path fill-rule="evenodd" clip-rule="evenodd" d="M19 6.5V9.5H24V10.5H21.5V23.5H8.5V10.5H6V9.5H11V6.5H19ZM9.5 10.5V22.5H20.5V10.5H9.5ZM18 7.5V9.5H12V7.5H18ZM13.5 13V20H12.5V13H13.5ZM17.5 20V13H16.5V20H17.5Z" fill="#333333"/>
     `
+    this.replySvgStr = `<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
+      <path d="M16 12.3333C16 12.7017 15.8537 13.055 15.5932 13.3154C15.3327 13.5759 14.9795 13.7222 14.6111 13.7222H6.27778L3.5 16.5V5.38889C3.5 5.02053 3.64633 4.66726 3.9068 4.4068C4.16726 4.14633 4.52053 4 4.88889 4H14.6111C14.9795 4 15.3327 4.14633 15.5932 4.4068C15.8537 4.66726 16 5.02053 16 5.38889V12.3333Z" stroke="#43474D" stroke-linecap="round" stroke-linejoin="round"/>
+    </svg>`
 
     this.page = page
   }

+ 37 - 11
packages/core/src/annotation/freetext.js

@@ -1,6 +1,6 @@
 import Base from './base';
 import { ALIGN } from '../../constants'
-import { getActualPoint, getClickPoint, createSvg, keepLastIndex, getInitialPoint, getHtmlToText } from './utils';
+import { getActualPoint, getClickPoint, createSvg, keepLastIndex, getInitialPoint, getHtmlToText, createElement } from './utils';
 import { hexToRgb, onClickOutside } from '../ui_utils'
 
 export default class Shape extends Base {
@@ -47,6 +47,7 @@ export default class Shape extends Base {
     this.onMousemove = this.handleMouseMove.bind(this)
     this.onMouseup = this.handleMouseUp.bind(this)
     this.onDelete = this.handleDelete.bind(this)
+    this.onOpenReply = this.openReply.bind(this)
     this.onBlur = this.handleFreetextEditElementBlur.bind(this)
     this.onDbclick = this.handleElementDbClick.bind(this)
     this.render()
@@ -179,11 +180,28 @@ export default class Shape extends Base {
     );
     this.deletetButton.addEventListener('click', this.onDelete)
     this.deletetButton.innerHTML = this.deleteSvgStr
+    this.deletetButton.style.left = (rect.left + rect.width - 30) + 'px'
+    this.deletetButton.style.top = (rect.top + rect.height + 12) + 'px'
 
-    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
+    this.replyButton = createElement(
+      'div',
+      {
+        width: '30px',
+        height: '30px',
+        background: '#DDE9FF',
+        borderRadius: '2px',
+        display: 'flex',
+        justifyContent: 'center',
+        alignItems: 'center'
+      },
+      {
+        class: 'reply-button'
+      }
+    );
+    this.replyButton.addEventListener('click', this.onOpenReply)
+    this.replyButton.innerHTML = this.replySvgStr
+    this.replyButton.style.left = (rect.left + rect.width - 65) + 'px'
+    this.replyButton.style.top = (rect.top + rect.height + 12) + 'px'
 
     const outerLine = createSvg('svg', null, {
       position: 'absolute',
@@ -334,6 +352,7 @@ export default class Shape extends Base {
     this.outerLine.append(this.topRect)
     this.outerLineContainer.append(this.outerLine)
     this.outerLineContainer.append(this.deletetButton)
+    this.outerLineContainer.append(this.replyButton)
     this.initial = true
     this.firstSelect = true
     if (this.show) this.handleElementSelect()
@@ -445,7 +464,7 @@ export default class Shape extends Base {
     if (!this.firstSelect) {
       this.updateTool()
     }
-    onClickOutside([this.freetextElement, this.outerLine, this.deletetButton], this.handleOutside.bind(this))
+    onClickOutside([this.freetextElement, this.outerLine, this.deletetButton, this.replyButton], this.handleOutside.bind(this))
     this.firstSelect = false
   }
 
@@ -549,7 +568,7 @@ export default class Shape extends Base {
     if (!this.hidden || this.layer.annotationStore.creating) return
     this.hidden = false
     this.updateTool()
-    onClickOutside([this.outerLine, this.freetextElement, this.deletetButton], this.handleOutside.bind(this))
+    onClickOutside([this.outerLine, this.freetextElement, this.deletetButton, this.replyButton], this.handleOutside.bind(this))
   }
 
   handleOutside () {
@@ -799,11 +818,14 @@ export default class Shape extends Base {
       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,
+      left: (rect.left + rect.width - 30) + 'px',
+      top: (rect.top + rect.height + 12) + 'px',
+    })
+
+    this.setCss(this.replyButton, {
+      left: (rect.left + rect.width - 65) + 'px',
+      top: (rect.top + rect.height + 12) + 'px',
     })
     
     
@@ -849,4 +871,8 @@ export default class Shape extends Base {
     }
     this.eventBus.dispatch('annotationChange', annotationData)
   }
+
+  openReply () {
+    this.eventBus.dispatch('openAnnotationReply', this.annotation)
+  }
 }

+ 33 - 6
packages/core/src/annotation/ink.js

@@ -1,5 +1,5 @@
 import Base from './base';
-import { getActualPoint, getClickPoint, createSvg, getInitialPoint } from './utils';
+import { getActualPoint, getClickPoint, createSvg, getInitialPoint, createElement } from './utils';
 import { onClickOutside, } from '../ui_utils'
 
 export default class Ink extends Base {
@@ -48,6 +48,7 @@ export default class Ink extends Base {
     this.onMouseup = this.handleMouseUp.bind(this)
     this.onMousemove = this.handleMouseMove.bind(this)
     this.onDelete = this.handleDelete.bind(this)
+    this.onOpenReply = this.openReply.bind(this)
     this.onKeydown = this.handleKeydown.bind(this)
     this.inkPath = []
     this.render()
@@ -112,7 +113,7 @@ export default class Ink extends Base {
     if (!this.hidden || this.layer.annotationStore.creating) return
     this.hidden = false
     this.updateTool()
-    onClickOutside([this.svgElement, this.outerLine, this.deletetButton], this.handleOutside.bind(this))
+    onClickOutside([this.svgElement, this.outerLine, this.deletetButton, this.replyButton], this.handleOutside.bind(this))
   }
 
   handleOutside () {
@@ -559,10 +560,11 @@ export default class Ink extends Base {
     this.outerLine.style.width = `${rect.width + 7 * 2}px`
     this.outerLine.style.height = `${rect.height + 7 * 2}px`
 
-    const left = (rect.left + rect.width - 30) + 'px'
-    const top = (rect.top + rect.height + 8) + 'px'
-    this.deletetButton.style.left = left
-    this.deletetButton.style.top = top
+    this.deletetButton.style.left = (rect.left + rect.width - 30) + 'px'
+    this.deletetButton.style.top = (rect.top + rect.height + 8) + 'px'
+
+    this.replyButton.style.left = (rect.left + rect.width - 65) + 'px'
+    this.replyButton.style.top = (rect.top + rect.height + 8) + 'px'
     
     this.moveRect.setAttribute('width',  rect.width + 8)
     this.moveRect.setAttribute('height',  rect.height + 8)
@@ -709,6 +711,26 @@ export default class Ink extends Base {
     const topPos = (rect.top + rect.height + 8) + 'px'
     this.deletetButton.style.left = leftPos
     this.deletetButton.style.top = topPos
+
+    this.replyButton = createElement(
+      'div',
+      {
+        width: '30px',
+        height: '30px',
+        background: '#DDE9FF',
+        borderRadius: '2px',
+        display: 'flex',
+        justifyContent: 'center',
+        alignItems: 'center'
+      },
+      {
+        class: 'reply-button'
+      }
+    );
+    this.replyButton.addEventListener('click', this.onOpenReply)
+    this.replyButton.innerHTML = this.replySvgStr
+    this.replyButton.style.left = (rect.left + rect.width - 65) + 'px'
+    this.replyButton.style.top = (rect.top + rect.height + 8) + 'px'
     
     const outerLine = document.createElementNS("http://www.w3.org/2000/svg", "svg")
     outerLine.style.position = 'absolute'
@@ -857,6 +879,11 @@ export default class Ink extends Base {
     this.outerLine.append(this.topRect)
     this.outerLineContainer.append(this.outerLine)
     this.outerLineContainer.append(this.deletetButton)
+    this.outerLineContainer.append(this.replyButton)
     this.initial = true
   }
+
+  openReply () {
+    this.eventBus.dispatch('openAnnotationReply', this.annotation)
+  }
 }

+ 35 - 10
packages/core/src/annotation/line.js

@@ -2,7 +2,7 @@ import { v4 as uuidv4 } from 'uuid';
 import Base from './base';
 import { hexToRgb, onClickOutside } from '../ui_utils'
 import { getAbsoluteCoordinate } from './position';
-import { getClickPoint, createSvg } from './utils';
+import { getClickPoint, createSvg, createElement } from './utils';
 import { MARGIN_DISTANCE } from '../../constants'
 import ArrowHelper from './arrow'
 
@@ -47,6 +47,7 @@ export default class Line extends Base {
     this.onMouseup = this.handleMouseUp.bind(this)
     this.onMousemove = this.handleMouseMove.bind(this)
     this.onDelete = this.handleDelete.bind(this)
+    this.onOpenReply = this.openReply.bind(this)
     this.onKeydown = this.handleKeydown.bind(this)
     this.render()
     
@@ -156,10 +157,28 @@ export default class Line extends Base {
     this.deletetButton.addEventListener('click', this.onDelete)
     this.deletetButton.innerHTML = this.deleteSvgStr
     
-    const left = (rect.left + rect.width + MARGIN_DISTANCE -30) + 'px'
-    const top = (rect.top - MARGIN_DISTANCE -30) + 'px'
-    this.deletetButton.style.left = left
-    this.deletetButton.style.top = top
+    this.deletetButton.style.left = (rect.left + rect.width + MARGIN_DISTANCE - 30) + 'px'
+    this.deletetButton.style.top = (rect.top - MARGIN_DISTANCE - 30) + 'px'
+
+    this.replyButton = createElement(
+      'div',
+      {
+        width: '30px',
+        height: '30px',
+        background: '#DDE9FF',
+        borderRadius: '2px',
+        display: 'flex',
+        justifyContent: 'center',
+        alignItems: 'center'
+      },
+      {
+        class: 'reply-button'
+      }
+    );
+    this.replyButton.addEventListener('click', this.onOpenReply)
+    this.replyButton.innerHTML = this.replySvgStr
+    this.replyButton.style.left = (rect.left + rect.width + MARGIN_DISTANCE - 65) + 'px'
+    this.replyButton.style.top = (rect.top - MARGIN_DISTANCE - 30) + 'px'
     
     const outerLine = document.createElementNS("http://www.w3.org/2000/svg", "svg");
     
@@ -215,6 +234,7 @@ export default class Line extends Base {
     this.outerLine.append(this.endCircle)
     this.outerLineContainer.append(this.outerLine)
     this.outerLineContainer.append(this.deletetButton)
+    this.outerLineContainer.append(this.replyButton)
     this.initial = true
   }
 
@@ -222,7 +242,7 @@ export default class Line extends Base {
     if (!this.hidden || this.layer.annotationStore.creating) return
     this.hidden = false
     this.updateTool()
-    onClickOutside([this.svgElement, this.outerLine, this.deletetButton], this.handleOutside.bind(this))
+    onClickOutside([this.svgElement, this.outerLine, this.deletetButton, this.replyButton], this.handleOutside.bind(this))
   }
 
   handleOutside () {
@@ -545,10 +565,11 @@ export default class Line extends Base {
     this.outerLine.style.top = `${rect.top - MARGIN_DISTANCE}px`
     this.outerLine.style.width = `${rect.width + MARGIN_DISTANCE * 2}px`
     this.outerLine.style.height = `${rect.height + MARGIN_DISTANCE * 2}px`
-    const left = (rect.left + rect.width + MARGIN_DISTANCE -30) + 'px'
-    const top = (rect.top - MARGIN_DISTANCE -30) + 'px'
-    this.deletetButton.style.left = left
-    this.deletetButton.style.top = top
+
+    this.deletetButton.style.left = (rect.left + rect.width + MARGIN_DISTANCE - 30) + 'px'
+    this.deletetButton.style.top = (rect.top - MARGIN_DISTANCE - 30) + 'px'
+    this.replyButton.style.left = (rect.left + rect.width + MARGIN_DISTANCE - 65) + 'px'
+    this.replyButton.style.top = (rect.top - MARGIN_DISTANCE - 30) + 'px'
     
     this.startCircle.setAttribute("cx", start.x + MARGIN_DISTANCE)
     this.startCircle.setAttribute("cy", start.y + MARGIN_DISTANCE)
@@ -600,4 +621,8 @@ export default class Line extends Base {
       }
     }
   }
+
+  openReply () {
+    this.eventBus.dispatch('openAnnotationReply', this.annotation)
+  }
 }

+ 34 - 10
packages/core/src/annotation/shape.js

@@ -1,6 +1,6 @@
 import Base from './base';
 import { MARGIN_DISTANCE } from '../../constants'
-import { getActualPoint, getClickPoint, createSvg } from './utils';
+import { getActualPoint, getClickPoint, createSvg, createElement } from './utils';
 import { hexToRgb, onClickOutside } from '../ui_utils'
 
 export default class Shape extends Base {
@@ -47,6 +47,7 @@ export default class Shape extends Base {
     this.onMouseup = this.handleMouseUp.bind(this)
     this.onMousemove = this.handleMouseMove.bind(this)
     this.onDelete = this.handleDelete.bind(this)
+    this.onOpenReply = this.openReply.bind(this)
     this.render()
   }
 
@@ -141,10 +142,28 @@ export default class Shape extends Base {
     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 + 8) + 'px'
-    this.deletetButton.style.left = left
-    this.deletetButton.style.top = top
+    this.deletetButton.style.left = (rect.left + rect.width - 30) + 'px'
+    this.deletetButton.style.top = (rect.top + rect.height + 8) + 'px'
+
+    this.replyButton = createElement(
+      'div',
+      {
+        width: '30px',
+        height: '30px',
+        background: '#DDE9FF',
+        borderRadius: '2px',
+        display: 'flex',
+        justifyContent: 'center',
+        alignItems: 'center'
+      },
+      {
+        class: 'reply-button'
+      }
+    );
+    this.replyButton.addEventListener('click', this.onOpenReply)
+    this.replyButton.innerHTML = this.replySvgStr
+    this.replyButton.style.left = (rect.left + rect.width - 65) + 'px'
+    this.replyButton.style.top = (rect.top + rect.height + 8) + 'px'
     
     const outerLine = document.createElementNS("http://www.w3.org/2000/svg", "svg");
 
@@ -294,6 +313,7 @@ export default class Shape extends Base {
     this.outerLine.append(this.topRect)
     this.outerLineContainer.append(this.outerLine)
     this.outerLineContainer.append(this.deletetButton)
+    this.outerLineContainer.append(this.replyButton)
     this.initial = true
   }
 
@@ -389,7 +409,7 @@ export default class Shape extends Base {
     if (!this.hidden || this.layer.annotationStore.creating) return
     this.hidden = false
     this.updateTool()
-    onClickOutside([this.svgElement, this.outerLine, this.deletetButton], this.handleOutside.bind(this))
+    onClickOutside([this.svgElement, this.outerLine, this.deletetButton, this.replyButton], this.handleOutside.bind(this))
   }
 
   handleOutside () {
@@ -657,10 +677,10 @@ export default class Shape extends Base {
     this.outerLine.style.width = `${rect.width + 7 * 2}px`
     this.outerLine.style.height = `${rect.height + 7 * 2}px`
 
-    const left = (rect.left + rect.width - 30) + 'px'
-    const top = (rect.top + rect.height + 8) + 'px'
-    this.deletetButton.style.left = left
-    this.deletetButton.style.top = top
+    this.deletetButton.style.left = (rect.left + rect.width - 30) + 'px'
+    this.deletetButton.style.top = (rect.top + rect.height + 8) + 'px'
+    this.replyButton.style.left = (rect.left + rect.width - 65) + 'px'
+    this.replyButton.style.top = (rect.top + rect.height + 8) + 'px'
     
     this.moveRect.setAttribute('width',  rect.width + 8)
     this.moveRect.setAttribute('height',  rect.height + 8)
@@ -686,4 +706,8 @@ export default class Shape extends Base {
 
     this.topRect.setAttribute("x", rect.width / 2 + 4)
   }
+
+  openReply () {
+    this.eventBus.dispatch('openAnnotationReply', this.annotation)
+  }
 }

+ 33 - 9
packages/core/src/annotation/stamp.js

@@ -49,6 +49,7 @@ export default class Stamp extends Base {
     this.onMouseup = this.handleMouseUp.bind(this)
     this.onMousemove = this.handleMouseMove.bind(this)
     this.onDelete = this.handleDelete.bind(this)
+    this.onOpenReply = this.openReply.bind(this)
     this.render()
   }
 
@@ -170,10 +171,28 @@ export default class Stamp extends Base {
     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 + 8) + 'px'
-    this.deletetButton.style.left = left
-    this.deletetButton.style.top = top
+    this.deletetButton.style.left = (rect.left + rect.width - 30) + 'px'
+    this.deletetButton.style.top = (rect.top + rect.height + 8) + 'px'
+
+    this.replyButton = createElement(
+      'div',
+      {
+        width: '30px',
+        height: '30px',
+        background: '#DDE9FF',
+        borderRadius: '2px',
+        display: 'flex',
+        justifyContent: 'center',
+        alignItems: 'center'
+      },
+      {
+        class: 'reply-button'
+      }
+    );
+    this.replyButton.addEventListener('click', this.onOpenReply)
+    this.replyButton.innerHTML = this.replySvgStr
+    this.replyButton.style.left = (rect.left + rect.width - 65) + 'px'
+    this.replyButton.style.top = (rect.top + rect.height + 8) + 'px'
     
     const outerLine = document.createElementNS("http://www.w3.org/2000/svg", "svg");
 
@@ -263,6 +282,7 @@ export default class Stamp extends Base {
     this.outerLine.append(this.topRightRect)
     this.outerLineContainer.append(this.outerLine)
     this.outerLineContainer.append(this.deletetButton)
+    this.outerLineContainer.append(this.replyButton)
     this.initial = true
 
     this.eventBus.dispatch('imageChange')
@@ -364,7 +384,7 @@ export default class Stamp extends Base {
     if (!this.hidden || this.layer.annotationStore.creating) return
     this.hidden = false
     this.updateTool()
-    onClickOutside([this.annotationContainer, this.outerLine, this.deletetButton], this.handleOutside.bind(this))
+    onClickOutside([this.annotationContainer, this.outerLine, this.deletetButton, this.replyButton], this.handleOutside.bind(this))
   }
 
   handleOutside () {
@@ -625,10 +645,10 @@ export default class Stamp extends Base {
     this.outerLine.style.width = `${rect.width + 7 * 2}px`
     this.outerLine.style.height = `${rect.height + 7 * 2}px`
 
-    const left = (rect.left + rect.width - 30) + 'px'
-    const top = (rect.top + rect.height + 8) + 'px'
-    this.deletetButton.style.left = left
-    this.deletetButton.style.top = top
+    this.deletetButton.style.left = (rect.left + rect.width - 30) + 'px'
+    this.deletetButton.style.top = (rect.top + rect.height + 8) + 'px'
+    this.replyButton.style.left = (rect.left + rect.width - 65) + 'px'
+    this.replyButton.style.top = (rect.top + rect.height + 8) + 'px'
     
     this.moveRect.setAttribute('width',  rect.width + 8)
     this.moveRect.setAttribute('height',  rect.height + 8)
@@ -640,4 +660,8 @@ export default class Stamp extends Base {
     
     this.topRightRect.setAttribute("x", rect.width + 8)
   }
+
+  openReply () {
+    this.eventBus.dispatch('openAnnotationReply', this.annotation)
+  }
 }

+ 23 - 1
packages/core/src/annotation/text.js

@@ -1,5 +1,5 @@
 import Base from './base';
-import { getActualPoint, getClickPoint, createSvg, getHtmlToText, keepLastIndex, getInitialPoint } from './utils';
+import { getActualPoint, getClickPoint, createSvg, getHtmlToText, keepLastIndex, getInitialPoint, createElement } from './utils';
 import { onClickOutside } from '../ui_utils'
 
 export default class Text extends Base {
@@ -49,6 +49,7 @@ export default class Text extends Base {
     this.onClick = this.handleClick.bind(this)
     this.onBlur = this.handleBlur.bind(this)
     this.onDelete = this.handleDelete.bind(this)
+    this.onOpenReply = this.openReply.bind(this)
 
     this.onMousedown = this.handleMouseDown.bind(this)
     this.onMousemove = this.handleMouseMove.bind(this)
@@ -445,5 +446,26 @@ export default class Text extends Base {
     textEditorContainer.append(deletetButton)
     this.deletetButton.addEventListener('click', this.onDelete)
     this.eventBus._on('closeTextEditor', this.handleClickOutside.bind(this))
+
+    this.replyButton = createElement(
+      'div',
+      {
+        position: 'absolute',
+        right: '40px',
+        bottom: '10px',
+        width: '20px',
+        height: '20px'
+      },
+      {
+        class: 'reply-button'
+      }
+    );
+    this.replyButton.innerHTML = this.replySvgStr
+    textEditorContainer.append(this.replyButton)
+    this.replyButton.addEventListener('click', this.onOpenReply)
+  }
+
+  openReply () {
+    this.eventBus.dispatch('openAnnotationReply', this.annotation)
   }
 }

+ 0 - 1
packages/core/src/editor/image_editor.js

@@ -434,7 +434,6 @@ export class ImageEditor {
         start: this.newStart,
         end: this.newEnd
       }
-      console.log(oldPoint, newPoint)
   
       const { pageX, pageY } = getClickPoint(e)
       if (!(pageX === this.startState?.clickX && pageY === this.startState?.clickY) && this.newStart && this.newEnd) {

+ 99 - 47
packages/core/src/index.js

@@ -18,6 +18,7 @@ import { InkSign } from "./ink_sign"
 import MessageHandler from "./message_handler"
 import JSZip from 'jszip'
 import Outline from './Outline'
+import { v4 as uuidv4 } from 'uuid';
 
 GlobalWorkerOptions.workerSrc = './lib/pdf.worker.min.js'
 const CMAP_URL = './cmaps/'
@@ -486,8 +487,7 @@ class ComPDFKitViewer {
                 if (resp.code === "200") {
                   const data = resp.data
                   this._pdfId = data.pdfId
-                  // const rawAnnotations = data.annotateJson && JSON.parse(data.annotateJson)
-                  const rawAnnotations = data.annotateJson && data.annotateJson
+                  const rawAnnotations = data.annotateJson && JSON.parse(data.annotateJson)
                   annotations = this.convertAnnotation(rawAnnotations)
                 } else if (resp.code === "319") {
   
@@ -560,11 +560,8 @@ class ComPDFKitViewer {
   convertAnnotation(rawAnnotations) {
     const annotations = []
 
-    const rawAnnotationsData = JSON.parse(rawAnnotations)
-
-    console.log(rawAnnotationsData)
-    for (const type in rawAnnotationsData) {
-      let annotationData = rawAnnotationsData[type]
+    for (const type in rawAnnotations) {
+      let annotationData = rawAnnotations[type]
       if (!Array.isArray(annotationData)) {
         annotationData = [annotationData]
       }
@@ -680,15 +677,17 @@ class ComPDFKitViewer {
   }
 
   #formatAnnotation(rawAnnotation) {
-    console.log(rawAnnotation)
     const annotation = {}
     annotation.type = rawAnnotation.type
     rawAnnotation.rect && (annotation.rect = this.#formatRect(rawAnnotation))
     annotation.date = parseAdobePDFTimestamp(rawAnnotation.creationdate)
     annotation.pageIndex = Number(rawAnnotation.page)
-    annotation.index && (annotation.index = Number(rawAnnotation.index))
+    annotation.index = Number(rawAnnotation.index)
     annotation.contents = rawAnnotation.content || ''
     annotation.opacity = round(rawAnnotation.opacity, 2) || 1
+    rawAnnotation.replies && (annotation.replies = rawAnnotation.replies)
+    rawAnnotation.markedAnnotState && (annotation.markedAnnotState = AnnotationStateString[rawAnnotation.markedAnnotState])
+    rawAnnotation.reviewAnnotState && (annotation.reviewAnnotState = AnnotationStateString[rawAnnotation.reviewAnnotState])
 
     switch (rawAnnotation.type) {
       case 'freetext':
@@ -837,6 +836,17 @@ class ComPDFKitViewer {
       }
     }
 
+    if (annotation.replies?.length) {
+      for (const reply of annotation.replies) {
+        reply.name = uuidv4()
+        reply.contents = reply.content
+        delete reply.content
+        reply.date = parseAdobePDFTimestamp(rawAnnotation.recentlyModifyDate || rawAnnotation.date || rawAnnotation.creationdate)
+        reply.pageIndex = Number(rawAnnotation.page)
+        delete reply.page
+      }
+    }
+
     return annotation
   }
 
@@ -977,6 +987,7 @@ class ComPDFKitViewer {
             typeInt,
             scale: this.scale
           })
+          if (attr && attr.replies && attr.replies.length) attr.replies.forEach(reply => reply.name = uuidv4())
         }
         Object.assign(annotation, attr)
         annotations.push(annotation)
@@ -1021,6 +1032,7 @@ class ComPDFKitViewer {
             typeInt,
             scale: this.scale
           })
+          if (attr && attr.replies && attr.replies.length) attr.replies.forEach(reply => reply.name = uuidv4())
         }
         Object.assign(annotation, attr)
         annotations.push(annotation)
@@ -1454,7 +1466,7 @@ class ComPDFKitViewer {
       if (!annotations) return
       const index = findIndex(annotation.name, annotations) 
       if (this.webviewerServer) {
-        annotation.index = index + 1
+        annotation.index = index
       }
       if (data.type === 'delete') {
         annotations.splice(index, 1)
@@ -1619,6 +1631,11 @@ class ComPDFKitViewer {
           'destPage' in annotation && (annotation.destPage = annotation.destPage - 1)
           delete annotation.color
           break
+        case 'reply':
+          annotation.page = annotation.pageIndex
+          annotation.targetPage = annotation.pageIndex + 1
+          delete annotation.pageIndex
+          break
       }
 
       if (annotation.operate === 'mod-form') {
@@ -1629,6 +1646,9 @@ class ComPDFKitViewer {
 
       for (let key in annotation) {
         switch (key) {
+          case 'index':
+            annotation.index += 1
+            break
           case 'rect':
             annotation.rect = this.#formatRectForBackend(annotation.rect, annotation.pageIndex)
             break
@@ -1715,6 +1735,23 @@ class ComPDFKitViewer {
               annotation.title && (annotation.value = annotation.title)
               delete annotation.title
             }
+            break
+          case 'replies':
+            for (const reply of annotation.replies) {
+              reply.page = reply.pageIndex
+              reply.targetPage = reply.pageIndex + 1
+              delete reply.pageIndex
+            }
+            break
+          case 'markedAnnotState':
+            annotation.markedAnnotState = AnnotationState[annotation.markedAnnotState]
+            break
+          case 'reviewAnnotState':
+            annotation.reviewAnnotState = AnnotationState[annotation.reviewAnnotState]
+            break
+          case 'repliesIndex':
+            annotation.repliesIndex += 1
+            break
         }
       }
     }
@@ -3310,7 +3347,7 @@ class ComPDFKitViewer {
     let annotateHandles = []
     
     if (type === 'add') {
-      if (reply) {
+      if (reply) { // 添加回复
         if (!this.webviewerServer) {
           await this.messageHandler.sendWithPromise('CreateReplyAnnotation', {
             pagePtr,
@@ -3321,24 +3358,30 @@ class ComPDFKitViewer {
             pagePtr,
             annotPtr
           })
-          if (replies.length) annot.replies = replies
+          if (replies.length) {
+            replies.forEach(reply => reply.name = uuidv4())
+            annot.replies = replies
+          }
           if (reviewAnnotState) annot.reviewAnnotState = reviewAnnotState
-          if (reviewAnnotState) annot.markedAnnotState = markedAnnotState
+          if (markedAnnotState) annot.markedAnnotState = markedAnnotState
         } else {
-          console.log(annot, reply)
+          const index = findIndex(annot.name, this.annotations[annotation.pageIndex])
+          annot.index = index
+          
           annotateHandles.push({
             operate: 'add-annot',
             type: 'reply',
+            pageIndex: annot.pageIndex,
             title: reply.author,
             content: reply.contents,
             date: reply.date,
-            page: annot.pageIndex,
-            index: annot.pageIndex + 1,
-            targetPage: annot.pageIndex + 1,
+            index: annot.index
           })
+          if (!annot.replies) annot.replies = []
+          annot.replies.push(reply)
         }
       }
-      if (reviewAnnotState || markedAnnotState) {
+      if (reviewAnnotState || markedAnnotState) { // 添加有状态回复(首次设置注释状态)
         if (!this.webviewerServer) {
           const newState = await this.messageHandler.sendWithPromise('CreateReplyStateAnnotation', {
             pagePtr,
@@ -3350,9 +3393,9 @@ class ComPDFKitViewer {
         } else {
           const data = {
             operate: 'mod-annot',
-            page: annot.pageIndex,
-            index: annot.pageIndex + 1,
-            targetPage: annot.pageIndex + 1,
+            index: annot.index,
+            pageIndex: annot.pageIndex,
+            targetPage: annot.pageIndex + 1
           }
           if (markedAnnotState) {
             annot.markedAnnotState = markedAnnotState
@@ -3372,23 +3415,23 @@ class ComPDFKitViewer {
           annotPtr: reply.annotPtr
         })
       } else {
-        console.log(reply)
         annotateHandles.push({
           operate: 'del-annot',
-          name: reply.name,
-          index: reply.index,
-          targetPage: reply.pageIndex + 1,
-          pageIndex: reply.pageIndex
+          index: annot.index,
+          repliesIndex: reply.index,
+          pageIndex: reply.pageIndex,
+          targetPage: reply.pageIndex + 1
         })
       }
-      annot.replies = annot.replies.filter(obj => obj.annotPtr !== reply.annotPtr)
-
-      let replyOri = JSON.parse(JSON.stringify(reply))
-      console.log(replyOri)
-      annotateHandles.push(replyOri)
+      annot.replies = annot.replies.filter(item => item.name !== reply.name)
+      annot.replies.forEach(item => {
+        if (item.index > reply.index) {
+          item.index--
+        }
+      })
 
     } else if (type === 'edit') {
-      if (markedAnnotState || reviewAnnotState) {
+      if (markedAnnotState || reviewAnnotState) { // 修改注释状态
         if (!this.webviewerServer) {
           const newState = await this.messageHandler.sendWithPromise('SetState', {
             reviewAnnotState,
@@ -3396,31 +3439,41 @@ class ComPDFKitViewer {
           })
           if (markedAnnotState) {
             annot.markedAnnotState = newState
-          } else if (reviewAnnotState) {
+          }
+          if (reviewAnnotState) {
             annot.reviewAnnotState = newState
           }
+        } else {
+          if (markedAnnotState) {
+            annot.markedAnnotState = markedAnnotState
+          }
+          if (reviewAnnotState) {
+            annot.reviewAnnotState = reviewAnnotState
+          }
+          let postData = JSON.parse(JSON.stringify(annot))
+          annotateHandles.push(postData)
         }
-
-        let annotOri = JSON.parse(JSON.stringify(annot))
-        console.log(annotOri)
-        annotateHandles.push(annotOri)
         
-      } else {
+      } else { // 修改回复内容
         if (!this.webviewerServer) {
           this.messageHandler.sendWithPromise('EditReplyAnnotation', {
             reply
           })
         }
-        const index = annot.replies.findIndex(obj => obj.annotPtr === reply.annotPtr)
+        const index = annot.replies.findIndex(obj => obj.name === reply.name)
         const replyItem = annot.replies[index]
         annot.replies[index] = {
           ...replyItem,
           ...reply
         }
-
-        let replyOri = JSON.parse(JSON.stringify(reply))
-        console.log(replyOri)
-        annotateHandles.push(replyOri)
+        annotateHandles.push({
+          operate: 'mod-annot',
+          index: annot.index,
+          repliesIndex: reply.index,
+          pageIndex: reply.pageIndex,
+          targetPage: reply.pageIndex + 1,
+          content: reply.contents
+        })
       }
 
       // const res = await this.messageHandler.sendWithPromise('GetReplyAnnotation', {
@@ -3435,13 +3488,12 @@ class ComPDFKitViewer {
     this.eventBus.dispatch('annotationChanged', { annotations: this.annotations })
 
     if (this.webviewerServer) {
-      console.log(annotateHandles)
-      // this.#convertAnnotationsForBackend(annotateHandles)
-      // this.#postAnnotations(annotateHandles)
+      this.#convertAnnotationsForBackend(annotateHandles)
+      this.#postAnnotations(annotateHandles)
     }
   }
 
-  async #postAnnotations (annotateHandles) {
+  async #postAnnotations(annotateHandles) {
     const options = {
       method: 'POST',
       headers: {

+ 29 - 6
packages/core/src/markup/text_annotation.js

@@ -1,6 +1,6 @@
 import BaseAnnotation from '../annotation/base'
 import { hexToRgb, onClickOutside } from '../ui_utils'
-import { createSvg } from '../annotation/utils'
+import { createSvg, createElement } from '../annotation/utils'
 import { getActualPoint } from '../annotation/utils'
 import Color from '../color';
 
@@ -31,6 +31,7 @@ class TextAnnotation extends BaseAnnotation {
 
     this.onHandleClick = this.handleClick.bind(this)
     this.onDelete = this.handleDelete.bind(this)
+    this.onOpenReply = this.openReply.bind(this)
 
     this.render()
   }
@@ -121,15 +122,33 @@ class TextAnnotation extends BaseAnnotation {
     )
     deletetButton.addEventListener('click', this.onDelete)
     deletetButton.innerHTML = this.deleteSvgStr
+    deletetButton.style.left = (rect.left + rect.width - 30) + 'px'
+    deletetButton.style.top = (rect.top - 38) + 'px'
 
-    const left = (rect.left + rect.width - 30) + 'px'
-    const top = (rect.top - 38) + 'px'
-    deletetButton.style.left = left
-    deletetButton.style.top = top
+    this.replyButton = createElement(
+      'div',
+      {
+        width: '30px',
+        height: '30px',
+        background: '#DDE9FF',
+        borderRadius: '2px',
+        display: 'flex',
+        justifyContent: 'center',
+        alignItems: 'center'
+      },
+      {
+        class: 'reply-button'
+      }
+    );
+    this.replyButton.addEventListener('click', this.onOpenReply)
+    this.replyButton.innerHTML = this.replySvgStr
+    this.replyButton.style.left = (rect.left + rect.width - 65) + 'px'
+    this.replyButton.style.top = (rect.top - 38) + 'px'
 
     this.deletetButton = deletetButton
     this.outerLineContainer.append(rectContainer)
     this.outerLineContainer.append(deletetButton)
+    this.outerLineContainer.append(this.replyButton)
   }
 
   getActualRect (viewport, s) {
@@ -183,7 +202,7 @@ class TextAnnotation extends BaseAnnotation {
     if (!this.hidden) return
     this.hidden = false
     this.updateTool()
-    onClickOutside([this.markupContainer, this.outerLineContainer, this.deletetButton], this.handleOutside.bind(this))
+    onClickOutside([this.markupContainer, this.outerLineContainer, this.deletetButton, this.replyButton], this.handleOutside.bind(this))
   }
 
   handleOutside () {
@@ -383,6 +402,10 @@ class TextAnnotation extends BaseAnnotation {
     this.container.append(markupContainer)
     this.markupContainer = markupContainer
   }
+  
+  openReply () {
+    this.eventBus.dispatch('openAnnotationReply', this.annotation)
+  }
 }
 
 export default TextAnnotation

+ 5 - 1
packages/webview/src/assets/main.scss

@@ -232,7 +232,8 @@ input, textarea, select {
       border: 1px solid #1460F3;
     }
   }
-  > svg {
+  > svg,
+  >.reply-button {
     width: 100%;
     height: 100%;
     position: absolute;
@@ -299,6 +300,9 @@ input, textarea, select {
     cursor: n-resize;
   }
 }
+.annotationContainer > .outline-container .reply-button {
+  pointer-events: auto;
+}
 .point-none {
   pointer-events: auto;
 }

+ 118 - 45
packages/webview/src/components/AnnotationContainer/AnnotationContent.vue

@@ -8,7 +8,7 @@
             <span>{{ pageAnnotations.pageAnnotationsCount }}</span>
           </div>
           <template v-for="(item, index) in pageAnnotations.annotations">
-            <div v-if="!item.isDelete && item.type !== 'link'" class="annotation-item" @click="goToPage(item)" :class="{ 'selected': selectedAnnot && selectedAnnot.name === item.name }">
+            <div v-if="!['link', 'reply'].includes(item.type) && !item.isDelete" class="annotation-item" @click="goToPage(item)" :class="{ 'selected': selectedAnnot && selectedAnnot.name === item.name }">
               <div class="item-header">
                 <Highlight v-if="item.type === 'highlight'" />
                 <Squiggle v-else-if="item.type === 'squiggly'" />
@@ -32,7 +32,7 @@
                 </div>
                 <n-popover trigger="hover" placement="bottom" class="mark-popover">
                   <template #trigger>
-                    <div class="mark-box" :class="{ 'marked': item.markedAnnotState?.state === 'MARKED' }" @click.stop="setMarkedState(item)"></div>
+                    <div class="mark-box" :class="{ 'marked': item.markedAnnotState === 'MARKED' || item.markedAnnotState?.state === 'MARKED' }" @click.stop="setMarkedState(item)"></div>
                   </template>
                   <span>Marked</span>
                 </n-popover>
@@ -40,7 +40,7 @@
 
               <div class="item-reply" v-if="selectedAnnot && selectedAnnot.name === item.name && (showReplyInput || item.replies?.length)">
                 <div v-if="showReplyInput">
-                  <textarea type="text" placeholder="Reply or add thoghts" v-model="replyText"></textarea>
+                  <textarea placeholder="Reply or add thoughts" v-model="replyText" class="annotReplyInput"></textarea>
                   <div class="buttons">
                     <span @click.stop="cancel">Cancel</span>
                     <button :class="{ 'active': replyText }" @click="addReply">Reply</button>
@@ -51,18 +51,18 @@
                   <div class="info" v-for="(reply, index) in item.replies">
                     <div class="info-header">
                       <div>
-                        <p class="name">{{ reply.author }}</p>
+                        <p class="name">{{ reply.author || reply.title || 'Guest' }}</p>
                         <p class="date">{{ dayjs(reply.date).format('DD/MM/YYYY HH:mm:ss') }}</p>
                       </div>
-                      <Button class="more" :class="{ active: showReplyPopover && reply.annotPtr === selectedReply?.annotPtr }" @click="handleReplyPopoverShow($event, reply)">
+                      <Button class="more" :class="{ active: showReplyPopover && reply.name === selectedReply?.name }" @click="handleReplyPopoverShow($event, reply)">
                         <MoreA />
                       </Button>
                     </div>
-                    <div class="content" v-if="editing && reply.annotPtr === selectedReply?.annotPtr">
-                      <textarea v-model="editingReplyText"></textarea>
+                    <div class="content" v-if="editing && reply.name === selectedReply?.name">
+                      <textarea v-model="editingReplyText" class="replyEditInput"></textarea>
                       <div class="buttons">
                         <span @click="cancelEdit">Cancel</span>
-                        <button :class="{ 'active': editingReplyText }" @click="editReply">Confirm</button>
+                        <button :class="{ 'active': editingReplyText && editingReplyText !== reply.contents }" @click="editReply">Confirm</button>
                       </div>
                     </div>
                     <div class="content" v-else>{{ reply.contents }}</div>
@@ -89,7 +89,7 @@
                 </n-popover>
               </div>
 
-              <div class="item-footer" v-if="selectedAnnot && selectedAnnot.name === item.name">
+              <div class="item-footer">
                 <n-popover
                   placement="bottom"
                   trigger="click"
@@ -98,30 +98,30 @@
                   :raw="true"
                   :z-index="96"
                   class="state-popover"
-                  :show="showPopover"
-                  @update:show="handlePopoverShow"
+                  :show="showPopover === item.name"
+                  @clickoutside="onOutsidePopover"
                 >
                   <template #trigger>
-                    <Button :class="{ active: showPopover }">
-                      <Accepted v-if="item.reviewAnnotState?.state === 'ACCEPTED'" />
-                      <Rejected v-else-if="item.reviewAnnotState?.state === 'REJECTED'" />
-                      <Cancelled v-else-if="item.reviewAnnotState?.state === 'CANCELLED'" />
-                      <Completed v-else-if="item.reviewAnnotState?.state === 'COMPLETED'" />
+                    <Button class="state" :class="{ active: showPopover === item.name }" @click.stop="handlePopoverShow(item)">
+                      <Accepted v-if="item.reviewAnnotState === 'ACCEPTED' || item.reviewAnnotState?.state === 'ACCEPTED'" />
+                      <Rejected v-else-if="item.reviewAnnotState === 'REJECTED' || item.reviewAnnotState?.state === 'REJECTED'" />
+                      <Cancelled v-else-if="item.reviewAnnotState === 'CANCELLED' || item.reviewAnnotState?.state === 'CANCELLED'" />
+                      <Completed v-else-if="item.reviewAnnotState === 'COMPLETED' || item.reviewAnnotState?.state === 'COMPLETED'" />
                       <None v-else />
                     </Button>
                   </template>
                   <div class="drop-down">
-                    <div class="drop-item" @click="setReviewState('ACCEPTED')"><Accepted />Accepted</div>
-                    <div class="drop-item" @click="setReviewState('REJECTED')"><Rejected />Rejected</div>
-                    <div class="drop-item" @click="setReviewState('CANCELLED')"><Cancelled />Cancelled</div>
-                    <div class="drop-item" @click="setReviewState('COMPLETED')"><Completed />Completed</div>
-                    <div class="drop-item" @click="setReviewState('NONE')"><None />None</div>
+                    <div class="drop-item" @click="setReviewState(item, 'ACCEPTED')"><Accepted />Accepted</div>
+                    <div class="drop-item" @click="setReviewState(item, 'REJECTED')"><Rejected />Rejected</div>
+                    <div class="drop-item" @click="setReviewState(item, 'CANCELLED')"><Cancelled />Cancelled</div>
+                    <div class="drop-item" @click="setReviewState(item, 'COMPLETED')"><Completed />Completed</div>
+                    <div class="drop-item" @click="setReviewState(item, 'NONE')"><None />None</div>
                   </div>
                 </n-popover>
                 <div class="re">
                   <Reply />
                   <span v-if="item.replies?.length">{{ item.replies.length }}</span>
-                  <Return class="return" @click.stop="showReplyInput = true" />
+                  <Return class="return" @click="selectReturn(item)" />
                 </div>
               </div>
             </div>
@@ -154,18 +154,17 @@ const selectedAnnot = ref(null)
 const selectedReply = ref(null)
 const showReplyInput = ref(false)
 const replyText = ref('')
-const markedAnnotState = ref('UNMARKED')
-const reviewAnnotState = ref('Accepted')
 const editing = ref(false)
 const editingReplyText = ref('')
 
-const showPopover = ref(false)
+const showPopover = ref('')
 const showReplyPopover = ref(false)
 const x = ref(0)
 const y = ref(0)
+let timeout = null
 
 watch([activePanelTab, isOpen], (oldValue, newValue) => {
-  if (newValue[0] === 'ANNOTATION' || newValue[1]) cancel()
+  if (newValue[0] === 'ANNOTATION' && newValue[1]) cancel()
 })
 
 const setAnnotationList = ({ annotations }) => {
@@ -195,30 +194,34 @@ const cancel = () => {
 }
 
 // 设置状态
-const setReviewState = (state) => {
+const setReviewState = (item, state) => {
   let data
-  if (selectedAnnot.value.reviewAnnotState) {
+  if (item.reviewAnnotState) {
     data = {
       type: 'edit',
-      annotation: selectedAnnot.value,
-      reviewAnnotState: {
-        annotPtr: selectedAnnot.value.reviewAnnotState.annotPtr,
+      annotation: item,
+      reviewAnnotState: item.reviewAnnotState.annotPtr ? {
+        annotPtr: item.reviewAnnotState.annotPtr,
         state
-      }
+      } : state
     }
   } else {
     data = {
       type: 'add',
-      annotation: selectedAnnot.value,
+      annotation: item,
       reviewAnnotState: state
     }
   }
   core.handleReplyAnnotation(data)
-  showPopover.value = false
+  showPopover.value = ''
 }
 
-const handlePopoverShow = () => {
-  showPopover.value = !showPopover.value
+const handlePopoverShow = (item) => {
+  if (showPopover.value) {
+    showPopover.value = ''
+  } else {
+    showPopover.value = item.name
+  }
 }
 
 const handleReplyPopoverShow = (e, reply) => {
@@ -241,21 +244,21 @@ const handleReplyPopoverShow = (e, reply) => {
 }
 
 // 标记状态
-const setMarkedState = (item) => {
+const setMarkedState = (annotation) => {
   let data
-  if (item.markedAnnotState) {
+  if (annotation.markedAnnotState) {
     data = {
       type: 'edit',
-      annotation: item,
-      markedAnnotState: {
-        annotPtr: item.markedAnnotState.annotPtr,
-        state: item.markedAnnotState.state === 'MARKED' ? 'UNMARKED' : 'MARKED'
-      }
+      annotation,
+      markedAnnotState: annotation.markedAnnotState.annotPtr ? {
+        annotPtr: annotation.markedAnnotState.annotPtr,
+        state: annotation.markedAnnotState.state === 'MARKED' ? 'UNMARKED' : 'MARKED'
+      } : (annotation.markedAnnotState === 'MARKED' ? 'UNMARKED' : 'MARKED')
     }
   } else {
     data = {
       type: 'add',
-      annotation: item,
+      annotation,
       markedAnnotState: 'MARKED'
     }
   }
@@ -268,9 +271,14 @@ const addReply = () => {
     type: 'add',
     annotation: selectedAnnot.value,
     reply: {
+      operate: 'add-annot',
+      type: 'reply',
       author: 'Guest',
+      title: 'Guest',
       contents: replyText.value,
-      date: new Date()
+      date: new Date(),
+      pageIndex: selectedAnnot.value.pageIndex,
+      index: selectedAnnot.value.replies?.length || 1
     }
   }
   core.handleReplyAnnotation(data)
@@ -285,6 +293,7 @@ const editReply = () => {
       annotation: selectedAnnot.value,
       reply: {
         ...selectedReply.value,
+        operate: 'mod-annot',
         contents: editingReplyText.value
       }
     }
@@ -293,6 +302,7 @@ const editReply = () => {
   } else {
     editing.value = true
     editingReplyText.value = selectedReply.value.contents
+    setTimeout(() => document.querySelector('.replyEditInput').focus())
   }
   
   showReplyPopover.value = false
@@ -326,6 +336,53 @@ const cancelEdit = () => {
   editingReplyText.value = ''
   selectedReply.value = null
 }
+
+const onOutsidePopover = (e) => {
+  const button = document.querySelector('.button.state.active')
+  if (!button.contains(e.target)) {
+    showPopover.value = ''
+  }
+}
+
+const selectReturn = (item) => {
+  showReplyInput.value = true
+
+  if (selectedAnnot.value?.name !== item.name) {
+    selectedAnnot.value = item
+    replyText.value = ''
+  }
+
+  setTimeout(() => document.querySelector('.annotReplyInput')?.focus())
+}
+
+const openAnnotationReply = (annotation) => {
+  if (activePanelTab.value !== 'ANNOTATION') {
+    useViewer.setActiveElementTab('leftPanelTab', 'ANNOTATION')
+  }
+  if (!isOpen.value) {
+    useViewer.openElement('leftPanel')
+    core.toggleSidebar()
+  }
+
+  selectReturn(annotation)
+
+  if (timeout) {
+    clearTimeout(timeout)
+    timeout = null
+  }
+  setTimeout(() => {
+    const div = document.querySelector('.annotation-item.selected')
+    if (!div.classList.contains('flash-border')) {
+      div.classList.add('flash-border')
+      timeout = setTimeout(() => document.querySelector('.annotation-item.selected').classList.remove('flash-border'), 2000)
+    } else {
+      div.classList.remove('flash-border')
+      clearTimeout(timeout)
+      timeout = null
+    }
+  })
+}
+core.addEvent('openAnnotationReply', openAnnotationReply)
 </script>
 
 <style lang="scss">
@@ -591,4 +648,20 @@ const cancelEdit = () => {
     }
   }
 }
+
+@keyframes flash {
+
+  0%,
+  100% {
+    border-color: transparent;
+  }
+
+  50% {
+    border-color: #1460F3;
+  }
+}
+
+.flash-border {
+  animation: flash 1s 2;
+}
 </style>