瀏覽代碼

Merge branch 'feature/webassembly'

liutian 1 年之前
父節點
當前提交
0b58d8f397
共有 42 個文件被更改,包括 2722 次插入64 次删除
  1. 1 1
      packages/core/src/editor/text_editor.js
  2. 437 5
      packages/core/src/index.js
  3. 0 2
      packages/core/src/pdf_page_view.js
  4. 1 0
      packages/core/src/pdf_viewer.js
  5. 207 9
      packages/core/src/worker/compdfkit_worker.js
  6. 45 1
      packages/webview/locales/en.json
  7. 45 1
      packages/webview/locales/zh-CN.json
  8. 1 0
      packages/webview/package.json
  9. 二進制
      packages/webview/public/example/Number Pages.pdf
  10. 17 12
      packages/webview/src/assets/base.css
  11. 7 0
      packages/webview/src/components/App/index.vue
  12. 1 1
      packages/webview/src/components/CompareButtons/CompareButtons.vue
  13. 4 0
      packages/webview/src/components/CompareDocumentContainer/CompareDocumentContainer.vue
  14. 71 0
      packages/webview/src/components/Dialogs/DeletePageDialog.vue
  15. 1 0
      packages/webview/src/components/Dialogs/Dialog.vue
  16. 295 0
      packages/webview/src/components/Dialogs/ExtractPageSettingDialog.vue
  17. 415 0
      packages/webview/src/components/Dialogs/InsertPageSettingDialog.vue
  18. 147 0
      packages/webview/src/components/Dialogs/MovePageSettingDialog.vue
  19. 6 6
      packages/webview/src/components/Dialogs/PreventDialog.vue
  20. 22 7
      packages/webview/src/components/DocumentContainer/DocumentContainer.vue
  21. 761 0
      packages/webview/src/components/DocumentEditorContainer/DocumentEditorContainer.vue
  22. 74 0
      packages/webview/src/components/DocumentEditorHeader/DocumentEditorHeader.vue
  23. 14 8
      packages/webview/src/components/HeaderItems/HeaderItems.vue
  24. 7 0
      packages/webview/src/components/Icon/CopyPage.vue
  25. 7 0
      packages/webview/src/components/Icon/DeletePage.vue
  26. 17 0
      packages/webview/src/components/Icon/DocumentPage.vue
  27. 7 0
      packages/webview/src/components/Icon/ExtractPage.vue
  28. 6 0
      packages/webview/src/components/Icon/FileFolder.vue
  29. 7 0
      packages/webview/src/components/Icon/InsertPage.vue
  30. 7 0
      packages/webview/src/components/Icon/MovePage.vue
  31. 7 0
      packages/webview/src/components/Icon/ReplacePage.vue
  32. 7 0
      packages/webview/src/components/Icon/RotatePageLeft.vue
  33. 7 0
      packages/webview/src/components/Icon/RotatePageRight.vue
  34. 7 0
      packages/webview/src/components/Icon/SelectAll.vue
  35. 7 0
      packages/webview/src/components/Icon/UnselectAll.vue
  36. 3 3
      packages/webview/src/components/Toolbar/Toolbar.vue
  37. 1 1
      packages/webview/src/core/checkPassword.js
  38. 4 0
      packages/webview/src/core/getDocEditorPages.js
  39. 5 1
      packages/webview/src/core/index.js
  40. 4 0
      packages/webview/src/core/saveDocumentEdit.js
  41. 23 3
      packages/webview/src/stores/modules/document.js
  42. 17 3
      packages/webview/src/stores/modules/viewer.js

+ 1 - 1
packages/core/src/editor/text_editor.js

