Преглед изворни кода

feat: add ctrl+v paste shortcut and improve clipboard handling

1. add V key to hotkey enum
2. add multi-language prompts for clipboard empty and permission issues
3. add paste logic to global hotkey handler
4. add hidden focus input for clipboard operations
5. fix student page isResultArray serialization/deserialization
6. update paste event to upload image to S3 first
7. delay usePasteEvent init until component mounted
lsc пре 4 недеља
родитељ
комит
88cb1ac856

+ 1 - 0
src/configs/hotkey.ts

@@ -13,6 +13,7 @@ export const enum KEYS {
   O = 'O',
   R = 'R',
   T = 'T',
+  V = 'V',
   MINUS = '-',
   EQUAL = '=',
   DIGIT_0 = '0',

+ 1 - 0
src/hooks/useCopyAndPasteElement.ts

@@ -35,6 +35,7 @@ export default () => {
 
   // 尝试将剪贴板元素数据解密后进行粘贴
   const pasteElement = () => {
+    console.log('pasteElement')
     readClipboard().then(text => {
       pasteTextClipboardData(text)
     }).catch(err => message.warning(err))

+ 12 - 1
src/hooks/useGlobalHotkey.ts

@@ -44,7 +44,7 @@ export default () => {
   const { combineElements, uncombineElements } = useCombineElement()
   const { deleteElement } = useDeleteElement()
   const { lockElement } = useLockElement()
-  const { copyElement, cutElement, quickCopyElement } = useCopyAndPasteElement()
+  const { copyElement, cutElement, quickCopyElement, pasteElement } = useCopyAndPasteElement()
   const { selectAllElements } = useSelectElement()
   const { moveElement } = useMoveElement()
   const { orderElement } = useOrderElement()
@@ -62,6 +62,12 @@ export default () => {
     else if (thumbnailsFocus.value) cutSlide()
   }
 
+  const paste = () => {
+    console.log('paste')
+    
+    if (editorAreaFocus.value) pasteElement()
+  }
+
   const quickCopy = () => {
     if (activeElementIdList.value.length) quickCopyElement()
     else if (thumbnailsFocus.value) copyAndPasteSlide()
@@ -186,6 +192,11 @@ export default () => {
       e.preventDefault()
       cut()
     }
+    // if (ctrlOrMetaKeyActive && key === KEYS.V) {
+    //   // if (disableHotkeys.value) return
+    //   e.preventDefault()
+    //   paste()
+    // }
     if (ctrlOrMetaKeyActive && key === KEYS.D) {
       if (disableHotkeys.value) return
       e.preventDefault()

+ 46 - 1
src/hooks/usePasteEvent.ts

@@ -4,6 +4,50 @@ import { useMainStore } from '@/store'
 import { getImageDataURL } from '@/utils/image'
 import usePasteTextClipboardData from './usePasteTextClipboardData'
 import useCreateElement from './useCreateElement'
+/**
+     * 上传 File 到 S3,返回公开访问的 URL
+     */
+const uploadFileToS3 = (file: File): Promise<string> => {
+  return new Promise((resolve, reject) => {
+    if (typeof window === 'undefined' || !window.AWS) {
+      reject(new Error('AWS SDK not available'))
+      return
+    }
+
+    const credentials = {
+      accessKeyId: 'AKIATLPEDU37QV5CHLMH',
+      secretAccessKey: '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',
+    }
+    window.AWS.config.update(credentials)
+    window.AWS.config.region = 'cn-northwest-1'
+
+    const bucket = new window.AWS.S3({
+      params: { Bucket: 'ccrb' }, httpOptions: {
+        timeout: 600000 // 10分钟超时
+      }
+    })
+    const ext = file.name.split('.').pop() || 'bin'
+    const key = `${file.name.split('.')[0]}_${Date.now()}.${ext}`
+
+    const params = {
+      Key: 'pptto/' + key,
+      ContentType: file.type,
+      Body: file,
+      ACL: 'public-read',
+    }
+    const options = {
+      partSize: 5 * 1024 * 1024, // 2GB 分片,可酌情调小
+      queueSize: 2,
+      leavePartsOnError: true,
+    }
+
+    bucket
+      .upload(params, options)
+      .promise()
+      .then(data => resolve(data.Location))
+      .catch(err => reject(err))
+  })
+}
 
 export default () => {
   const { editorAreaFocus, thumbnailsFocus, disableHotkeys } = storeToRefs(useMainStore())
@@ -13,7 +57,8 @@ export default () => {
 
   // 粘贴图片到幻灯片元素
   const pasteImageFile = (imageFile: File) => {
-    getImageDataURL(imageFile).then(dataURL => createImageElement(dataURL))
+    // getImageDataURL(imageFile).then(dataURL => createImageElement(dataURL))
+    uploadFileToS3(imageFile).then(url => createImageElement(url))
   }
 
   /**

+ 22 - 0
src/utils/clipboard.ts

@@ -1,5 +1,26 @@
 import Clipboard from 'clipboard'
 import { decrypt } from '@/utils/crypto'
+import { lang } from '@/main'
+
+// 全局隐藏的 input 元素,用于保持页面焦点
+let clipboardFocusInput: HTMLInputElement | null = null
+
+// 初始化隐藏的 input 元素以保持焦点
+export const initClipboardFocus = () => {
+  if (clipboardFocusInput) return clipboardFocusInput
+  
+  clipboardFocusInput = document.createElement('input')
+  clipboardFocusInput.style.cssText = 'position:fixed;left:-9999px;top:-9999px;opacity:0;pointer-events:none;'
+  // clipboardFocusInput.style.cssText = 'position:fixed;left:-0;top:-0;'
+  // clipboardFocusInput.setAttribute('readonly', 'readonly')
+  clipboardFocusInput.setAttribute('tabindex', '-1')
+  document.querySelector('.viewport-wrapper')?.appendChild(clipboardFocusInput)
+  
+  // 尝试聚焦
+  clipboardFocusInput.focus()
+  readClipboard()
+  return clipboardFocusInput
+}
 
 /**
  * 复制文本到剪贴板
@@ -27,6 +48,7 @@ export const copyText = (text: string) => {
   })
 }
 
+// 读取剪贴板
 // 读取剪贴板
 export const readClipboard = (): Promise<string> => {
   return new Promise((resolve, reject) => {

+ 11 - 2
src/views/Editor/Canvas/EditableElement.vue

@@ -17,7 +17,7 @@
 </template>
 
 <script lang="ts" setup>
-import { computed } from 'vue'
+import { computed, onMounted } from 'vue'
 import { storeToRefs } from 'pinia'
 import { ElementTypes, type PPTElement } from '@/types/slides'
 import type { ContextmenuItem } from '@/components/Contextmenu/types'
@@ -30,7 +30,7 @@ import useAlignElementToCanvas from '@/hooks/useAlignElementToCanvas'
 import useCopyAndPasteElement from '@/hooks/useCopyAndPasteElement'
 import useSelectElement from '@/hooks/useSelectElement'
 import useHistorySnapshot from '@/hooks/useHistorySnapshot'
-import { useSlidesStore } from '@/store'
+import { useSlidesStore, useMainStore } from '@/store'
 import message from '@/utils/message'
 import { lang } from '@/main'
 
@@ -46,6 +46,15 @@ import LatexElement from '@/views/components/element/LatexElement/index.vue'
 import VideoElement from '@/views/components/element/VideoElement/index.vue'
 import AudioElement from '@/views/components/element/AudioElement/index.vue'
 import FrameElement from '@/views/components/element/FrameElement/index.vue'
+import { initClipboardFocus } from '@/utils/clipboard'
+
+const mainStore = useMainStore()
+
+onMounted(() => {
+  console.log('ed,onMounted')
+  initClipboardFocus()
+  mainStore.setDisableHotkeysState(false)
+})
 
 const props = defineProps<{
   elementInfo: PPTElement

+ 5 - 2
src/views/Editor/index.vue

@@ -43,7 +43,7 @@
 </template>
 
 <script lang="ts" setup>
-import { ref } from 'vue'
+import { ref, onMounted } from 'vue'
 import { storeToRefs } from 'pinia'
 import { useMainStore } from '@/store'
 import useGlobalHotkey from '@/hooks/useGlobalHotkey'
@@ -79,7 +79,10 @@ const closeAIPPTDialog = () => mainStore.setAIPPTDialogState(false)
 const remarkHeight = ref(0)
 
 useGlobalHotkey()
-usePasteEvent()
+onMounted(() => {
+  console.log('ed,onMounted')
+  usePasteEvent()
+})
 </script>
 
 <style lang="scss" scoped>

+ 4 - 4
src/views/Student/index.vue

@@ -2823,7 +2823,7 @@ const getCourseDetail = async () => {
             // 广播消息
             sendMessage({ 
               type: 'isResultArray', 
-              isResultArray: isResultArray.value,
+              isResultArray: JSON.stringify(isResultArray.value),
               courseid: props.courseid 
             })
             console.log(isResultArray.value)
@@ -3108,7 +3108,7 @@ const setIsResultArray2 = (value: boolean, key: string) => {
   }
   sendMessage({ 
     type: 'isResultArray', 
-    isResultArray: isResultArray.value,
+    isResultArray: JSON.stringify(isResultArray.value),
     courseid: props.courseid 
   })
 }
@@ -3117,7 +3117,7 @@ const setCan = (Array: any[]) => {
   isResultArray.value = Array
   sendMessage({ 
     type: 'isResultArray', 
-    isResultArray: isResultArray.value,
+    isResultArray: JSON.stringify(isResultArray.value),
     courseid: props.courseid 
   })
 }
@@ -3696,7 +3696,7 @@ const getMessages = (msgObj: any) => {
 
   // 获取是否展开结果数组
   if (props.type == '2' && msgObj.type === 'isResultArray' && msgObj.courseid === props.courseid) {
-    isResultArray.value = msgObj.isResultArray || []
+    isResultArray.value = JSON.parse(msgObj.isResultArray || '[]')
     if (isResultArray.value.length > 0 ) {
       const result = isResultArray.value[slideIndex.value]
       console.log('是否展开结果数组:', result)

+ 3 - 1
src/views/lang/cn.json

@@ -922,5 +922,7 @@
   "ssPasteCode": "粘贴代码",
   "ssEditWebpage": "修改网页",
   "ssDownloadLoading": "下载中...",
-  "ssBatchDownload": "批量下载"
+  "ssBatchDownload": "批量下载",
+  "ssClipboardEmpty": "剪贴板为空或者不包含文本",
+  "ssClipboardPermission": "浏览器不支持或禁止访问剪贴板,请使用快捷键 Ctrl + V"
 }

+ 3 - 1
src/views/lang/en.json

@@ -922,5 +922,7 @@
   "ssPasteCode": "Paste Code",
   "ssEditWebpage": "Edit Webpage",
   "ssDownloadLoading": "Downloading...",
-  "ssBatchDownload": "Batch Download"
+  "ssBatchDownload": "Batch Download",
+  "ssClipboardEmpty": "Clipboard is empty or does not contain text",
+  "ssClipboardPermission": "Browser does not support or blocks access to clipboard, please use Ctrl + V to paste code"
 }

+ 3 - 1
src/views/lang/hk.json

@@ -922,5 +922,7 @@
   "ssPasteCode": "粘貼代碼",
   "ssEditWebpage": "修改網頁",
   "ssDownloadLoading": "下載中...",
-  "ssBatchDownload": "批量下載"
+  "ssBatchDownload": "批量下載",
+  "ssClipboardEmpty": "剪貼板空或不包含文本",
+  "ssClipboardPermission": "瀏覽器不支持或禁止訪問剪貼板,請使用快捷鍵 Ctrl + V 粘貼代碼"
 }