@@ -888,7 +888,7 @@ export class TextEditor {
     if (this.isFirefox) {
       if (e.inputType === 'insertLineBreak') {
         data = '\n'
-      } else if (this.composing) {
+      } else if (this.composing || data === null) {
         return
       }
     }

+ 437 - 5
packages/core/src/index.js

@@ -342,6 +342,7 @@ class ComPDFKitViewer {
     annotationStore.annotationsAll = null
     annotationStore.selectedName = null
     this.fontFileInited = false
+    this.saveAction = options.saveAction || null
     const parameters = {
       cMapUrl: CMAP_URL,
       cMapPacked: true,
@@ -1377,6 +1378,7 @@ class ComPDFKitViewer {
     this.baseUrl = "";
     this._downloadUrl = "";
     this.documentInfo = null;
+    this.saveAction = null
     this.metadata = null;
     this._contentDispositionFilename = null;
     this._contentLength = null;
@@ -2700,7 +2702,7 @@ class ComPDFKitViewer {
     }
   }
 
-  async checkPassword(file) {
+  async checkPassword(file, notUpdatePwd = false) {
     const parameters = {
       cMapUrl: CMAP_URL,
       cMapPacked: true,
@@ -2717,16 +2719,16 @@ class ComPDFKitViewer {
     }
     const loadingTask = getDocument(parameters)
     this.pdfLoadingTask = loadingTask
-    this.#pwd = ''
-    const getPwd = (pwd) => this.#pwd = pwd
+    let password = ''
+    const getPwd = (pwd) => password = pwd
     loadingTask.onPassword = (updateCallback, reason) => {
       this.passwordPrompt.setUpdateCallback(updateCallback, reason, getPwd);
       this.passwordPrompt.open();
     };
 
     return await loadingTask.promise.then(() => {
-      if (this.#pwd) return this.#pwd
-      return ''
+      if (password && !notUpdatePwd) this.#pwd = password
+      return password
     }).catch((error) => {
       return false
     })
@@ -2748,6 +2750,436 @@ class ComPDFKitViewer {
       console.log(error)
     }
   }
+
+  async getPages(doc) {
+    const pagesCount = await this.messageHandler.sendWithPromise("GetPageCount", { doc })
+
+    const pages = []
+    for (let pageIndex = 0; pageIndex < pagesCount; pageIndex++) {
+      const page = await this.messageHandler.sendWithPromise("GetPageSize", {
+        doc,
+        pageIndex
+      })
+
+      pages.push({
+        pagePtr: page.pagePtr,
+        width: page.width,
+        height: page.height
+      })
+    }
+    return pages
+  }
+
+  getMinRadioSize(width, height, pageSize) {
+    const radio = Math.min(
+      pageSize.width / width,
+      pageSize.height / height
+    );
+    return {
+      width: width * radio,
+      height: height * radio
+    }
+  }
+
+  // 页面编辑 - 获取每个页面图片
+  // 获取范围内的页面图片 index起始索引 num页面数量
+  async getDocEditorPages(pageSize, index = 0, num = null) {
+    if (!this.docEditorCopy) {
+      const doc = await this.messageHandler.sendWithPromise('createEditorDoc', {
+        doc: this.doc,
+        password: this.#oldPwd || this.#pwd
+      });
+      const pages = await this.getPages(doc);
+
+      this.docEditorCopy = { doc, pages };
+    }
+
+    const pages = this.docEditorCopy.pages;
+    const radio = window.devicePixelRatio || 1;
+  
+    const canvasPool = [];
+    const contextPool = [];
+
+    for (let i = 0; i < (num ?? pages.length); i++) {
+      if (this.toolMode !== 'document') {
+        break;
+      }
+
+      const page = pages[i + index];
+      const canvas = canvasPool.pop() || document.createElement('canvas');
+      const context = contextPool.pop() || canvas.getContext('2d', { alpha: false });
+
+      const { width, height } = this.getMinRadioSize(page.width, page.height, pageSize)
+
+      const canvasWidth = Math.round(width * radio);
+      const canvasHeight = Math.round(height * radio);
+
+      const { imageArray } = await this.messageHandler.sendWithPromise('RenderPageBitmap', {
+        pagePtr: page.pagePtr,
+        x: 0,
+        y: 0,
+        width: canvasWidth,
+        height: canvasHeight
+      });
+
+      canvas.width = canvasWidth;
+      canvas.height = canvasHeight;
+
+      const imageData = context.createImageData(canvas.width, canvas.height);
+      const imageDataArray = new Uint8ClampedArray(imageArray.buffer);
+
+      imageData.data.set(imageDataArray);
+      context.putImageData(imageData, 0, 0);
+      const imageURL = canvas.toDataURL('image/png', 1);
+
+      const pageData = {
+        type: 'page',
+        img: imageURL,
+        size: {
+          width: page.width,
+          height: page.height
+        },
+        rotation: 0
+      };
+
+      this.eventBus.dispatch('getDocEditorPages', {
+        index: i + index,
+        pageData
+      });
+
+      canvas.width = 0;
+      canvas.height = 0;
+      context.clearRect(0, 0, canvasWidth, canvasHeight);
+      canvasPool.push(canvas);
+      contextPool.push(context);
+    }
+
+    canvasPool.forEach(canvas => URL.revokeObjectURL(canvas.toDataURL()));
+    canvasPool.length = 0;
+    contextPool.length = 0;
+  }
+
+  async updatePages() {
+    const blobData = await this.messageHandler.sendWithPromise('SaveDocumentByStream', {
+      doc: this.doc,
+      saveType: 2,
+      password: this.#oldPwd,
+      oldPassword: this.#oldPwd
+    })
+
+    const saveAction = this.saveAction
+    await this.loadDocument(URL.createObjectURL(blobData), {
+      filename: this._docName,
+      notUpdatePwd: true,
+      saveAction
+    })
+    this.eventBus.dispatch('onPagesUpdated')
+  }
+
+  /**
+   * 
+   * @param {number} pageIndex
+   * @param {number} pageWidth
+   * @param {number} pageHeight
+   */
+  async insertBlankPage(pageIndex = 0, pageWidth = 595, pageHeight = 842) {
+    if (pageIndex > this.pagesCount || pageIndex < 0) return
+
+    const res = await this.messageHandler.sendWithPromise('InsertPage', {
+      doc: this.doc,
+      index: pageIndex,
+      width: pageWidth,
+      height: pageHeight
+    })
+    if(!res) {
+      console.warn('insert page', res)
+      return
+    }
+    await this.updatePages()
+  }
+
+  /**
+   *
+   * @param {File} file
+   * @param {number} pageIndex
+   * @param {Array} range
+   */
+  async insertPages(file, pageIndex = 0, pagesIndex = [0]) {
+    if (!file || pageIndex >= this.pagesCount || pageIndex < 0) return
+
+    let range = null
+    if (Array.isArray(pagesIndex)) {
+      range = pagesIndex.join(',')
+    } else if (pagesIndex === 'all') {
+      range = 'all'
+    } else {
+      return
+    }
+
+    const res = await this.messageHandler.sendWithPromise('ImportPagesAtIndex', {
+      doc: this.doc,
+      file,
+      range,
+      index: pageIndex,
+    })
+    if(!res) {
+      console.warn('insert pdf', res)
+      return
+    }
+
+    await this.updatePages()
+  }
+
+  /**
+   * 
+   * @param {Array} pagesIndex
+   * @returns 
+   */
+  async removePages(pagesIndex) {
+    pagesIndex.sort(function(prev, next) {
+      return next - prev
+    })
+    pagesIndex.forEach(async index => {
+      if (index >= this.pagesCount || index < 0) return
+
+      const res = await this.messageHandler.sendWithPromise('RemovePage', {
+        doc: this.doc,
+        index
+      })
+      if(!res) console.warn('delete', res)
+    })
+
+    await this.updatePages()
+  }
+
+  /**
+   * 
+   * @param {Array} pagesIndex
+   * @param {number} angle
+   * @returns 
+   */
+  async rotatePages(pagesIndex, angle) {
+    pagesIndex.forEach(async index => {
+      if (index >= this.pagesCount || index < 0) return
+
+      await this.messageHandler.sendWithPromise('RotatePage', {
+        pagePtr: this.pagesPtr[index].pagePtr,
+        rotation: angle
+      })
+    })
+
+    await this.updatePages()
+  }
+
+  /**
+   * 
+   * @param {number} pageIndex
+   * @returns 
+   */
+  async copyPages(pagesIndex) {
+    pagesIndex.sort(function(prev, next) {
+      return next - prev
+    })
+    pagesIndex.forEach(async index => {
+      if (index >= this.pagesCount || index < 0) return
+
+      const res = await this.messageHandler.sendWithPromise('CopyPage', {
+        doc: this.doc,
+        index
+      })
+      if(!res) console.warn('copy', res)
+    })
+
+    await this.updatePages()
+  }
+
+  /**
+   * 
+   * @param {Array} pagesIndex
+   * @returns 
+   */
+  async extractPages(pagesIndex) {
+    const range = pagesIndex.join(',')
+    const blobData = await this.messageHandler.sendWithPromise('ExtractPage', {
+      doc: this.doc,
+      range
+    })
+
+    if(!blobData) console.warn('extract', 0)
+
+    return blobData
+  }
+
+  /**
+   * 
+   * @param {Array} pagesIndex
+   * @param {number} targetPageIndex
+   * @returns 
+   */
+  async movePages(pagesIndex, targetPageIndex) {
+    pagesIndex.sort((a, b) => a - b)
+    targetPageIndex = Math.min(this.pagesCount, targetPageIndex)
+    targetPageIndex = Math.max(0, targetPageIndex)
+
+    for (let i = 0; i < pagesIndex.length; i++) {
+      let index, tIndex
+
+      if (pagesIndex[i] >= this.pagesCount || pagesIndex[i] < 0) continue
+
+      if (pagesIndex[i] === targetPageIndex) {
+        index = pagesIndex[i]
+        tIndex = targetPageIndex
+      } else if (pagesIndex[i] > targetPageIndex) {
+        index = pagesIndex[i]
+        if (pagesIndex.includes(targetPageIndex)) {
+          tIndex = targetPageIndex
+          for (let j = 0; j < pagesIndex.length; j++) {
+            if (pagesIndex[j] === tIndex && index !== tIndex) tIndex++
+          }
+        } else {
+          tIndex = targetPageIndex
+        }
+      } else {
+        index = pagesIndex[i] - i
+        tIndex = targetPageIndex - 1
+      }
+
+      if (pagesIndex.includes(index) && pagesIndex.includes(tIndex) && index === tIndex) continue
+
+      const res = await this.messageHandler.sendWithPromise('MovePage', {
+        doc: this.doc,
+        index,
+        targetIndex: tIndex
+      })
+
+      if(!res) console.warn('move', res)
+    }
+
+    await this.updatePages()
+  }
+
+  // 页面编辑 - 保存
+  async saveDocumentEdit(op) {
+    const doc = this.docEditorCopy.doc
+    const clearDocument = async () => {
+      await this.messageHandler.sendWithPromise('ClearDocument', doc)
+      this.docEditorCopy = null
+    }
+
+    if (typeof op !== 'object') {
+      if (op === 'cancel') {
+        await clearDocument()
+        return
+      }
+
+      if (this.saveAction === 'remove') this.#pwd = ''
+      const blobData = await this.messageHandler.sendWithPromise('SaveDocumentByStream', {
+        doc,
+        saveType: this.saveAction === 'remove' ? 3 : 2,
+        password: this.#pwd,
+        oldPassword: this.#oldPwd
+      })
+      const filename = this._docName
+
+      if (op === 'saveAs') return { blobData, filename }
+
+      const newUrl = URL.createObjectURL(blobData)
+      if (this.saveAction === 'set' && this.#oldPwd !== this.#pwd) this.#oldPwd = this.#pwd
+      await this.loadDocument(newUrl, { filename, notUpdatePwd: true })
+      await clearDocument()
+      return newUrl
+    }
+
+    if (op.type === 'insert') {
+      if (op.file) {
+        const res = await this.messageHandler.sendWithPromise('ImportPagesAtIndex', {
+          doc,
+          file: op.file,
+          range: op.range,
+          index: op.pageIndex,
+          password: op.password
+        })
+        if(!res) console.warn('insert pdf', res)
+        
+        this.docEditorCopy.pages = await this.getPages(doc)
+        return this.docEditorCopy.pages.length
+      } else {
+        const res = await this.messageHandler.sendWithPromise('InsertPage', {
+          doc,
+          index: op.pageIndex,
+          width: op.size.width,
+          height: op.size.height
+        })
+        if(!res) console.warn('insert page', res)
+      }
+    }
+    if (op.type === 'delete') {
+      op.selectedPageIndexArray.forEach(async index => {
+        const res = await this.messageHandler.sendWithPromise('RemovePage', {
+          doc,
+          index
+        })
+        if(!res) console.warn('delete', res)
+      })
+    }
+    if (op.type === 'rotate') {
+      op.selectedPageIndexArray.forEach(async index => {
+        await this.messageHandler.sendWithPromise('RotatePage', {
+          pagePtr: this.docEditorCopy.pages[index].pagePtr,
+          rotation: op.rotation
+        })
+      })
+    }
+    if (op.type === 'copy') {
+      op.selectedPageIndexArray.forEach(async index => {
+        const res = await this.messageHandler.sendWithPromise('CopyPage', {
+          doc,
+          index
+        })
+        if(!res) console.warn('copy', res)
+      })
+    }
+    if (op.type === 'extract') {
+      const blobData = await this.messageHandler.sendWithPromise('ExtractPage', {
+        doc,
+        range: op.range,
+        separateFile: op.separateFile
+      })
+      if(!blobData) console.warn('extract', 0)
+
+      if (op.separateFile) {
+        const zip = new JSZip()
+
+        let files = []
+        for (let i = 0; i < blobData.length; i++) {
+          const file = blobData[i]
+          files.push({ blobData: file.blobData, filename: file.name + '.pdf' })
+        }
+        
+        files.forEach((file) => {
+          zip.file(file.filename, file.blobData, { binary: true })
+        })
+        
+        zip.generateAsync({ type: 'blob' }).then((content) => {
+          saveAs(content, 'extracted documents.zip')
+        })
+      } else {
+        const filename = this._docName
+        saveAs(blobData, filename)
+      }
+      return
+    }
+    if (op.type === 'move') {
+      const res = await this.messageHandler.sendWithPromise('MovePage', {
+        doc,
+        index: op.pageIndex,
+        targetIndex: op.targetPageIndex
+      })
+      if(!res) console.warn('move', res)
+    }
+
+    this.docEditorCopy.pages = await this.getPages(doc)
+  }
 }
 
 class PDFWorker {

+ 0 - 2
packages/core/src/pdf_page_view.js

@@ -227,14 +227,12 @@ class PDFPageView {
             name: uuidv4()
           }
         } else {
-          const imageData = await convertBase64ToBytes(imgData)
           annotation = {
             operate: "add-annot",
             type: "stamp",
             stampType: 'image',
             pageIndex,
             imageBase64: imgData,
-            imageData,
             rect: {
               left,
               top,

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

@@ -896,6 +896,7 @@ class PDFViewer {
   }
 
   #scrollIntoView(pageView, pageSpot = null) {
+    if (!pageView) return
     const { div, id } = pageView;
 
     // Ensure that `this._currentPageNumber` is correct, when `#scrollIntoView`

+ 207 - 9
packages/core/src/worker/compdfkit_worker.js

@@ -32,7 +32,7 @@ let PDFRange = {}
 let TextRectArray = []
 
 import MessageHandler from "../message_handler"
-import { convertFileToBuffer } from '../fileHandler';
+import { convertFileToBuffer, convertBase64ToBytes } from '../fileHandler';
 
 async function sleep(delay) {
   await new Promise((resolve) => setTimeout(resolve, delay))
@@ -106,12 +106,7 @@ class CPDFWorker {
       })
     })
 
-    messageHandler.on('LoadDocumentByStream', (data) => {
-      const { doc, fileId, length, password: rawPassword } = data
-
-      const password = stringToNewUTF8(rawPassword)
-      return Module._LoadDocumentByStream(doc, fileId, length, password)
-    })
+    messageHandler.on('LoadDocumentByStream', loadDocumentByStream)
 
     messageHandler.on('GetPageCount', (data) => {
       const { doc } = data
@@ -221,6 +216,24 @@ class CPDFWorker {
       return { imageArray }
     })
 
+    messageHandler.on('RenderPageBitmap', (data) => {
+      const { pagePtr, x, y, width, height } = data
+
+      let param = {
+        pagePtr,
+        x,
+        y,
+        width,
+        height,
+        mode: -1,
+        flag: 1,
+        form: 1
+      }
+      let imageArray = RenderPageBitmap(param)
+
+      return { imageArray }
+    })
+
     messageHandler.on('EditAnnotation', (data) => {
       const { annotation } = data
       if (annotation.rect) {
@@ -1025,8 +1038,162 @@ class CPDFWorker {
       }
       fileReader.readAsArrayBuffer(data.fontFile)
     })
-  }
 
+    messageHandler.on('createEditorDoc', async (data) => {
+      const { doc, password } = data
+      // const doc = Module._InitDocument()
+      // const buffer = ComPDFKitJS.opened_files[0]
+      // const passwordPtr = stringToNewUTF8(password)
+      // Module._LoadDocumentByStream(doc, 0, buffer.length, passwordPtr)
+      // return doc
+      return await copyDocument(doc, password)
+    })
+
+    messageHandler.on('InsertPage', (data) => {
+      const { doc, index, width, height } = data
+      return Module._InsertPage(doc, index, width, height)
+    })
+
+    messageHandler.on('ImportPagesAtIndex', async (data) => {
+      const { doc, file, range, index, password } = data
+
+      const buffer = await convertFileToBuffer(file)
+      const importDoc = Module._InitDocument()
+      const length = ComPDFKitJS.opened_files.length || 0
+      ComPDFKitJS.opened_files[length] = buffer
+      const passwordPtr = stringToNewUTF8(password)
+      Module._LoadDocumentByStream(importDoc, length, buffer.length, passwordPtr)
+      const pagesCount = Module._GetPageCount(importDoc)
+      let importRange
+      if (range === 'all') {
+        importRange = '1-' + pagesCount
+        // importRange = ''
+      } else if (range === 'odd') {
+        importRange = Array.from({ length: pagesCount }, (_, i) => i + 1).filter(num => num % 2 !== 0).join(",")
+      } else if (range === 'even') {
+        importRange = Array.from({ length: pagesCount }, (_, i) => i + 1).filter(num => num % 2 === 0).join(",")
+      } else {
+        const max = pagesCount
+        let str = range.replace(/\s/g, '')
+        let parts = str.split(',')
+        let result = []
+
+        parts.forEach(part => {
+          if (part.includes('-')) {
+            let [start, end] = part.split('-').map(num => parseInt(num))
+            if (start > max) {
+              return
+            } else if (end > max) {
+              end = max
+            }
+            result.push((start === max) ? start.toString() : `${start}-${end}`)
+          } else {
+            let num = parseInt(part)
+            if (num <= max) {
+              result.push(num.toString())
+            }
+          }
+        })
+        importRange = result.join(',')
+      }
+      // console.log('导入文档的页面范围:' + importRange)
+      if (!importRange) return 0
+
+      importRange = stringToNewUTF8(importRange)
+
+      const resCode = Module._ImportPagesAtIndex(
+        doc,
+        importDoc,
+        importRange,
+        index
+      )
+      ComPDFKitJS.opened_files.pop()
+      return resCode
+    })
+
+    messageHandler.on('RemovePage', (data) => {
+      const { doc, index } = data
+      return Module._RemovePage(doc, index)
+    })
+
+    messageHandler.on('RotatePage', (data) => {
+      const { pagePtr, rotation } = data
+      Module._RotatePage(pagePtr, rotation)
+    })
+
+    messageHandler.on('GetPageWithoutParse', (data) => {
+      const { doc, index } = data
+      const pagePtr = Module._GetPageWithoutParse(doc, index)
+      return pagePtr
+    })
+
+    messageHandler.on('CopyPage', (data) => {
+      const { doc, index } = data
+
+      const importRange = stringToNewUTF8(index + 1 + '')
+      return Module._ImportPagesAtIndex(
+        doc,
+        doc,
+        importRange,
+        index + 1
+      )
+    })
+
+    messageHandler.on('GetPageRotation', (pagePtr) => {
+      return Module._GetPageRotation(pagePtr)
+    })
+
+    messageHandler.on('ClearDocument', (doc) => {
+      Module._ClearDocument(doc)
+    })
+
+    messageHandler.on('ExtractPage', async (data) => {
+      const { doc, range, separateFile } = data
+
+      if (separateFile) {
+        let blobDataList = []
+
+        for (let i = 0; i < range.length; i++) {
+          const pageNum = range[i] + 1 + ''
+          
+          const page = stringToNewUTF8(pageNum)
+          const newDoc = Module._InitDocument()
+          Module._CreateDocument(newDoc)
+          Module._ImportPagesAtIndex(
+            newDoc,
+            doc,
+            page,
+            0
+          )
+          const blobData = saveDocument(newDoc, 2)
+          blobDataList.push({ name: pageNum, blobData })
+        }
+        return blobDataList
+
+      } else {
+        const exportRange = stringToNewUTF8(range)
+        const newDoc = Module._InitDocument()
+        Module._CreateDocument(newDoc)
+        Module._ImportPagesAtIndex(
+          newDoc,
+          doc,
+          exportRange,
+          0
+        )
+        return saveDocument(newDoc, 2)
+      }
+    })
+
+    messageHandler.on('MovePage', (data) => {
+      const { doc, index, targetIndex } = data
+
+      return Module._MovePage(
+        doc,
+        index,
+        targetIndex
+      )
+    })
+  }
 }
 
 async function initDoc() {
@@ -1043,6 +1210,13 @@ initDoc().then((doc) => {
   CPDFWorker.setup(doc)
 })
 
+function loadDocumentByStream(data) {
+  const { doc, fileId, length, password: rawPassword } = data
+
+  const password = stringToNewUTF8(rawPassword)
+  return Module._LoadDocumentByStream(doc, fileId, length, password)
+}
+
 function setPassword(data) {
   const { doc, password: rawPassword } = data
   const password = stringToNewUTF8(rawPassword)
@@ -1072,6 +1246,29 @@ function RenderPageBitmapWithMatrix(data) {
   return imageArray
 }
 
+function RenderPageBitmap(data) {
+  const { pagePtr, x, y, width, height, mode, flag, form } = data
+  let pixelNum = parseInt(width) * parseInt(height)
+  let imageBytes = pixelNum * 4
+  let imageptr = _malloc(imageBytes)
+  for (var i = 0; i < pixelNum; i++) {
+    Module.HEAP32[imageptr / 4 + i] = 0
+  }
+
+  Module._RenderPageBitmap(pagePtr, x, y, width, height, mode, imageptr, flag, form)
+  let imageArray = new Uint8Array(imageBytes)
+  for (var i = 0; i < imageBytes; i += 4) {
+    //bgra 转 rgba
+    imageArray[i] = Module.HEAPU8[imageptr + i + 2]
+    imageArray[i + 1] = Module.HEAPU8[imageptr + i + 1]
+    imageArray[i + 2] = Module.HEAPU8[imageptr + i]
+    imageArray[i + 3] = Module.HEAPU8[imageptr + i + 3]
+  }
+  _free(imageptr)
+
+  return imageArray
+}
+
 function createAnnotation(doc, pagePtr, annotation) {
   const typeInt = AnnotationType[annotation.type.toUpperCase()]
 
@@ -2280,7 +2477,8 @@ function createTextStamp(data) {
 }
 
 function createImageStamp(data) {
-  const { annotPtr, imageData, stampShape, rect, doc } = data
+  const { annotPtr, imageBase64, stampShape, rect, doc } = data
+  const imageData = convertBase64ToBytes(imageBase64)
 
   setAnnotContent({
     annotPtr,

+ 45 - 1
packages/webview/locales/en.json

@@ -15,6 +15,7 @@
     "security": "Security",
     "compare": "Compare Documents",
     "editor": "Content Editor",
+    "document": "Document Editor",
 
     "note": "Note",
     "highlight": "Highlight",
@@ -154,8 +155,9 @@
 
   "prevent": {
     "title": "Not Available in Server-Backed",
+    "standalone": "Standalone",
     "description": "Content Editor is only available on Standalone. Change to Standalone to edit PDF content.",
-    "standalone": "Standalone"
+    "description1": "Document Editor is only available on Standalone. Change to Standalone to edit PDF pages."
   },
 
   "passwordDialog": {
@@ -273,5 +275,47 @@
     "replace": "Replace",
     "export": "Export",
     "crop": "Crop"
+  },
+
+  "documentEditor": {
+    "save": "Save",
+    "saveAs": "Save As...",
+    "insert": "Insert",
+    "delete": "Delete",
+    "rotateRight": "Rotate Right",
+    "rotateLeft": "Rotate Left",
+    "copy": "Copy",
+    "extract": "Extract",
+    "replace": "Replace",
+    "move": "Move",
+    "selectAll": "Select All",
+
+    "dialog": {
+      "insertPages": "Insert Pages",
+      "blankPage": "Blank Page",
+      "customPage": "Custom Blank Page",
+      "fromPdf": "From PDF",
+      "selectFile": "Select a File",
+      "pageRange": "Page Range",
+      "allPages": "All Pages",
+      "oddPage": "Odd Pages Only",
+      "evenPage": "Even Pages Only",
+      "customRange": "Custom Range",
+      "insertTo": "Insert to",
+      "firstPage": "First Page",
+      "lastPage": "Last Page",
+      "page": "Page",
+      "pageTip": "e.g. 3, 5-10",
+      "before": "Before",
+      "after": "After"
+    },
+
+    "deleteConfirmText": "Do you want to delete page",
+
+    "eachPage": "Each page in a separate file",
+    "deleteAfter": "Delete page after extraction",
+
+    "moveTo": "Move after page:",
+    "inputPageTip": "Input 0 to maximum page number"
   }
 }

+ 45 - 1
packages/webview/locales/zh-CN.json

@@ -15,6 +15,7 @@
     "security": "安全",
     "compare": "文档对比",
     "editor": "内容编辑器",
+    "document": "页面编辑",
 
     "note": "便签",
     "highlight": "高亮",
@@ -154,8 +155,9 @@
 
   "prevent": {
     "title": "不可用",
+    "standalone": "离线版",
     "description": "在线版不支持内容编辑,请切换到离线版(Standalone)模式使用内容编辑。",
-    "standalone": "离线版"
+    "description1": "在线版不支持文档编辑,请切换到离线版(Standalone)模式使用文档编辑。"
   },
 
   "passwordDialog": {
@@ -273,5 +275,47 @@
     "replace": "替换",
     "export": "导出",
     "crop": "裁剪"
+  },
+  
+  "documentEditor": {
+    "save": "保存",
+    "saveAs": "另存为",
+    "insert": "插入",
+    "delete": "删除",
+    "rotateRight": "右旋转",
+    "rotateLeft": "左旋转",
+    "copy": "复制",
+    "extract": "提取",
+    "replace": "替换",
+    "move": "移动",
+    "selectAll": "全选",
+
+    "dialog": {
+      "insertPages": "页面范围",
+      "blankPage": "空白页",
+      "customPage": "自定义空白页",
+      "fromPdf": "从其他PDF",
+      "selectFile": "选择文件",
+      "pageRange": "页面范围",
+      "allPages": "所有页面",
+      "oddPage": "奇数页",
+      "evenPage": "偶数页",
+      "customRange": "自定义范围",
+      "insertTo": "页面插入位置",
+      "firstPage": "首页",
+      "lastPage": "末页",
+      "page": "页面",
+      "pageTip": "例:3, 5-10",
+      "before": "前",
+      "after": "后"
+    },
+
+    "deleteConfirmText": "确认要删除所选页面吗?",
+
+    "eachPage": "每页作为单个文件",
+    "deleteAfter": "提取后删除页面",
+
+    "moveTo": "移动到",
+    "inputPageTip": "输入0~文档最大页码数"
   }
 }

+ 1 - 0
packages/webview/package.json

@@ -17,6 +17,7 @@
     "file-saver": "^2.0.5",
     "lodash.debounce": "^4.0.8",
     "pinia": "^2.0.36",
+    "sortablejs": "^1.15.2",
     "vue": "^3.2.41",
     "vue-i18n": "9"
   },

二進制
packages/webview/public/example/Number Pages.pdf


+ 17 - 12
packages/webview/src/assets/base.css

@@ -27,6 +27,7 @@
   --c-blue-3: #6499FF;
   --c-blue-4: #DDE9FF;
   --c-blue-5: #FAFCFF;
+  --c-blue-6: #F7F8FA;
   
   --c-bg: var(--c-black-11);
 
@@ -89,7 +90,7 @@
 
   --c-header-circle-bg: var(--c-blue-1);
 
-  /* 右侧属性栏 start */
+  /* 右侧属性栏 */
   --c-right-side-header-text: var(--c-black-13);
 
   --c-right-side-header-bg: var(--c-black-11);
@@ -102,14 +103,12 @@
   --c-right-side-info-tip-bg: var(--c-black-15);
 
   --c-right-side-list-item-bg: rgba(73, 130, 230, 0.2);
-  /* 右侧属性栏 end */
 
-  /* 工具栏 start */
+  /* 工具栏 */
   --c-toolbar-bg: var(--c-black-10);
   --c-toolbar-border: rgba(0, 0, 0, 0.12);
 
   --c-tool-mode-bg: var(--c-blue-5);
-  /* 工具栏 end */
 
   /* page Mode 栏 */
   --c-tool-right-page: var(--c-black-12);
@@ -117,10 +116,14 @@
 
   /* 文档对比模式 */
   --c-file-header: var(--c-blue-5);
-  /* 在线错误提示弹出框 */
+
+  /* 文本编辑 */
   --c-edit-text: var(--c-black-5);
-  /* 文本编辑侧边栏 */
   --c-edit-right-page: var(--c-black-18);
+
+  /* 页面编辑模式 */
+  --c-doc-editor-bg: var(--c-blue-6);
+  --c-doc-editor-popup-shadow: rgba(129, 149, 200, 0.32);
 }
 
 html.dark {
@@ -198,25 +201,27 @@ html.dark {
   --c-right-side-info-tip-bg: var(--c-black-16);
 
   --c-right-side-list-item-bg: var(--c-blue-3);
-  /* 右侧属性栏 end */
 
-  /* 工具栏 start */
+  /* 工具栏 */
   --c-toolbar-bg: var(--c-black-14);
   --c-toolbar-border: rgba(255, 255, 255, 0.1);
 
   --c-tool-mode-bg: var(--c-blue-9);
-  /* 工具栏 end */
 
   /* page Mode 栏 */
   --c-tool-right-page: var(--c-black-8);
   --c-tool-right-page-active: var(--c-blue-3);
 
-  /* 文档对比模式 */
+  /* 文档对比 */
   --c-file-header: var(--c-black-14);
-  /* 在线错误提示弹出框 */
+
+  /* 文本编辑 */
   --c-edit-text: var(--c-black-17);
-  /* 文本编辑侧边栏 */
   --c-edit-right-page: var(--c-white);
+
+  /* 页面编辑 */
+  --c-doc-editor-bg: var(--c-black-7);
+  --c-doc-editor-popup-shadow: rgba(255, 255, 255, 0.1);
 }
 
 body {

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

@@ -5,6 +5,7 @@
       <LeftPanel />
       <DocumentContainer />
       <CompareDocumentContainer />
+      <DocumentEditorContainer v-if="toolMode === 'document'" :Sortable="Sortable" />
       <RightPanel />
       <RightPanelPageMode />
       <StampPanel />
@@ -33,6 +34,10 @@
   import initDocument from '@/helpers/initDocument'
   import { ref, computed, provide, getCurrentInstance } from 'vue'
   import { useViewerStore } from '@/stores/modules/viewer'
+  import { Sortable, MultiDrag } from 'sortablejs'
+  import core from '@/core'
+  
+  Sortable.mount(new MultiDrag())
 
   const useViewer = useViewerStore()
   const themeMode = computed(() => useViewer.getThemeMode)
@@ -40,6 +45,7 @@
   const downloadError = computed(() => useViewer.getDownloadError)
   const activeSignCreatePanel = computed(() => useViewer.isElementOpen('signCreatePanel'))
   const popoverChanged = computed(() => useViewer.getPopoverChanged)
+  const toolMode = computed(() => useViewer.getToolMode)
 
   const instance = getCurrentInstance().appContext.app.config.globalProperties
 
@@ -54,6 +60,7 @@
       useViewer.setActiceToolMode('view')
     }
     if (!popoverChanged.value) useViewer.setPopoverChanged(true)
+    if (toolMode.value === 'document') core.saveDocumentEdit('cancel')
   }
 
   const closeMask = () => {

+ 1 - 1
packages/webview/src/components/CompareButtons/CompareButtons.vue

@@ -47,7 +47,7 @@ const uploadPdf = async () => {
         useViewer.resetSetting()
         const openFileInput = document.getElementById("fileInput")
         openFileInput.value = ''
-        useDocument.setCurrentPdfData(null)
+        useDocument.setCurrentPdfData()
         return
       }
     }

+ 4 - 0
packages/webview/src/components/CompareDocumentContainer/CompareDocumentContainer.vue

@@ -307,6 +307,10 @@ async function handlePdf (pdf, filename, docNum) {
   display: flex;
   width: 100%;
   background-color: var(--c-file-header);
+
+  &.under {
+    display: none;
+  }
   .old, .new {
     width: 100%;
     max-width: calc(50% - 5px);

+ 71 - 0
packages/webview/src/components/Dialogs/DeletePageDialog.vue

@@ -0,0 +1,71 @@
+<template>
+  <div class="delete-page-popup" v-if="show">
+    <Dialog :show="show" :dialogName="dialogName" :close="false">
+      <!-- content -->
+      <Warning />
+      <p>{{ $t('documentEditor.deleteConfirmText') }}{{ locale === 'en' ? ` ${pageStr}?` : '' }}</p>
+
+      <!-- footer -->
+      <template #footer>
+        <div class="rect-button white" @click="closeDialog">{{ $t('cancel') }}</div>
+        <div class="rect-button blue" @click="handleDelete">{{ $t('documentEditor.delete') }}</div>
+      </template>
+    </Dialog>
+  </div>
+</template>
+
+<script setup>
+import { computed } from 'vue'
+import { useViewerStore } from '@/stores/modules/viewer'
+import { useI18n } from 'vue-i18n'
+const { locale } = useI18n()
+
+const props = defineProps(['selectedPageList'])
+const emits = defineEmits(['deletePage'])
+
+const useViewer = useViewerStore()
+
+const dialogName = 'deletePageDialog'
+const show = computed(() => useViewer.isElementOpen(dialogName))
+
+const pageStr = computed(() => {
+  return props.selectedPageList.sort((a, b) => a - b).map(item => item + 1).join(', ')
+})
+
+const closeDialog = () => {
+  useViewer.closeElement(dialogName)
+}
+
+const handleDelete = async () => {
+  emits('deletePage')
+  closeDialog()
+}
+</script>
+
+<style lang="scss">
+.delete-page-popup {
+
+  .dialog-container {
+    width: 381px;
+
+    main {
+      margin-top: 0;
+      margin-bottom: 20px;
+      display: flex;
+      align-items: center;
+
+      svg {
+        margin-right: 8px;
+        min-width: 20px;
+        width: 20px;
+        height: 20px;
+      }
+
+      p {
+        font-size: 16px;
+        line-height: 24px;
+      }
+    }
+  }
+}
+</style>

+ 1 - 0
packages/webview/src/components/Dialogs/Dialog.vue

@@ -46,6 +46,7 @@ const closeDialog = () => {
 .dialog-container {
   background-color: var(--c-header-bg);
   padding: 20px;
+  margin: 0 10px;
   border-radius: 4px;
   .close {
     margin-top: -6px;

+ 295 - 0
packages/webview/src/components/Dialogs/ExtractPageSettingDialog.vue

@@ -0,0 +1,295 @@
+<template>
+  <div class="extract-page-setting-popup" v-if="show">
+    <Dialog :show="show" :dialogName="dialogName" :close="false">
+      <template #header>
+        <p>{{ $t('documentEditor.extract') }}</p>
+      </template>
+
+      <p class="title">{{ $t('documentEditor.dialog.pageRange') }}</p>
+
+      <div class="container">
+        <div class="select-content">
+          <div @click="pageRange = 'all'" class="option"><RadioBtnSel v-if="pageRange === 'all'" /><RadioBtnDis v-else />{{ $t('documentEditor.dialog.allPages') }}</div>
+          <div @click="pageRange = 'odd'" class="option"><RadioBtnSel v-if="pageRange === 'odd'" /><RadioBtnDis v-else />{{ $t('documentEditor.dialog.oddPage') }}</div>
+          <div @click="pageRange = 'even'" class="option" :class="{ 'disabled': totalPages === 1 }">
+            <div v-if="totalPages === 1" class="disabled-btn"></div>
+            <RadioBtnSel v-else-if="pageRange === 'even'" />
+            <RadioBtnDis v-else />
+            {{ $t('documentEditor.dialog.evenPage') }}</div>
+          <div @click="pageRange = 'custom'" class="option custom-page">
+            <RadioBtnSel v-if="pageRange === 'custom'" /><RadioBtnDis v-else />{{ $t('documentEditor.dialog.page') }}
+            <div class="addition">
+              <input type="text" v-model="customRange" :placeholder="$t('documentEditor.dialog.pageTip')" @blur="validateCustomRange">
+              <span>/ {{ totalPages }}</span>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <div class="check-content">
+        <div class="check-box" @click="separateFile = !separateFile">
+          <div class="check" :class="{'active': separateFile}"><Checkbox v-show="separateFile" /></div>
+          <span>{{ $t('documentEditor.eachPage') }}</span>
+        </div>
+        <div class="check-box" :class="{ 'disabled': checkDeleteAfter }" @click="() => { if (!checkDeleteAfter) deleteAfter = !deleteAfter }">
+          <div class="check" :class="{'active': deleteAfter}"><Checkbox v-show="deleteAfter && !checkDeleteAfter" /></div>
+          <span>{{ $t('documentEditor.deleteAfter') }}</span>
+        </div>
+      </div>
+
+      <template #footer>
+        <div class="rect-button white" @click="closeDialog">{{ $t('cancel') }}</div>
+        <div class="rect-button blue" :class="{ 'disabled': pageRange === 'pdf' && (!inputFile || (pageRange === 'custom' && !validCustomRange)) }" @click="confirm">{{ $t('documentEditor.extract') }}</div>
+      </template>
+    </Dialog>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, watch } from 'vue'
+import { useViewerStore } from '@/stores/modules/viewer'
+
+const props = defineProps(['selectedPageList', 'totalPages'])
+const emits = defineEmits(['extractPage'])
+
+const useViewer = useViewerStore()
+
+const dialogName = 'extractPageSettingDialog'
+const show = computed(() => useViewer.isElementOpen(dialogName))
+
+const pageRange = ref('custom')
+const customRange = ref('')
+const validCustomRange = ref('')
+const separateFile = ref(false)
+const deleteAfter = ref(false)
+let initialRange = ''
+
+watch(() => show.value, (newVal, oldVal) => {
+  if (newVal) {
+    const selectedPageStr = props.selectedPageList.map(function(num) {
+      return num + 1
+    }).join(',')
+
+    initialRange = formatText(selectedPageStr)
+    customRange.value = validCustomRange.value = initialRange
+  }
+})
+
+// 关闭弹窗
+const closeDialog = () => {
+  useViewer.closeElement(dialogName)
+
+  pageRange.value = 'custom'
+}
+
+// 确认
+const confirm = async () => {
+  const data = {
+    range: pageRange.value === 'custom' ? customRange.value : pageRange.value,
+    separateFile: separateFile.value,
+    deleteAfter: pageRange.value === 'all' ? false : deleteAfter.value
+  }
+  emits('extractPage', data)
+  closeDialog()
+}
+
+// 输入自定义页面范围校验 格式化
+const validateCustomRange = () => {
+  validCustomRange.value = formatText(customRange.value)
+  customRange.value = validCustomRange.value
+}
+const formatText = (inputText) => {
+  const max = props.totalPages
+  const text = inputText.replace(/\s/g, '')
+  const matches = text.match(/\d+-\d+|\d+|\d+/g)
+
+  if (matches) {
+    const sortedNums = matches
+      .flatMap(match => {
+        if (match.includes('-')) {
+          const [start, end] = match.split('-')
+          const smallest = Math.min(start, end)
+          const largest = Math.min(Math.max(start, end), max)
+          return Array.from({ length: largest - smallest + 1 }, (_, i) =>
+            String(Number(smallest) + i)
+          )
+        } else {
+          return Math.min(Math.max(match, 1), max)
+        }
+      })
+      .sort((a, b) => a - b)
+
+    const formattedText = []
+
+    let start = sortedNums[0]
+    let prev = sortedNums[0]
+
+    for (let i = 1; i < sortedNums.length; i++) {
+      const current = sortedNums[i]
+
+      const prevNum = Number(prev)
+      const currentNum = Number(current)
+
+      if (currentNum - prevNum > 1) {
+        formattedText.push(formatRange(start, prev))
+        start = current
+      }
+      prev = current
+    }
+    formattedText.push(formatRange(start, prev))
+    return formattedText.join(', ')
+  }
+  return initialRange
+}
+const formatRange = (start, end) => {
+  return (start === end) ? start.toString() : `${start}-${end}`
+}
+
+const checkDeleteAfter = computed(() => {
+  if (pageRange.value === 'all') return true
+  if (props.totalPages === 1 && pageRange.value !== 'even') return true
+  if (props.totalPages > 1 && pageRange.value === 'custom' && customRange.value === '1-' + props.totalPages) return true
+})
+</script>
+
+<style lang="scss">
+.extract-page-setting-popup {
+  .dialog-container {
+    width: 460px;
+    box-shadow: 0px 4px 32px 0px var(--c-doc-editor-popup-shadow);
+
+    main {
+      margin-top: 16px;
+    }
+  }
+
+  .title {
+    font-size: 14px;
+    font-weight: 700;
+    line-height: 16px;
+  }
+
+  .container {
+    margin-top: 8px;
+    margin-bottom: 16px;
+    padding: 8px 16px 16px;
+    border-radius: 4px;
+    background: var(--c-toolbar-bg);
+
+    .select-content {
+
+      .option:not(:first-child) {
+        margin-top: 16px;
+      }
+
+      .option.disabled {
+        pointer-events: none;
+        opacity: 0.5;
+
+        .disabled-btn {
+          margin-right: 4px;
+          width: 12px;
+          height: 12px;
+          border-radius: 50%;
+          border: 1px solid #666;
+          background-color: #b4b4b4;
+        }
+      }
+    }
+    
+    .addition {
+      position: relative;
+      display: flex;
+      align-items: center;
+      
+      span {
+        white-space: nowrap;
+        font-size: 14px;
+        line-height: 16px;
+      }
+
+      input {
+        padding: 0 20px 0 8px;
+        width: 100%;
+        height: 24px;
+        background: var(--c-right-side-content-fillbox-bg);
+        border: 1px solid var(--c-right-side-content-fillbox-border);
+        border-radius: 1px;
+        font-size: 14px;
+        line-height: 16px;
+        color: var(--c-text);
+      }
+
+      input[type="text"]:focus {
+        border-color: #0078D7;
+      }
+    }
+
+    .option.custom-page {
+      padding: 0;
+
+      .addition {
+        flex: 1;
+        margin-left: 20px;
+
+        span {
+          margin-left: 8px;
+          color: #999;
+        }
+      }
+    }
+  }
+
+  .check-content {
+    padding: 0 16px;
+    display: flex;
+    flex-direction: column;
+    align-items: flex-start;
+
+    .check-box {
+      padding: 5px 0;
+      display: inline-flex;
+      align-items: center;
+      cursor: pointer;
+
+      & + .check-box {
+        margin-top: 8px;
+      }
+
+      .check {
+        margin-right: 4px;
+        width: 14px;
+        height: 14px;
+        background: var(--c-right-side-content-fillbox-bg);
+        border: 1px solid var(--c-right-side-content-fillbox-border);
+        border-radius: 1px;
+        overflow: hidden;
+        transition: none;
+
+        &.active {
+          border: none;
+        }
+
+        svg {
+          vertical-align: top;
+        }
+      }
+
+      &.disabled {
+        cursor: not-allowed;
+        color: #b4b4b4;
+
+        .check {
+          background-color: #b4b4b4;
+          opacity: 0.5;
+        }
+      }
+
+      span {
+        font-size: 14px;
+        line-height: 16px;
+      }
+    }
+  }
+}
+</style>

+ 415 - 0
packages/webview/src/components/Dialogs/InsertPageSettingDialog.vue

@@ -0,0 +1,415 @@
+<template>
+  <div class="add-page-setting-popup" v-if="show">
+    <Dialog :show="show" :dialogName="dialogName" :close="false">
+
+      <!-- 插入页面类型 -->
+      <p class="title">{{ $t('documentEditor.dialog.insertPages') }}</p>
+      <div class="container">
+        <div class="select-content">
+          <!-- 空白页 -->
+          <div @click="type = 'blank'" class="option"><RadioBtnSel v-if="type === 'blank'" /><RadioBtnDis v-else />{{ $t('documentEditor.dialog.blankPage') }}</div>
+          <!-- 自定义空白页 -->
+          <div @click="type = 'custom'" class="option"><RadioBtnSel v-if="type === 'custom'" /><RadioBtnDis v-else />{{ $t('documentEditor.dialog.customPage') }}</div>
+          <div v-if="type === 'custom'" class="addition">
+            <select name="customPageSize" v-model="customPageSize">
+              <option value="A3">A3</option>
+              <option value="A4">A4</option>
+              <option value="A5">A5</option>
+            </select>
+            <ArrowDown />
+          </div>
+          <!-- 从其他PDF -->
+          <div @click="type = 'pdf'" class="option"><RadioBtnSel v-if="type === 'pdf'" /><RadioBtnDis v-else />{{ $t('documentEditor.dialog.fromPdf') }}</div>
+          <div v-if="type === 'pdf'" class="addition">
+            <div class="file-input-underbox">
+              <span :class="{ 'uploaded': inputFile?.name }">{{ inputFile?.name || $t('documentEditor.dialog.selectFile') }}</span>
+              <FileFolder />
+            </div>
+            <input type="file" @change="handleFile" accept=".pdf">
+          </div>
+          <div v-if="type === 'pdf'" class="addition from-pdf">
+            <span>{{ $t('documentEditor.dialog.pageRange') }}</span>
+            <div>
+              <div class="addition">
+                <select name="inputFileRange" v-model="inputFileRange">
+                  <option value="all">{{ $t('documentEditor.dialog.allPages') }}</option>
+                  <option value="odd">{{ $t('documentEditor.dialog.oddPage') }}</option>
+                  <option value="even">{{ $t('documentEditor.dialog.evenPage') }}</option>
+                  <option value="custom">{{ $t('documentEditor.dialog.customRange') }}</option>
+                </select>
+                <ArrowDown />
+              </div>
+              <input v-if="inputFileRange === 'custom'" type="text" v-model="customRange" :placeholder="$t('documentEditor.dialog.pageTip')" @blur="validateCustomRange">
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <!-- 插入页面位置 -->
+      <p class="title">{{ $t('documentEditor.dialog.insertTo') }}</p>
+      <div class="container">
+        <div class="select-content">
+          <!-- 首页 -->
+          <div @click="place = 'first'" class="option"><RadioBtnSel v-if="place === 'first'" /><RadioBtnDis v-else />{{ $t('documentEditor.dialog.firstPage') }}</div>
+          <!-- 末页 -->
+          <div @click="place = 'last'" class="option"><RadioBtnSel v-if="place === 'last'" /><RadioBtnDis v-else />{{ $t('documentEditor.dialog.lastPage') }}</div>
+          <!-- 自定义位置 -->
+          <div @click="place = 'custom'" class="option" :class="{ 'custom-page': place === 'custom' }">
+            <div class="left">
+              <RadioBtnSel v-if="place === 'custom'" /><RadioBtnDis v-else />{{ $t('documentEditor.dialog.page') }}
+            </div>
+            <div v-if="place === 'custom'" class="right">
+              <div class="addition">
+                <input v-model.number="targetIndex" type="number" :max="totalPages" min="1" pattern="\d*" onkeypress="return (/[\d]/.test(String.fromCharCode(event.keyCode)))" @input="validatePageInput" @blur="onblurPageInput">
+                <span>/ {{ totalPages }}</span>
+              </div>
+              <div class="addition">
+                <select name="targetPlace" v-model="targetPlace">
+                  <option :value="0">{{ $t('documentEditor.dialog.before') }}</option>
+                  <option :value="1">{{ $t('documentEditor.dialog.after') }}</option>
+                </select>
+                <ArrowDown />
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <!-- footer -->
+      <template #footer>
+        <div class="rect-button white" @click="closeDialog">{{ $t('cancel') }}</div>
+        <div class="rect-button blue" :class="{ 'disabled': type === 'pdf' && (!inputFile || (inputFileRange === 'custom' && !validCustomRange)) }" @click="confirm">{{ $t('documentEditor.insert') }}</div>
+      </template>
+    </Dialog>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, watch, h } from 'vue'
+import { useViewerStore } from '@/stores/modules/viewer'
+import core from '@/core'
+
+const props = defineProps(['selectedPageIndex', 'totalPages'])
+const emits = defineEmits(['insertPage'])
+
+const useViewer = useViewerStore()
+
+const dialogName = 'insertPageSettingDialog'
+const show = computed(() => useViewer.isElementOpen(dialogName))
+
+const type = ref('blank')
+const customPageSize = ref('A3')
+const inputFile = ref(null)
+const inputFileRange = ref('all')
+const customRange = ref('')
+const validCustomRange = ref('')
+
+const place = ref('first')
+const targetIndex = ref(1)
+const targetPlace = ref(0)
+const password = ref('')
+
+watch(() => show.value, (newVal, oldVal) => {
+  if (newVal) {
+    targetIndex.value = props.selectedPageIndex > 0 ? props.selectedPageIndex : 1
+  }
+})
+
+// 关闭弹窗
+const closeDialog = () => {
+  useViewer.closeElement(dialogName)
+
+  // 初始化
+  type.value = 'blank'
+  customPageSize.value = 'A3'
+  inputFile.value = null
+  inputFileRange.value = 'all'
+  customRange.value = ''
+  validCustomRange.value = ''
+
+  place.value = 'first'
+  targetPlace.value = 0
+  password.value = ''
+}
+
+// 确认
+const confirm = async () => {
+  const data = {
+    type: type.value === 'pdf' ? 'pdf' : 'blank',
+  }
+
+  if (type.value === 'custom') {
+    // A3纸的尺寸是297mm × 420mm
+    // A4纸的尺寸是210mm × 297mm
+    // A5纸的尺寸是148mm × 210mm
+    // 1px = 25.4 * 1mm / PPI
+    const pageSize = {
+      A3: {
+        width: 297,
+        height: 420
+      },
+      A4: {
+        width: 210,
+        height: 297
+      },
+      A5: {
+        width: 148,
+        height: 210
+      }
+    }
+    data.size = {
+      width: pageSize[customPageSize.value].width / 25.4 * 72,
+      height: pageSize[customPageSize.value].height / 25.4 * 72
+    }
+  }
+
+  if (type.value === 'pdf' && inputFile.value) {
+    data.file = inputFile.value
+    data.range = inputFileRange.value === 'custom' ? customRange.value : inputFileRange.value
+    data.password = password.value
+  }
+
+  if (place.value === 'custom') {
+    data.place = targetIndex.value - 1 + targetPlace.value
+    data.targetPlace = targetPlace.value
+  } else if (place.value === 'last') {
+    data.place = props.totalPages
+  } else {
+    data.place = 0
+  }
+
+  emits('insertPage', data)
+  closeDialog()
+}
+
+// 上传文件
+const handleFile = async (e) => {
+  if (!e.target.files[0]) return
+
+  let url = URL.createObjectURL(e.target.files[0])
+  const pass = await core.checkPassword(url, true)
+  if (pass === false) {
+    e.target.value = ''
+    return
+  }
+  
+  inputFile.value = e.target.files[0]
+  password.value = pass
+}
+
+// 输入页码校验
+const validatePageInput = (e) => {
+  const value = e.target.valueAsNumber
+  if (value > props.totalPages) {
+    targetIndex.value = props.totalPages
+  } else if (value < 1) {
+    targetIndex.value = 1
+  }
+}
+const onblurPageInput = () => {
+  if (!targetIndex.value) targetIndex.value = 1
+}
+
+// 输入自定义页面范围校验 格式化
+const validateCustomRange = () => {
+  validCustomRange.value = formatText(customRange.value)
+  customRange.value = validCustomRange.value
+}
+const formatText = (inputText) => {
+  const text = inputText.replace(/\s/g, '')
+  const matches = text.match(/\d+-\d+|\d+|\d+/g)
+
+  if (matches) {
+    const sortedNums = matches
+      .flatMap(match => {
+        if (match.includes('-')) {
+          const [start, end] = match.split('-')
+          const smallest = Math.min(start, end)
+          const largest = Math.max(start, end)
+          return Array.from({ length: largest - smallest + 1 }, (_, i) =>
+            String(Number(smallest) + i)
+          )
+        } else {
+          return match
+        }
+      })
+      .sort((a, b) => a - b)
+
+    const formattedText = []
+
+    let start = sortedNums[0]
+    let prev = sortedNums[0]
+
+    for (let i = 1; i < sortedNums.length; i++) {
+      const current = sortedNums[i]
+
+      const prevNum = Number(prev)
+      const currentNum = Number(current)
+
+      if (currentNum - prevNum > 1) {
+        formattedText.push(formatRange(start, prev))
+        start = current
+      }
+      prev = current
+    }
+    formattedText.push(formatRange(start, prev))
+    return formattedText.join(', ')
+  }
+  return '1'
+}
+const formatRange = (start, end) => {
+  return (start === end) ? start.toString() : `${start}-${end}`
+}
+</script>
+
+<style lang="scss">
+.add-page-setting-popup {
+  .dialog-container {
+    width: 420px;
+    box-shadow: 0px 4px 32px 0px var(--c-doc-editor-popup-shadow);
+
+    main {
+      margin: 0;
+    }
+  }
+
+  .title {
+    font-size: 16px;
+    font-weight: 700;
+    line-height: 24px;
+  }
+
+  .container {
+    margin-top: 8px;
+    margin-bottom: 16px;
+    padding: 8px 16px 16px;
+    border-radius: 4px;
+    background: var(--c-toolbar-bg);
+
+    .select-content .option:not(:first-child) {
+      margin-top: 16px;
+    }
+    
+    .addition {
+      position: relative;
+      display: flex;
+      align-items: center;
+
+      & + .addition {
+        margin-top: 4px;
+      }
+      
+      span {
+        white-space: nowrap;
+        font-size: 14px;
+        line-height: 16px;
+      }
+
+      svg {
+        position: absolute;
+        right: 4px;
+        pointer-events: none;
+      }
+
+      select,
+      input,
+      .file-input-underbox {
+        padding: 0 20px 0 8px;
+        width: 100%;
+        height: 24px;
+        background: var(--c-right-side-content-fillbox-bg);
+        border: 1px solid var(--c-right-side-content-fillbox-border);
+        border-radius: 1px;
+        font-size: 14px;
+        line-height: 16px;
+      }
+
+      input,
+      select {
+        color: var(--c-text);
+      }
+
+      select {
+        -webkit-appearance: none;
+        -moz-appearance: none;
+        appearance: none;
+      }
+
+      .file-input-underbox {
+        display: flex;
+        align-items: center;
+        color: #999;
+
+        .uploaded {
+          color: var(--c-text);
+          overflow: hidden;
+          text-overflow: ellipsis;
+        }
+
+        svg {
+          color: var(--c-side-outline-text);
+        }
+      }
+
+      input[type="file"] {
+        position: absolute;
+        width: 100%;
+        height: 100%;
+        opacity: 0;
+        cursor: pointer;
+
+        &::file-selector-button {
+          cursor: pointer;
+        }
+      }
+
+      &.from-pdf {
+        align-items: baseline;
+
+        span {
+          margin-right: 8px;
+        }
+
+        > div {
+          flex: 1;
+
+          input[type="text"] {
+            margin-top: 4px;
+          }
+        }
+      }
+
+      input[type="text"]:focus {
+        border-color: #0078D7;
+      }
+    }
+
+    .option.custom-page {
+      padding: 0;
+      align-items: baseline;
+
+      .left {
+        display: flex;
+        align-items: center;
+      }
+
+      .right {
+        flex: 1;
+        margin-left: 20px;
+
+        .addition {
+          span {
+            margin-left: 8px;
+            color: #999;
+          }
+
+          &:last-child {
+            margin-top: 16px;
+            max-width: 70%;
+          }
+        }
+      }
+    }
+  }
+}
+</style>

+ 147 - 0
packages/webview/src/components/Dialogs/MovePageSettingDialog.vue

@@ -0,0 +1,147 @@
+<template>
+  <div class="move-page-popup" v-if="show">
+    <Dialog :show="show" :dialogName="dialogName" :close="true">
+      <template #header>
+        <p>{{ $t('documentEditor.moveTo') }}</p>
+      </template>
+
+      <input v-model.number="targetIndex"
+        type="number"
+        pattern="\d*"
+        onkeypress="return (/[\d]/.test(String.fromCharCode(event.keyCode)))"
+        @input="validateInput"
+        :placeholder="$t('documentEditor.inputPageTip')"
+        @compositionstart="startComposition" @compositionend="endComposition"
+      >
+
+      <template #footer>
+        <div class="rect-button blue" :class="{ 'disabled': invalidMove }" @click="handleMove">{{ $t('ok') }}</div>
+      </template>
+    </Dialog>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed } from 'vue'
+import { useViewerStore } from '@/stores/modules/viewer'
+
+const props = defineProps(['totalPages', 'selectedPageList'])
+const emits = defineEmits(['movePage'])
+
+const useViewer = useViewerStore()
+
+const dialogName = 'movePageSettingDialog'
+const show = computed(() => useViewer.isElementOpen(dialogName))
+
+const targetIndex = ref('')
+let composing = false
+
+const invalidMove = computed(() => {
+  if (typeof targetIndex.value !== 'number') return true
+  const selected = props.selectedPageList
+  if (selected.length === 1 && (selected[0] === targetIndex.value || selected[0] === targetIndex.value - 1)) return true
+  if (selected.length > 1 && isConsecutive(selected) && (selected.includes(targetIndex.value) || selected.includes(targetIndex.value - 1))) return true
+  return false
+})
+
+// 是否是连续的数字
+const isConsecutive = (arr) => {
+  arr.sort(function(a, b) {
+    return a - b
+  })
+
+  for (var i = 0; i < arr.length - 1; i++) {
+    if (arr[i] + 1 !== arr[i + 1]) {
+      return false
+    }
+  }
+
+  return true
+}
+
+const closeDialog = () => {
+  useViewer.closeElement(dialogName)
+  targetIndex.value = ''
+}
+
+const handleMove = async () => {
+  emits('movePage', targetIndex.value)
+  closeDialog()
+}
+
+const validateInput = (e) => {
+  if (composing) return
+
+  const value = e.target.valueAsNumber
+
+  if (isNaN(value)) {
+    e.target.value = ''
+  }
+  
+  if (value > props.totalPages) { 
+    targetIndex.value = props.totalPages
+  }
+  if (value === 0) {
+    e.target.value = value.toFixed(0)
+  }
+}
+
+const startComposition = () => {
+  composing = true
+}
+const endComposition = () => {
+  composing = false
+}
+</script>
+
+<style lang="scss">
+.move-page-popup {
+
+  .dialog-container {
+    width: 289px;
+
+    .close {
+      float: right;
+      margin-top: 3px;
+    }
+
+    main {
+      margin-top: 20px;
+    }
+
+    footer {
+      .rect-button {
+        width: 100%;
+      }
+    }
+  }
+
+  input[type="number"] {
+    padding: 0 20px 0 8px;
+    width: 100%;
+    height: 24px;
+    background: var(--c-right-side-content-fillbox-bg);
+    border: 1px solid var(--c-right-side-content-fillbox-border);
+    border-radius: 1px;
+    font-size: 14px;
+    line-height: 16px;
+    color: var(--c-text);
+    -moz-appearance: textfield;
+
+    &:focus {
+      border-color: #0078D7;
+    }
+
+    &::-webkit-inner-spin-button,
+    &::-webkit-outer-spin-button {
+      -webkit-appearance: none;
+      appearance: none;
+    }
+
+    &::placeholder {
+      font-size: 12px;
+      color: #999;
+    }
+  }
+}
+</style>

+ 6 - 6
packages/webview/src/components/Dialogs/PreventDialog.vue

@@ -1,10 +1,10 @@
 <template>
-  <div v-if="isModalVisible" class="edit-dialog">
+  <div v-if="contentEditorVisible || docEditorVisible" class="edit-dialog">
     <div class="edit">
       <div class="close"><Close @click="handleClose" /></div>
       <div class="warning"><Warning /></div>
       <div class="title">{{ $t('prevent.title') }}</div>
-      <div class="des">{{ $t('prevent.description') }}</div>
+      <div class="des">{{ contentEditorVisible ? $t('prevent.description') : $t('prevent.description1') }}</div>
       <div class="button" @click="handleClose">{{ $t('ok') }}</div>
     </div>
   </div>
@@ -16,10 +16,10 @@
 
   const useViewer = useViewerStore()
 
-  const isModalVisible = computed(() => useViewer.isElementOpen('preventDialog'))
-  const handleClose = () => {
-    useViewer.closeElement('preventDialog')
-  }
+  const contentEditorVisible = computed(() => useViewer.isElementOpen('contentEditorPreventDialog'))
+  const docEditorVisible = computed(() => useViewer.isElementOpen('docEditorPreventDialog'))
+
+  const handleClose = () => useViewer.closeElement(contentEditorVisible.value ? 'contentEditorPreventDialog' : 'docEditorPreventDialog')
 </script>
 
 <style lang="scss">

+ 22 - 7
packages/webview/src/components/DocumentContainer/DocumentContainer.vue

@@ -1,6 +1,7 @@
 <template>
   <div ref="mainContainer" class="document-container"
-    :class="{ 'no-select': isHandActive, 'under': toolMode === 'compare' && compareStatus !== 'finished' }" :style="{
+    :class="{ 'no-select': isHandActive, 'under': (toolMode === 'compare' && compareStatus !== 'finished') || toolMode === 'document' }"
+    :style="{
       width: `calc(100% - ${leftPanelSpace}px - ${rightPanelSpace}px)`,
       'margin-left': `${leftPanelSpace}px`,
       'margin-right': `${rightPanelSpace}px`,
@@ -48,8 +49,7 @@
   <PreventDialog />
   <MeasurePop />
   <div v-if="loading && loadingPercent < 100" class="loading-state">{{ $t('loading') }}...</div>
-  <div v-show="!load && loadingPercent <= 0 && activePanelTab !== 'COMPARISON' && toolMode !== 'compare'"
-    class="upload-container">
+  <div v-show="!load && loadingPercent <= 0 && activePanelTab !== 'COMPARISON' && ['compare', 'document'].includes(toolMode)" class="upload-container">
     <input id="fileInput" type="file" accept=".pdf" @change="handleUpload" />
     <label for="fileInput">{{ $t('upload') }}</label>
   </div>
@@ -81,7 +81,7 @@ const rightPanelSpace = computed(() => {
 const toolMode = computed(() => useViewer.getToolMode)
 const compareStatus = computed(() => useViewer.getCompareStatus)
 const topSpace = computed(() => {
-  return useViewer.getToolMode === 'view' || useViewer.getToolMode === 'sign' || (toolMode.value === 'compare' && compareStatus.value !== 'finished') ? 0 : 44
+  return ['view', 'sign', 'document'].includes(useViewer.getToolMode) || (toolMode.value === 'compare' && compareStatus.value !== 'finished') ? 0 : 44
 })
 const loading = computed(() => useViewer.getUploadLoading && useViewer.getUpload)
 const activePanelTab = computed(() => useViewer.getActiveElementTab('leftPanelTab'))
@@ -142,7 +142,7 @@ async function handlePdf (pdf, filename = null) {
   try {
     const { pwd } = await loadDocument(pdf, options)
     useDocument.setFileHasPwd(pwd)
-    useDocument.setCurrentPdfData({ pdf, options })
+    useDocument.setCurrentPdfData(pdf, options)
     useDocument.setOutline(core.getOutlines())
     const toolModeChanged = (mode) => {
       if (mode === toolMode.value) return
@@ -153,11 +153,10 @@ async function handlePdf (pdf, filename = null) {
     console.log(error)
     if (error === 'invalid_file_error' || error === 'no_password_given' || error === 'incorrect_password') {
       useViewer.setUpload(false)
-      useDocument.setLoadingProgress(0)
       useViewer.resetSetting()
       const openFileInput = document.getElementById("fileInput")
       openFileInput.value = ''
-      useDocument.setCurrentPdfData(null)
+      useDocument.setCurrentPdfData()
       return
     }
   }
@@ -203,6 +202,22 @@ window.instance.initOptions = async (options) => {
   } else {
     useViewer.setUpload(false)
   }
+
+  core.addEvent('onPagesUpdated', () => {
+    useDocument.setOutline(core.getOutlines())
+    const totalPages = core.getPagesCount()
+    const scale = core.getScale()
+    useDocument.setTotalPages(totalPages)
+    useViewer.setCurrentPage(1)
+    useViewer.setCurrentScale(scale)
+
+    const scrollMode = useViewer.getScrollMode
+    const pageMode = useViewer.getPageMode
+
+    const modeNum = scrollMode === 'Vertical' ? 0 : 1
+    core.switchScrollMode(modeNum)
+    core.switchSpreadMode(pageMode)
+  })
 }
 // window.instance.initOptions()
 </script>

+ 761 - 0
packages/webview/src/components/DocumentEditorContainer/DocumentEditorContainer.vue

@@ -0,0 +1,761 @@
+<template>
+  <div class="document-editor-container" ref="docEditorContainer">
+    <!-- 编辑工具 -->
+    <div class="tools-container">
+      <Button class="with-text" @click="openInsertPageDialog">
+        <InsertPage />
+        <span>{{ $t('documentEditor.insert') }}</span>
+      </Button>
+      <Button class="with-text" :class="{ disabled: !selectedPageList.length || selectedPageList.length == pageList.length  }" @click="openDeletePageDialog">
+        <DeletePage />
+        <span>{{ $t('documentEditor.delete') }}</span>
+      </Button>
+      <Button class="with-text" :class="{ disabled: !selectedPageList.length }" @click="rotatePage(1)">
+        <RotatePageRight />
+        <span>{{ $t('documentEditor.rotateRight') }}</span>
+      </Button>
+      <Button class="with-text" :class="{ disabled: !selectedPageList.length }" @click="rotatePage(-1)">
+        <RotatePageLeft />
+        <span>{{ $t('documentEditor.rotateLeft') }}</span>
+      </Button>
+      <Button class="with-text" :class="{ disabled: !selectedPageList.length }" @click="copyPage">
+        <CopyPage />
+        <span>{{ $t('documentEditor.copy') }}</span>
+      </Button>
+      <Button class="with-text" :class="{ disabled: !selectedPageList.length }" @click="openExtractPageDialog">
+        <ExtractPage />
+        <span>{{ $t('documentEditor.extract') }}</span>
+      </Button>
+      <Button class="with-text" :class="{ disabled: !selectedPageList.length }" @click="replacePage">
+        <ReplacePage />
+        <span>{{ $t('documentEditor.replace') }}</span>
+      </Button>
+      <Button class="with-text" :class="{ disabled: !selectedPageList.length }" @click="openMovePageDialog">
+        <MovePage />
+        <span>{{ $t('documentEditor.move') }}</span>
+      </Button>
+      <Button class="select-all" @click="selectAll">
+        <UnselectAll v-if="selectedPageList.length === pageList.length" />
+        <SelectAll v-else />
+      </Button>
+    </div>
+
+    <!-- 页面展示 -->
+    <div class="page-container" ref="dragContainer" :style="{ 'margin-left': marginLeft + 'px' }">
+      <div v-for="(item, index) in pageList" :key="`${item.type} - ${index}`" class="page"
+        :class="{
+          'selected': selectedPageList.includes(index),
+          'drag-indicate-right': dragToIndex === index && !isDragToLeft,
+          'drag-indicate-left': dragToIndex + 1 === index && isDragToLeft,
+          'drag-indicate-disable': dragFromIndex >= 0 && dragToIndex >= -1 && dragIndicateDisable(index),
+          'has-hover': !isMobileDevice && dragFromIndex < 0 && !selectedPageList.includes(index)
+        }"
+        ref="imgBoxEl"
+      >
+        <div class="img-box" @click="selectPage(index)">
+          <img v-if="item.img" :src="item.img" :style="`transform: rotate(${rotationToAngle(item.rotation)}deg);`" />
+          <div v-else-if="item.type === 'blank'"
+            :class="{ 'blank-page': item.type === 'blank' }"
+            :style="{ 'transform': 'rotate(' + rotationToAngle(item.rotation) + 'deg)', 'width': blankPageScaleSize(item.size).width + 'px', 'height': blankPageScaleSize(item.size).height + 'px' }">
+          </div>
+        </div>
+        <p>{{ index + 1 }}</p>
+      </div>
+    </div>
+
+  </div>
+  <InsertPageSettingDialog @insertPage="handleInsertPage" :selectedPageIndex="selectedPageList.length === 1 ? selectedPageList[0] + 1 : -1" :totalPages="pageList.length" />
+  <DeletePageDialog @deletePage="handleDeletePage" :selectedPageList="selectedPageList" />
+  <ExtractPageSettingDialog @extractPage="handleExtractPage" :totalPages="pageList.length" :selectedPageList="selectedPageList" />
+  <MovePageSettingDialog @movePage="handleMovePage" :totalPages="pageList.length" :selectedPageList="selectedPageList" />
+</template>
+
+<script setup>
+import { ref, onMounted, reactive, onUnmounted, computed } from 'vue'
+import { useViewerStore } from '@/stores/modules/viewer'
+import { useDocumentStore } from '@/stores/modules/document'
+import core from '@/core'
+import { isMobileDevice } from '@/helpers/device'
+
+const { Sortable } = defineProps(['Sortable'])
+
+const useViewer = useViewerStore()
+const useDocument = useDocumentStore()
+
+const data = reactive({
+  pageList: [], // 展示的所有页面
+  selectedPageList: [], // 选中的页面index列表
+})
+let { pageList, selectedPageList } = data
+
+const dragContainer = ref(null)
+const dragFromIndex = ref(-2)
+const dragToIndex = ref(-2)
+const isDragToLeft = ref(false)
+const imgBoxEl = ref(null)
+const marginLeft = ref(0)
+const docEditorContainer = ref(null)
+
+const ratio = window.devicePixelRatio || 1
+const isMobileWidth = window.innerWidth < 768
+let timer = null
+
+onMounted(() => {
+  Sortable.create(dragContainer.value, {
+    multiDrag: true,
+    selectedClass: 'sortable-selected',
+    // handle: '.img-box',
+    dragClass: "sortable-drag",
+    sort: false,
+    forceFallback: true,
+    fallbackTolerance: isMobileDevice ? 0 : 20,
+    onStart: dragStart,
+    onEnd: dragEnd,
+    delay: isMobileDevice ? 200 : 0
+  })
+
+  const { width, height } = imgBoxEl.value[0].getBoundingClientRect()
+  const pageSize = {
+    width,
+    height
+  }
+  core.getDocEditorPages(pageSize)
+
+  window.addEventListener('resize', updateMarginLeft)
+  updateMarginLeft()
+})
+
+onUnmounted(() => {
+  core.removeEvent('getDocEditorPages', getPages)
+  window.addEventListener('resize', updateMarginLeft)
+})
+
+// 初始化 获取所有页面数量和序号
+const totalPages = core.getPagesCount()
+for (let i = 0; i < totalPages; i++) {
+  pageList.push({
+    type: 'page'
+  })
+}
+
+// 获取单个页面图片
+const getPages = (data) => {
+  const { index, pageData } = data
+  pageList[index] = pageData
+}
+core.addEvent('getDocEditorPages', getPages)
+
+// 打开插入页面选项弹窗
+const openDeletePageDialog = () => {
+  useViewer.openElement('deletePageDialog')
+}
+
+// 插入页面
+const handleInsertPage = async (data) => {
+  const operation = {
+    type: 'insert',
+    pageIndex: data.place
+  }
+  
+  if (data.type === 'blank') {
+    // 添加数据到用于展示的pageList
+    const adjacentPage = data.targetPlace ? pageList[data.place - 1] : (pageList[data.place] || pageList[data.place - 1])
+    const pageListData = {
+      type: data.type,
+      rotation: 0,
+      size: data.size || {
+        width: adjacentPage.size.width,
+        height: adjacentPage.size.height
+      }
+    }
+    pageList.splice(data.place, 0, pageListData)
+
+    // 要传到core处理的数据
+    operation.size = pageListData.size
+    core.saveDocumentEdit(operation)
+
+  } else if (data.type === 'pdf') {
+    operation.file = data.file
+    operation.range = data.range
+    operation.password = data.password || ''
+    
+    const { width, height } = imgBoxEl.value[0].getBoundingClientRect()
+
+    const totalPages = await core.saveDocumentEdit(operation)
+    const oldLenth = pageList.length
+    for (let i = 0; i < totalPages - oldLenth; i++) {
+      pageList.splice(data.place + i, 0, { type: 'page' })
+    }
+
+    const pageSize = {
+      width,
+      height
+    }
+    core.getDocEditorPages(pageSize, data.place, totalPages - oldLenth)
+  }
+  useDocument.setDocEditorOperationList(operation)
+  selectedPageList.length = 0
+}
+
+// 计算空白页的显示大小
+const blankPageScaleSize = (size) => {
+  const { width, height } = size
+  const boxWidth = isMobileWidth ? 140 : 204
+  const boxHeight = isMobileWidth ? 141 : 216
+
+  const scaleWidth = width / ratio
+  const scaleHeight = height / ratio
+
+  const widthRatio = boxWidth / scaleWidth
+  const heightRatio = boxHeight / scaleHeight
+
+  let scaleRatio = Math.min(widthRatio, heightRatio)
+
+  if (scaleRatio > 1) {
+    scaleRatio = 1
+  }
+
+  const scaledWidth = scaleWidth * scaleRatio
+  const scaledHeight = scaleHeight * scaleRatio
+
+  return { width: scaledWidth, height: scaledHeight}
+}
+
+// 选中页面
+const selectPage = (index) => {
+  const i = selectedPageList.indexOf(index)
+  i === -1 ? selectedPageList.push(index) : selectedPageList.splice(i, 1)
+}
+
+// 全选
+const selectAll = () => {
+  if (selectedPageList.length < pageList.length) {
+    selectedPageList.length = 0
+    for (let i = 0; i < pageList.length; i++) {
+      selectedPageList.push(i)
+    }
+  } else {
+    selectedPageList.length = 0
+  }
+}
+
+// 打开删除页面弹窗
+const openInsertPageDialog = () => {
+  useViewer.openElement('insertPageSettingDialog')
+}
+
+// 删除页面
+const handleDeletePage = () => {
+  selectedPageList.sort((a, b) => b - a)
+  selectedPageList.forEach(index => {
+    pageList.splice(index, 1)
+  })
+
+  const operation = {
+    type: 'delete',
+    selectedPageIndexArray: Array.from(selectedPageList)
+  }
+  core.saveDocumentEdit(operation)
+  useDocument.setDocEditorOperationList(operation)
+  selectedPageList.length = 0
+}
+
+// 旋转页面
+const rotatePage = (rotation) => {
+  selectedPageList.forEach(index => {
+    const page = pageList[index]
+    page.rotation += rotation
+    if (page.rotation > 3 || page.rotation < -3) page.rotation = 0
+  })
+
+  const operation = {
+    type: 'rotate',
+    selectedPageIndexArray: Array.from(selectedPageList),
+    rotation
+  }
+  core.saveDocumentEdit(operation)
+  useDocument.setDocEditorOperationList(operation)
+}
+// 旋转角度值(为:-3,-2,-1,0,1,2,3)(0是0度,1是90度,2是180度,3是270度)
+const rotationToAngle = (rotation) => {
+  const map = new Map([[-3, -270], [-2, 180], [-1, -90], [0, 0], [1, 90], [2, 180], [3, 270]])
+  return map.get(rotation)
+}
+
+// 复制页面
+const copyPage = () => {
+  selectedPageList.sort((a, b) => b - a)
+  selectedPageList.forEach(index => {
+    pageList.splice(index, 0, { ...pageList[index] })
+  })
+
+  const operation = {
+    type: 'copy',
+    selectedPageIndexArray: Array.from(selectedPageList)
+  }
+  core.saveDocumentEdit(operation)
+  useDocument.setDocEditorOperationList(operation)
+  selectedPageList.length = 0
+}
+
+// 替换页面
+const replacePage = () => {
+  const fileInput = document.createElement('input')
+  fileInput.type = 'file'
+  fileInput.accept = '.pdf'
+  fileInput.click()
+
+  fileInput.addEventListener('change', async function(event) {
+    const file = event.target.files[0]
+    const startIndex = selectedPageList[0]
+
+    let url = URL.createObjectURL(file)
+    const pass = await core.checkPassword(url, true)
+    if (pass === false) return
+
+    handleInsertPage({
+      type: 'pdf',
+      file,
+      range: 'all',
+      place: startIndex,
+      password: pass
+    })
+    handleDeletePage()
+  })
+}
+
+// 打开提取页面弹窗
+const openExtractPageDialog = () => {
+  useViewer.openElement('extractPageSettingDialog')
+}
+
+// 提取页面
+const handleExtractPage = async (data) => {
+  let exportRange = data.range.replace(/\s/g, '')
+  const pagesCount = pageList.length
+  if (data.range === 'all') {
+    exportRange = '1-' + pagesCount
+    // exportRange = ''
+  } else if (data.range === 'odd') {
+    exportRange = Array.from({ length: pagesCount }, (_, i) => i + 1).filter(num => num % 2 !== 0).join(",")
+  } else if (data.range === 'even') {
+    exportRange = Array.from({ length: pagesCount }, (_, i) => i + 1).filter(num => num % 2 === 0).join(",")
+  }
+
+  let parts = exportRange.split(',')
+  let indexArray = []
+
+  parts.forEach(part => {
+    if (part.includes('-')) {
+      let [start, end] = part.split('-').map(num => parseInt(num))
+      for (let i = start; i <= end; i++) {
+        indexArray.push(i - 1)
+      }
+    } else if (part) {
+      indexArray.push(parseInt(part) - 1)
+    }
+  })
+
+  const operation = {
+    type: 'extract',
+    range: data.separateFile ? indexArray : exportRange,
+    separateFile: data.separateFile
+  }
+  await core.saveDocumentEdit(operation)
+  useDocument.setDocEditorOperationList(operation)
+
+  if (data.deleteAfter) {
+    indexArray.reverse().forEach(index => {
+      pageList.splice(index, 1)
+    })
+
+    const operation = {
+      type: 'delete',
+      selectedPageIndexArray: indexArray
+    }
+    core.saveDocumentEdit(operation)
+    useDocument.setDocEditorOperationList(operation)
+    selectedPageList.length = 0
+  }
+}
+
+// 打开移动页面弹窗
+const openMovePageDialog = () => {
+  useViewer.openElement('movePageSettingDialog')
+}
+
+// 移动页面
+const handleMovePage = (targetIndex) => {
+  if (selectedPageList.length === 1 ||
+    (selectedPageList.length < 2 && dragFromIndex.value >= 0) ||
+    (selectedPageList.length > 1 && dragFromIndex.value >= 0 && !selectedPageList.includes(dragFromIndex.value))
+  ) { // 移动一页
+    const dragSelectedOrNoDrag = (selectedPageList.length === 1 && selectedPageList.includes(dragFromIndex.value)) || (selectedPageList.length === 1 && dragFromIndex.value < 0)
+    const index = dragSelectedOrNoDrag ? selectedPageList[0] : [dragFromIndex.value]
+    const tIndex = index > targetIndex ? targetIndex : targetIndex - 1
+    if (index === tIndex) return
+    
+    const [page] = pageList.splice(index, 1)
+    pageList.splice(tIndex, 0, page)
+
+    const operation = {
+      type: 'move',
+      pageIndex: index,
+      targetPageIndex: tIndex
+    }
+    core.saveDocumentEdit(operation)
+    useDocument.setDocEditorOperationList(operation)
+    if (dragSelectedOrNoDrag) selectedPageList[0] = tIndex
+
+  } else { // 移动多页
+    selectedPageList.sort((a, b) => a - b)
+
+    for (let i = 0; i < selectedPageList.length; i++) {
+      let index, tIndex
+      if (selectedPageList[i] === targetIndex) {
+        index = selectedPageList[i]
+        tIndex = targetIndex
+      } else if (selectedPageList[i] > targetIndex) {
+        index = selectedPageList[i]
+        if (selectedPageList.includes(targetIndex)) {
+          tIndex = targetIndex
+          for (let j = 0; j < selectedPageList.length; j++) {
+            if (selectedPageList[j] === tIndex && index !== tIndex) tIndex++
+          }
+        } else {
+          tIndex = targetIndex
+        }
+      } else {
+        index = selectedPageList[i] - i
+        tIndex = targetIndex - 1
+      }
+
+      if (selectedPageList.includes(index) && selectedPageList.includes(tIndex) && index === tIndex) continue
+
+      const [page] = pageList.splice(index, 1)
+      pageList.splice(tIndex, 0, page)
+
+      const operation = {
+        type: 'move',
+        pageIndex: index,
+        targetPageIndex: tIndex
+      }
+      core.saveDocumentEdit(operation)
+      useDocument.setDocEditorOperationList(operation)
+      selectedPageList[i] = selectedPageList[i] > targetIndex ? tIndex : tIndex - i
+    }
+  }
+}
+
+// 拖拽开始
+const dragStart = (e) => {
+  dragFromIndex.value = e.oldIndex
+  dragContainer.value.addEventListener(isMobileDevice ? 'touchmove' : 'mousemove', dragging)
+
+  if (isMobileDevice) {
+    document.body.style.overscrollBehavior = 'none'
+    document.body.style.userSelect = 'none'
+    document.getElementById('app').style.touchAction = 'none'
+    docEditorContainer.value.style.overflow = 'hidden'
+  }
+}
+
+// 拖拽中
+const dragging = (e) => {
+  if (selectedPageList.length > 1 && selectedPageList.includes(dragFromIndex.value)) {
+    dragContainer.value.querySelector('.sortable-drag').style.setProperty("--after-content", "'" + selectedPageList.length + "'")
+  }
+
+  let target = isMobileDevice ? document.elementFromPoint(e.touches[0].clientX, e.touches[0].clientY) : e.target
+  const pageDomList = dragContainer.value.children
+
+  for (let i = 0; i < pageDomList.length; i++) {
+    const element = pageDomList[i]
+
+    if (element.contains(target)) {
+      const domRect = element.getBoundingClientRect()
+      const offsetX = isMobileDevice ? (e.touches[0].clientX - domRect.left) : (e.offsetX + 30)
+      isDragToLeft.value = offsetX < domRect.width / 2
+
+      if (isDragToLeft.value) {
+        dragToIndex.value = i - 1
+      } else {
+        dragToIndex.value = i
+      }
+    }
+  }
+
+  if (isMobileDevice) handleAutoScroll(e.touches[0].clientY)
+}
+
+// 拖拽结束
+const dragEnd = () => {
+  dragContainer.value.removeEventListener(isMobileDevice ? 'touchmove' : 'mousemove', dragging)
+  if (dragToIndex.value > -2 && !dragIndicateDisable(dragToIndex.value)) {
+    handleMovePage(dragToIndex.value + 1)
+  }
+  dragFromIndex.value = -2
+  dragToIndex.value = -2
+
+  if (isMobileDevice) {
+    document.body.style.overscrollBehavior = 'auto'
+    document.body.style.userSelect = 'auto'
+    document.getElementById('app').style.touchAction = 'auto'
+    docEditorContainer.value.style.overflow = 'auto'
+  }
+}
+
+// 拖拽计算显示位置的竖条是否置灰
+const dragIndicateDisable = (index) => {
+  if (![index - 1, index, index + 1].includes(dragToIndex.value + 1)) return false
+
+  if (selectedPageList.includes(dragFromIndex.value)) {
+    if (selectedPageList.length === 1 && (selectedPageList[0] === dragToIndex.value + 1 || selectedPageList[0] === dragToIndex.value)) return true
+    if (selectedPageList.length > 1 && isConsecutive(selectedPageList) && (selectedPageList.includes(dragToIndex.value + 1) || selectedPageList.includes(dragToIndex.value))) return true
+  } else {
+    if ([dragFromIndex.value, dragFromIndex.value - 1].includes(dragToIndex.value)) return true
+  }
+
+  return false
+}
+
+// 是否是连续的数字
+const isConsecutive = (arr) => {
+  arr.sort(function(a, b) {
+    return a - b
+  })
+
+  for (var i = 0; i < arr.length - 1; i++) {
+    if (arr[i] + 1 !== arr[i + 1]) {
+      return false
+    }
+  }
+
+  return true
+}
+
+// 计算左边距
+const updateMarginLeft = () => {
+  const width = window.innerWidth
+  const pageWidth = width > 767 ? 280 : 170
+  marginLeft.value = parseInt(width % pageWidth / 2)
+}
+
+// 移动端处理自动滚动
+const handleAutoScroll = (clientY) => {
+  const threshold = 100
+  const containerEl = docEditorContainer.value
+  const containerRect = containerEl.getBoundingClientRect()
+  const containerHeight = containerRect.height
+  const deltaY = clientY
+  const totalHeight = containerEl.scrollHeight
+  
+  // 判断鼠标位置是否在阈值范围内
+  if (deltaY <= threshold || deltaY >= containerHeight - threshold) {
+    clearInterval(timer)
+    timer = setInterval(() => {
+      // 向上滚动
+      if (deltaY <= threshold) {
+        containerEl.scrollTop -= 10
+        if (containerEl.scrollTop <= 0) {
+          clearInterval(timer)
+        }
+      }
+      // 向下滚动
+      else if (deltaY >= containerHeight - threshold) {
+        containerEl.scrollTop += 10
+        if (containerEl.scrollTop + containerHeight >= totalHeight) {
+          clearInterval(timer)
+        }
+      }
+    }, 10)
+  } else {
+    // 鼠标位置不在阈值范围内,停止滚动
+    clearInterval(timer)
+  }
+}
+</script>
+
+<style lang="scss">
+.document-editor-container {
+  margin-top: 44px;
+  width: 100%;
+  background-color: var(--c-doc-editor-bg);
+  overflow: auto;
+
+  .tools-container {
+    position: absolute;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    margin-top: -44px;
+    width: 100%;
+    height: 44px;
+    z-index: 71;
+    background-color: var(--c-toolbar-bg);
+    border-bottom: 1px solid var(--c-toolbar-border);
+    opacity: 1;
+    overflow-x: auto;
+    overflow-y: hidden;
+
+    &.hidden {
+      display: none;
+    }
+
+    button {
+      &.disabled {
+        opacity: 1;
+        color: #999;
+      }
+
+      &:last-child {
+        margin-right: 0;
+      }
+    }
+  }
+
+  .page-container {
+    padding-bottom: 30px;
+    display: flex;
+    flex-flow: wrap;
+
+    .page {
+      position: relative;
+      padding: 8px;
+      margin: 30px 30px 0;
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      justify-content: space-between;
+      width: 220px;
+      height: 256px;
+      cursor: pointer;
+
+      .img-box {
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        width: 100%;
+        height: calc(100% - 24px);
+
+        img {
+          pointer-events: none;
+        }
+
+        img,
+        .blank-page {
+          max-width: 100%;
+          max-height: 100%;
+          border: 0.5px solid rgba(0, 0, 0, 0.12);
+          -webkit-touch-callout: none;
+          -ms-touch-action: manipulation;
+          touch-action: manipulation; 
+        }
+
+        .blank-page {
+          background-color: #fff;
+        }
+      }
+
+      p {
+        margin-top: 8px;
+        font-size: 12px;
+        line-height: 16px;
+        color: var(--c-right-side-header-text);
+        text-align: center;
+        user-select: none;
+      }
+
+      &.selected {
+        background-color: var(--c-header-button-active);
+      }
+
+      &.drag-indicate-right:after {
+        position: absolute;
+        top: 0;
+        right: -32px;
+        display: block;
+        content: '';
+        height: 100%;
+        width: 4px;
+        background-color: var(--c-popup-bg-active);
+      }
+
+      &.drag-indicate-left::before {
+        position: absolute;
+        top: 0;
+        left: -32px;
+        display: block;
+        content: '';
+        height: 100%;
+        width: 4px;
+        background-color: var(--c-popup-bg-active);
+      }
+
+      &.drag-indicate-disable::after, &.drag-indicate-disable::before {
+        background-color: var(--c-divider);
+      }
+
+      &.sortable-drag::after {
+        content: var(--after-content, '1');
+        position: absolute;
+        top: 10px;
+        left: 10px;
+        padding: 5px 20px;
+        border-radius: 50px;
+        background-color: var(--c-popup-bg-active);
+        color: white;
+      }
+    }
+  }
+}
+
+@media screen and (min-width: 820px) {
+  .document-editor-container {
+    .tools-container {
+      button.select-all {
+        position: absolute;
+        right: 16px;
+      }
+    }
+
+    .page-container .page.has-hover:hover {
+      background-color: rgba(0, 0, 0, 0.10);
+    }
+  }
+}
+
+@media screen and (max-width: 819px) {
+  .document-editor-container .tools-container {
+    padding: 0 10px;
+    justify-content: flex-start;
+  }
+}
+
+@media screen and (min-width: 578px) {
+  .document-editor-container .tools-container .button {
+    &:not(.disabled):hover {
+      background-color: var(--c-header-button-active);
+    }
+  }
+}
+
+@media screen and (max-width: 767px) {
+  .document-editor-container .page-container {
+    .page {
+      margin: 12px 10px;
+      padding: 5px;
+      width: 150px;
+      height: 175px;
+
+      &.drag-indicate-right:after {
+        right: -12px;
+      }
+
+      &.drag-indicate-left::before {
+        left: -12px;
+      }
+    }
+  }
+}
+</style>

+ 74 - 0
packages/webview/src/components/DocumentEditorHeader/DocumentEditorHeader.vue

@@ -0,0 +1,74 @@
+
+<template>
+  <div class="header-items document-editor-header">
+    <div class="rect-button white" @click="cancel">{{ $t('cancel') }}</div>
+    <div class="right-container">
+      <div class="rect-button blue" :class="{ 'disabled': !docEditorOperationList.length }" @click="save">{{ $t('documentEditor.save') }}</div>
+      <div class="rect-button blue" :class="{ 'disabled': !docEditorOperationList.length }" @click="saveFileAs">{{ $t('documentEditor.saveAs') }}</div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { computed, watch } from 'vue'
+import { useViewerStore } from '@/stores/modules/viewer'
+import { useDocumentStore } from '@/stores/modules/document'
+import core from '@/core'
+import { saveAs } from 'file-saver'
+
+const useViewer = useViewerStore()
+const useDocument = useDocumentStore()
+
+const docEditorOperationList = computed(() => useDocument.getDocEditorOperationList)
+const toolMode = computed(() => useViewer.getToolMode)
+const scrollMode = computed(() => useViewer.getScrollMode)
+const pageMode = computed(() => useViewer.getPageMode)
+
+watch(toolMode, (newToolMode, oldToolMode) => {
+  if (oldToolMode === 'document') {
+    useDocument.setDocEditorOperationList('reset')
+  }
+})
+
+const cancel = () => {
+  useDocument.setToolState('')
+  useViewer.setActiceToolMode('view')
+  useViewer.setCompareStatus('')
+  core.saveDocumentEdit('cancel')
+}
+
+const save = async () => {
+  useDocument.resetSetting()
+  useDocument.setToolState('')
+  useViewer.setActiceToolMode('view')
+  useViewer.setUpload(false)
+  useViewer.setUploadLoading(true)
+  const oldScale = useViewer.getScale
+
+  const { newUrl } = await core.saveDocumentEdit()
+  
+  useDocument.setCurrentPdfData(newUrl)
+  const totalPages = core.getPagesCount()
+  useDocument.setTotalPages(totalPages)
+  useDocument.setOutline(core.getOutlines())
+  useViewer.setCurrentPage(1)
+  core.switchScrollMode(scrollMode.value === 'Vertical' ? 0 : 1)
+  core.switchSpreadMode(pageMode.value)
+  useViewer.setCurrentScale(oldScale / 100)
+  core.scaleChanged(oldScale / 100)
+
+  useViewer.setUploadLoading(false)
+  useViewer.setUpload(true)
+}
+
+const saveFileAs = async () => {
+  const { blobData, filename } = await core.saveDocumentEdit('saveAs')
+  saveAs(blobData, filename)
+}
+</script>
+
+<style lang="scss">
+.document-editor-header {
+  justify-content: space-between;
+}
+</style>

+ 14 - 8
packages/webview/src/components/HeaderItems/HeaderItems.vue

@@ -1,5 +1,5 @@
 <template>
-  <div v-if="toolMode !== 'compare'" class="header-items">
+  <div v-if="toolMode !== 'compare' && toolMode !== 'document'" class="header-items">
     <template v-for="(item, index) in items" :key="`${item.type}-${item.dataElement || index}`">
       <CustomButton v-if="item.name === 'customButton' && !item.hidden && !item.dropItem" :item="item" />
       <ToggleElementButton v-else-if="item.type === 'toggleElementButton' && !item.hidden" :item="item" :class="{ disabled: !load }" :data-element="item.dataElement" />
@@ -38,6 +38,7 @@
             <div v-else-if="item.element === 'security' && !item.hidden" :data-element="item.dataElement" class="drop-item" :class="{ active: toolMode === 'security' }" @click="changeToolMode('security')">{{ $t('header.security') }}</div>
             <div v-else-if="item.element === 'compare' && !item.hidden" :data-element="item.dataElement" class="drop-item" :class="{ active: toolMode === 'compare' }" @click="changeToolMode('compare')">{{ $t('header.compare') }}</div>
             <div v-else-if="item.element === 'editor' && !item.hidden" :data-element="item.dataElement" class="drop-item" :class="{ active: toolMode === 'editor' }" @click="changeToolMode('editor')">{{ $t('header.editor') }}</div>
+          <div v-else-if="item.element === 'document' && !item.hidden" :data-element="item.dataElement" class="drop-item" :class="{ active: toolMode === 'document' }" @click="changeToolMode('document')">{{ $t('header.document') }}</div>
           </template>
         </div>
       </n-popover>
@@ -52,6 +53,7 @@
           <div v-else-if="item.element === 'security' && !item.hidden" :data-element="item.dataElement" class="item" :class="{ active: toolMode === 'security' }" @click="changeToolMode('security')">{{ $t('header.security') }}</div>
           <div v-else-if="item.element === 'compare' && !item.hidden" :data-element="item.dataElement" class="item" :class="{ active: toolMode === 'compare' }" @click="changeToolMode('compare')">{{ $t('header.compare') }}</div>
           <div v-else-if="item.element === 'editor' && !item.hidden" :data-element="item.dataElement" class="item" :class="{ active: toolMode === 'editor' }" @click="changeToolMode('editor')">{{ $t('header.editor') }}</div>
+          <div v-else-if="item.element === 'document' && !item.hidden" :data-element="item.dataElement" class="item" :class="{ active: toolMode === 'document' }" @click="changeToolMode('document')">{{ $t('header.document') }}</div>
         </template>
       </div>
     </div>
@@ -70,7 +72,7 @@
   </div>
 
   <!-- 文档对比模式 -->
-  <div v-else class="header-items">
+  <div v-else-if="toolMode === 'compare'" class="header-items">
     <template v-for="(item, index) in items" :key="`${item.type}-${item.dataElement || index}`">
       <FullScreenButton v-if="item.type === 'fullScreenButton' && !item.hidden" :item="item" :class="{ disabled: compareStatus !== 'finished' }" :data-element="item.dataElement" />
     </template>
@@ -85,6 +87,8 @@
       </template>
     </div>
   </div>
+
+  <DocumentEditorHeader v-else-if="toolMode === 'document'" />
 </template>
 
 <script setup>
@@ -115,7 +119,8 @@ let showToolMode = computed(()=>{
     sign: $t('header.signatures'),
     security: $t('header.security'),
     compare: $t('header.compare'),
-    editor: $t('header.editor')
+    editor: $t('header.editor'),
+    document: $t('header.document')
   }
   if (data[prop.toolMode]) {
     return data[prop.toolMode]
@@ -131,8 +136,9 @@ const changeToolMode = (mode) => {
     alert('Invalid license')
     return
   }
-  if (webviewerMode.value !== 'Standalone' && mode === 'editor') {
-    useViewer.openElement('preventDialog')
+  if (webviewerMode.value !== 'Standalone' && ['editor', 'document'].includes(mode)) {
+    if (mode === 'editor') useViewer.openElement('contentEditorPreventDialog')
+    else useViewer.openElement('docEditorPreventDialog')
     return
   }
   useViewer.setActiceToolMode(mode)
@@ -146,7 +152,7 @@ const changeToolMode = (mode) => {
 
   popoverMode.value.setShow(false)
 
-  if (mode === 'compare') {
+  if (mode === 'compare' || mode === 'document') {
     useViewer.resetPanels()
   }
 }
@@ -238,7 +244,7 @@ const openSignCreatePanel = () => {
     pointer-events: none;
   }
 
-@media screen and (max-width: 1320px) {
+@media screen and (max-width: 1536px) {
   .tool-container.pc {
     display: none !important;
   }
@@ -252,7 +258,7 @@ const openSignCreatePanel = () => {
     }
   }
 }
-@media screen and (min-width: 1321px) {
+@media screen and (min-width: 1537px) {
   .tool-container.mobile {
     display: none !important;
   }

File diff suppressed because it is too large
+ 7 - 0
packages/webview/src/components/Icon/CopyPage.vue


+ 7 - 0
packages/webview/src/components/Icon/DeletePage.vue

@@ -0,0 +1,7 @@
+<template>
+  <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
+    <g id="toolbar / pageedit / delete">
+      <path id="ic" fill-rule="evenodd" clip-rule="evenodd" d="M13 2.75V1.25H7V2.75H13ZM19 5.75V4.25H16.75H3.25H1V5.75H3.25V18.75H16.75V5.75H19ZM4.75 17.25V5.75H15.25V17.25H4.75ZM8.75 8.5V14.5H7.25V8.5H8.75ZM12.75 8.5V14.5H11.25V8.5H12.75Z" fill="currentColor"/>
+    </g>
+  </svg>
+</template>

File diff suppressed because it is too large
+ 17 - 0
packages/webview/src/components/Icon/DocumentPage.vue


+ 7 - 0
packages/webview/src/components/Icon/ExtractPage.vue

@@ -0,0 +1,7 @@
+<template>
+  <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
+    <g id="toolbar / pageedit / extract">
+      <path id="ic" fill-rule="evenodd" clip-rule="evenodd" d="M2.25 1.25H13.3107L17.75 5.68934V11H16.25V6.311L12.689 2.75H3.75V17.25H10V18.75H2.25V1.25ZM16.0174 16.25L15.1768 17.091L16.2374 18.1516L18.8891 15.5L16.2374 12.8483L15.1768 13.909L16.0174 14.75H11V16.25H16.0174Z" fill="currentColor"/>
+    </g>
+  </svg>
+</template>

+ 6 - 0
packages/webview/src/components/Icon/FileFolder.vue

@@ -0,0 +1,6 @@
+<template>
+  <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+    <path d="M6.82225 3.96144L6.99439 4.05909H7.19231H11.75V13.25H1.25V2.75H4.6867L6.82225 3.96144Z" stroke="currentColor" stroke-width="1.5"/>
+    <path d="M1.55867 13.25L3.84383 6.75H14.4413L12.1562 13.25H1.55867Z" fill="transparent" stroke="currentColor" stroke-width="1.5"/>
+  </svg>
+</template>

+ 7 - 0
packages/webview/src/components/Icon/InsertPage.vue

@@ -0,0 +1,7 @@
+<template>
+  <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
+    <g id="toolbar / pageedit / insert">
+      <path id="ic" fill-rule="evenodd" clip-rule="evenodd" d="M2.25 1.25H13.3107L17.75 5.68934V11H16.25V6.311L12.689 2.75H3.75V17.25H10V18.75H2.25V1.25ZM14.75 18H13.25V15.75H11V14.25H13.25V12H14.75V14.25H17V15.75H14.75V18Z" fill="currentColor"/>
+    </g>
+  </svg>
+</template>

+ 7 - 0
packages/webview/src/components/Icon/MovePage.vue

@@ -0,0 +1,7 @@
+<template>
+  <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
+    <g id="toolbar / pageedit / reverse">
+      <path id="ic" fill-rule="evenodd" clip-rule="evenodd" d="M10.9019 1.75H1.25V18.25H14.75V5.95882L10.9019 1.75ZM10.24 3.25L13.25 6.542V16.75H2.75V3.25H10.24ZM19.624 6.83397L16.25 1.77292V18.25H17.75V6.729L18.376 7.66603L19.624 6.83397Z" fill="currentColor"/>
+    </g>
+  </svg>
+</template>

+ 7 - 0
packages/webview/src/components/Icon/ReplacePage.vue

@@ -0,0 +1,7 @@
+<template>
+  <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
+    <g id="toolbar / pageedit / replace">
+      <path id="ic" fill-rule="evenodd" clip-rule="evenodd" d="M2.25 1H13.3107L17.75 5.43934V10.75H16.25V6.061L12.689 2.5H3.75V17H10V18.5H2.25V1ZM14.3454 12.7983L15.3689 11.7017L18.9028 15H11V13.5H15.097L14.3454 12.7983ZM14.5339 19.5L15.5574 18.4034L14.8058 17.7017H18.9028V16.2017H11L14.5339 19.5Z" fill="currentColor"/>
+    </g>
+  </svg>
+</template>

File diff suppressed because it is too large
+ 7 - 0
packages/webview/src/components/Icon/RotatePageLeft.vue


File diff suppressed because it is too large
+ 7 - 0
packages/webview/src/components/Icon/RotatePageRight.vue


+ 7 - 0
packages/webview/src/components/Icon/SelectAll.vue

@@ -0,0 +1,7 @@
+<template>
+  <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
+    <g id="toolbar / pageedit / all select">
+      <path id="Union" fill-rule="evenodd" clip-rule="evenodd" d="M17.5 2.5H6.5V13.5H17.5V2.5ZM6.5 1H5V2.5V2.9342H2H1.25V3.6842V18V18.75H2H16.3158H17.0658V18V15H17.5H19V13.5V2.5V1H17.5H6.5ZM2.75 4.4342H5V13.5V15H6.5H15.5658V17.25H2.75V4.4342ZM15.4446 6.94453L15.9749 6.4142L14.9142 5.35355L14.3839 5.88388L11.1016 9.16615L10.2177 8.28227L9.68734 7.75195L8.62667 8.8126L9.15701 9.34293L10.5712 10.7571L11.1016 11.2875L11.6319 10.7571L15.4446 6.94453Z" fill="currentColor"/>
+    </g>
+  </svg>
+</template>

+ 7 - 0
packages/webview/src/components/Icon/UnselectAll.vue

@@ -0,0 +1,7 @@
+<template>
+  <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
+    <g id="toolbar / pageedit / unselect">
+      <path id="Union" fill-rule="evenodd" clip-rule="evenodd" d="M17.5 2.5H6.5V13.5H17.5V2.5ZM6.5 1H5V2.5V2.9342H2H1.25V3.6842V18V18.75H2H16.3158H17.0658V18V15H17.5H19V13.5V2.5V1H17.5H6.5ZM2.75 4.4342H5V13.5V15H6.5H15.5658V17.25H2.75V4.4342ZM8 8.75H16V7.25H8V8.75Z" fill="currentColor"/>
+    </g>
+  </svg>
+</template>

+ 3 - 3
packages/webview/src/components/Toolbar/Toolbar.vue

@@ -1,6 +1,6 @@
 <template>
-  <div class="toolbar" :class="{ hidden: toolMode === 'view' || toolMode === 'sign' || (toolMode === 'compare' && compareStatus !== 'finished'), security: toolMode === 'security'}">
-    <template v-for="(item, index) in toolItems[toolMode]" :key="`${item.type}-${item.dataElement || index}`">
+  <div class="toolbar" :class="{ hidden: ['view', 'sign', 'document'].includes(toolMode) || (toolMode === 'compare' && compareStatus !== 'finished'), security: toolMode === 'security'}">
+    <template v-for="(item, index) in tools[toolMode]" :key="`${item.type}-${item.dataElement || index}`">
       <!-- Annotation -->
       <Annotate v-if="item.type === 'annotation'" :item="item.tools" />
 
@@ -31,7 +31,7 @@ import { useViewerStore } from '@/stores/modules/viewer'
 defineProps(['toolMode'])
 
 const useViewer = useViewerStore()
-const toolItems = useViewer.getToolItems
+const tools = useViewer.getToolItems
 const compareStatus = computed(() => useViewer.getCompareStatus)
 </script>
 

+ 1 - 1
packages/webview/src/core/checkPassword.js

@@ -1,3 +1,3 @@
 import core from '@/core'
 
-export default (file) => core.getDocumentViewer().checkPassword(file)
+export default (file, notUpdatePwd) => core.getDocumentViewer().checkPassword(file, notUpdatePwd)

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

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

+ 5 - 1
packages/webview/src/core/index.js

@@ -51,6 +51,8 @@ import setActiveSearchResult from './setActiveSearchResult'
 import clearSearchResults from './clearSearchResults'
 import init from './init'
 import getSelectedText from './getSelectedText'
+import getDocEditorPages from './getDocEditorPages'
+import saveDocumentEdit from './saveDocumentEdit'
 
 export default {
   getDocumentViewer,
@@ -108,5 +110,7 @@ export default {
   setActiveSearchResult,
   clearSearchResults,
   init,
-  getSelectedText
+  getSelectedText,
+  getDocEditorPages,
+  saveDocumentEdit
 }

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

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

+ 23 - 3
packages/webview/src/stores/modules/document.js

@@ -138,7 +138,8 @@ export const useDocumentStore = defineStore({
     compareResult: null, // 内容对比结果
     outlines: [],
     activeOutlineId: null,
-    searchResults: []
+    searchResults: [],
+    docEditorOperationList: [], // 页面编辑操作记录
   }),
   getters: {
     getTotalPages () {
@@ -256,6 +257,9 @@ export const useDocumentStore = defineStore({
     },
     getSearchResults () {
       return this.searchResults
+    },
+    getDocEditorOperationList () {
+      return this.docEditorOperationList
     }
   },
   actions: {
@@ -267,6 +271,7 @@ export const useDocumentStore = defineStore({
       this.outlines = []
       this.activeOutlineId = null
       this.searchResults = []
+      this.docEditorOperationList = []
     },
     setTotalPages (totalPages) {
       this.totalPages = totalPages
@@ -384,8 +389,16 @@ export const useDocumentStore = defineStore({
         newFileColor: '#93B9FD'
       }
     },
-    setCurrentPdfData (data) {
-      this.currentPdfData = data
+    setCurrentPdfData (pdf, options) {
+      if (pdf && options) {
+        if (!this.currentPdfData) this.currentPdfData = {}
+        this.currentPdfData.pdf = pdf
+        this.currentPdfData.options = options
+      } else if (pdf) {
+        this.currentPdfData.pdf = pdf
+      } else (
+        this.currentPdfData = null
+      )
     },
     setFileHasPwd (bool) {
       this.fileHasPwd = bool
@@ -401,6 +414,13 @@ export const useDocumentStore = defineStore({
     },
     setSearchResults (searchResults) {
       this.searchResults = searchResults
+    },
+    setDocEditorOperationList (operation) {
+      if (operation === 'reset') {
+        this.docEditorOperationList = []
+      } else if (operation) {
+        this.docEditorOperationList.push(operation)
+      }
     }
   }
 })

+ 17 - 3
packages/webview/src/stores/modules/viewer.js

@@ -34,7 +34,12 @@ export const useViewerStore = defineStore({
       downloadSettingDialog: false,
       printSettingDialog: false,
       editTextPanel: false,
-      preventDialog: false
+      contentEditorPreventDialog: false,
+      docEditorPreventDialog: false,
+      insertPageSettingDialog: false,
+      deletePageDialog: false,
+      extractPageSettingDialog: false,
+      movePageSettingDialog: false
     },
     activeElementsTab: {
       leftPanelTab: 'THUMBS',
@@ -202,6 +207,10 @@ export const useViewerStore = defineStore({
       {
         element: 'editor',
         dataElement: 'toolMenu-Editor'
+      },
+      {
+        element: 'document',
+        dataElement: 'toolMenu-Document'
       }
     ],
     toolItems: {
@@ -443,12 +452,17 @@ export const useViewerStore = defineStore({
         linkPanel: false,
         compareSettingDialog: false,
         languageDialog: false,
-        preventDialog: false,
+        contentEditorPreventDialog: false,
+        docEditorPreventDialog: false,
         setPasswordModal: false,
         signCreatePanel: false,
         downloadSettingDialog: false,
         printSettingDialog: false,
-        editTextPanel: false
+        editTextPanel: false,
+        insertPageSettingDialog: false,
+        deletePageDialog: false,
+        extractPageSettingDialog: false,
+        movePageSettingDialog: false
       },
       this.activeElementsTab = {
         leftPanelTab: 'THUMBS',