ソースを参照

文本元素接入ProseMirror

pipipi-pikachu 5 年 前
コミット
1c7b2181bc

+ 53 - 0
src/assets/styles/prosemirror.scss

@@ -0,0 +1,53 @@
+.ProseMirror, .ProseMirror-static {
+  outline: 0;
+  border: 0;
+  font-size: 20px;
+  line-height: 1.5;
+  word-break: break-word;
+  font-family: '微软雅黑';
+
+  ::selection {
+    background-color: rgba(27, 110, 232, 0.3);
+    color: inherit;
+  }
+
+  p + p {
+    margin-top: 5px;
+  }
+
+  ul {
+    list-style-type: disc;
+    padding-inline-start: 20px;
+
+    li {
+      list-style-type: disc;
+    }
+  }
+
+  ol {
+    list-style-type: decimal;
+    padding-inline-start: 20px;
+
+    li {
+      list-style-type: decimal;
+    }
+  }
+
+  code {
+    background-color: #eee;
+    padding: 1px 3px;
+    margin: 0 1px;
+    border-radius: 2px;
+    font-family: inherit;
+  }
+
+  blockquote {
+    overflow: hidden;
+    padding-right: 1.2em;
+    padding-left: 1.2em;
+    margin-left: 0;
+    margin-right: 0;
+    font-style: italic;
+    border-left: 5px solid #ccc;
+  }
+}

+ 2 - 0
src/main.ts

@@ -2,6 +2,8 @@ import { createApp } from 'vue'
 import App from './App.vue'
 import store from './store'
 
+import 'prosemirror-view/style/prosemirror.css'
+import '@/assets/styles/prosemirror.scss'
 import '@/assets/styles/global.scss'
 import 'animate.css'
 

+ 1 - 1
src/mocks/index.ts

@@ -24,7 +24,7 @@ export const slides: Slide[] = [
         },
         opacity: 1,
         lock: false,
-        content: '<div style=\'text-align: center;\'><span style=\'font-size: 28px;\'><span style=\'color: rgb(232, 107, 153); font-weight: bold;\'>一段测试文字</span>,字号固定为<span style=\'font-weight: bold; font-style: italic; text-decoration-line: underline;\'>28px</span></span></div>',
+        content: '<p style=\'text-align: center;\'><span style=\'font-size: 28px;\'><span style=\'color: rgb(232, 107, 153); font-weight: bold;\'>一段测试文字</span>,字号固定为<span style=\'font-weight: bold; font-style: italic; text-decoration-line: underline;\'>28px</span></span></p>',
       },
       {
         id: 'xxx3',

+ 28 - 0
src/prosemirror/index.ts

@@ -0,0 +1,28 @@
+import { EditorState } from 'prosemirror-state'
+import { EditorView } from 'prosemirror-view'
+import { Schema, DOMParser } from 'prosemirror-model'
+
+import { buildPlugins } from './plugins/index'
+import { schemaNodes, schemaMarks } from './schema/index'
+
+const schema = new Schema({
+  nodes: schemaNodes,
+  marks: schemaMarks,
+})
+
+export const createDocument = (content: string) => {
+  const htmlString = `<div>${content}</div>`
+  const parser = new window.DOMParser()
+  const element = parser.parseFromString(htmlString, 'text/html').body.firstElementChild
+  return DOMParser.fromSchema(schema).parse(element as Element)
+}
+
+export const initProsemirrorEditor = (dom: Element, content: string, props = {}) => {
+  return new EditorView(dom, {
+    state: EditorState.create({
+      doc: createDocument(content),
+      plugins: buildPlugins(schema),
+    }),
+    ...props,
+  })
+}

+ 1 - 1
src/prosemirror/plugins/inputrules.ts

@@ -15,7 +15,7 @@ const orderedListRule = (nodeType: NodeType) => (
     /^(\d+)\.\s$/, 
     nodeType, 
     match => ({order: +match[1]}),
-    (match, node) => node.childCount + node.attrs.order == +match[1],
+    (match, node) => node.childCount + node.attrs.order === +match[1],
   )
 )
 

+ 30 - 28
src/prosemirror/schema/marks.ts

@@ -1,79 +1,79 @@
 import { marks } from 'prosemirror-schema-basic'
-import { Node } from 'prosemirror-model'
+import { MarkSpec } from 'prosemirror-model'
 
-const subscript = {
+const subscript: MarkSpec = {
   excludes: 'subscript',
   parseDOM: [
     { tag: 'sub' },
     {
       style: 'vertical-align',
-      getAttrs: (value: string) => value === 'sub' && null
+      getAttrs: value => value === 'sub' && null
     },
   ],
   toDOM: () => ['sub', 0],
 }
 
-const superscript = {
+const superscript: MarkSpec = {
   excludes: 'superscript',
   parseDOM: [
     { tag: 'sup' },
     {
       style: 'vertical-align',
-      getAttrs: (value: string) => value === 'super' && null
+      getAttrs: value => value === 'super' && null
     },
   ],
   toDOM: () => ['sup', 0],
 }
 
-const strikethrough = {
+const strikethrough: MarkSpec = {
   parseDOM: [
     { tag: 'strike' },
     {
       style: 'text-decoration',
-      getAttrs: (value: string) => value === 'line-through' && null
+      getAttrs: value => value === 'line-through' && null
     },
     {
       style: 'text-decoration-line',
-      getAttrs: (value: string) => value === 'line-through' && null
+      getAttrs: value => value === 'line-through' && null
     },
   ],
   toDOM: () => ['span', { style: 'text-decoration-line: line-through' }, 0],
 }
 
-const underline = {
+const underline: MarkSpec = {
   parseDOM: [
     { tag: 'u' },
     {
       style: 'text-decoration',
-      getAttrs: (value: string) => value === 'underline' && null
+      getAttrs: value => value === 'underline' && null
     },
     {
       style: 'text-decoration-line',
-      getAttrs: (value: string) => value === 'underline' && null
+      getAttrs: value => value === 'underline' && null
     },
   ],
   toDOM: () => ['span', { style: 'text-decoration: underline' }, 0],
 }
 
-const forecolor = {
+const forecolor: MarkSpec = {
   attrs: {
     color: {},
   },
   parseDOM: [
     {
       style: 'color',
-      getAttrs: (color: string) => color ? { color } : {}
+      getAttrs: color => color ? { color } : {}
     },
   ],
-  toDOM: (node: Node) => {
-    const { color } = node.attrs
+  toDOM: mark => {
+    const { color } = mark.attrs
     let style = ''
     if(color) style += `color: ${color};`
     return ['span', { style }, 0]
   },
 }
 
-const backcolor = {
+const backcolor: MarkSpec = {
   attrs: {
     backcolor: {},
   },
@@ -82,18 +82,18 @@ const backcolor = {
   parseDOM: [
     {
       tag: 'span[style*=background-color]',
-      getAttrs: (backcolor: string) => backcolor ? { backcolor } : {}
+      getAttrs: backcolor => backcolor ? { backcolor } : {}
     },
   ],
-  toDOM: (node: Node) => {
-    const { backcolor } = node.attrs
+  toDOM: mark => {
+    const { backcolor } = mark.attrs
     let style = ''
     if(backcolor) style += `background-color: ${backcolor};`
     return ['span', { style }, 0]
   },
 }
 
-const fontsize = {
+const fontsize: MarkSpec = {
   attrs: {
     fontsize: {},
   },
@@ -102,31 +102,33 @@ const fontsize = {
   parseDOM: [
     {
       style: 'font-size',
-      getAttrs: (fontsize: string) => fontsize ? { fontsize } : {}
+      getAttrs: fontsize => fontsize ? { fontsize } : {}
     },
   ],
-  toDOM: (node: Node) => {
-    const { fontsize } = node.attrs
+  toDOM: mark => {
+    const { fontsize } = mark.attrs
     let style = ''
     if(fontsize) style += `font-size: ${fontsize}`
     return ['span', { style }, 0]
   },
 }
 
-const fontname = {
+const fontname: MarkSpec = {
   attrs: {
-    fontname: '',
+    fontname: {},
   },
   inline: true,
   group: 'inline',
   parseDOM: [
     {
       style: 'font-family',
-      getAttrs: (fontname: string) => ({ fontname: fontname ? fontname.replace(/[\"\']/g, '') : '' })
+      getAttrs: fontname => {
+        return { fontname: fontname && typeof fontname === 'string' ? fontname.replace(/[\"\']/g, '') : '' }
+      }
     },
   ],
-  toDOM: (node: Node) => {
-    const { fontname } = node.attrs
+  toDOM: mark => {
+    const { fontname } = mark.attrs
     let style = ''
     if(fontname) style += `font-family: ${fontname}`
     return ['span', { style }, 0]

+ 45 - 42
src/prosemirror/schema/nodes.ts

@@ -1,55 +1,58 @@
 import { nodes } from 'prosemirror-schema-basic'
-import { Node } from 'prosemirror-model'
+import { Node, NodeSpec } from 'prosemirror-model'
 import { orderedList, bulletList, listItem } from 'prosemirror-schema-list'
 
-const listNodes = {
-  ordered_list: {
-    ...orderedList,
-    content: 'list_item+',
-    group: 'block',
-  },
-  bullet_list: {
-    ...bulletList,
-    content: 'list_item+',
-    group: 'block',
-  },
-  list_item: {
-    ...listItem,
-    content: 'paragraph block*',
-    group: 'block',
-  },
+const _orderedList: NodeSpec = {
+  ...orderedList,
+  content: 'list_item+',
+  group: 'block',
+}
+
+const _bulletList: NodeSpec = {
+  ...bulletList,
+  content: 'list_item+',
+  group: 'block',
+}
+
+const _listItem: NodeSpec = {
+  ...listItem,
+  content: 'paragraph block*',
+  group: 'block',
+}
 
-  paragraph: {
-    attrs: {
-      align: {
-        default: '',
-      },
+const paragraph: NodeSpec = {
+  attrs: {
+    align: {
+      default: '',
     },
-    content: 'inline*',
-    group: 'block',
-    parseDOM: [
-      {
-        tag: 'p',
-        getAttrs: (dom: HTMLElement) => {
-          const { textAlign } = dom.style
-          let align = dom.getAttribute('align') || textAlign || ''
-          align = /(left|right|center|justify)/.test(align) ? align : ''
-        
-          return { align }
-        }
+  },
+  content: 'inline*',
+  group: 'block',
+  parseDOM: [
+    {
+      tag: 'p',
+      getAttrs: dom => {
+        const { textAlign } = (dom as HTMLElement).style
+        let align = (dom as HTMLElement).getAttribute('align') || textAlign || ''
+        align = /(left|right|center|justify)/.test(align) ? align : ''
+      
+        return { align }
       }
-    ],
-    toDOM: (node: Node) => {
-      const { align } = node.attrs
-      let style = ''
-      if(align && align !== 'left') style += `text-align: ${align};`
+    }
+  ],
+  toDOM: (node: Node) => {
+    const { align } = node.attrs
+    let style = ''
+    if(align && align !== 'left') style += `text-align: ${align};`
 
-      return ['p', { style }, 0]
-    },
+    return ['p', { style }, 0]
   },
 }
 
 export default {
   ...nodes,
-  ...listNodes,
+  'ordered_list': _orderedList,
+  'bullet_list': _bulletList,
+  'list_item': _listItem,
+  paragraph,
 }

+ 4 - 0
src/utils/selection.ts

@@ -0,0 +1,4 @@
+export const removeAllRanges = () => {
+  const selection = window.getSelection()
+  selection && selection.removeAllRanges()
+}

+ 2 - 0
src/views/Editor/Canvas/index.vue

@@ -82,6 +82,7 @@ import { State, MutationTypes } from '@/store'
 import { ContextmenuItem } from '@/components/Contextmenu/types'
 import { PPTElement, Slide } from '@/types/slides'
 import { AlignmentLineProps, CreateElementSelectionData } from '@/types/edit'
+import { removeAllRanges } from '@/utils/selection'
 
 import useViewportSize from './hooks/useViewportSize'
 import useMouseSelection from './hooks/useMouseSelection'
@@ -161,6 +162,7 @@ export default defineComponent({
       store.commit(MutationTypes.SET_ACTIVE_ELEMENT_ID_LIST, [])
       if(!ctrlOrShiftKeyActive.value) updateMouseSelection(e)
       if(!editorAreaFocus.value) store.commit(MutationTypes.SET_EDITORAREA_FOCUS, true)
+      removeAllRanges()
     }
 
     const removeEditorAreaFocus = () => {

+ 19 - 8
src/views/Editor/useHotkey.ts

@@ -41,7 +41,6 @@ export default () => {
   const { redo, undo } = useHistorySnapshot()
 
   const copy = () => {
-    if(disableHotkeys.value) return
     if(activeElementIdList.value.length) copyElement()
     else if(thumbnailsFocus.value) copySlide()
   }
@@ -53,38 +52,36 @@ export default () => {
   }
 
   const selectAll = () => {
-    if(!editorAreaFocus.value && disableHotkeys.value) return
+    if(!editorAreaFocus.value) return
     selectAllElement()
   }
 
   const lock = () => {
-    if(!editorAreaFocus.value && disableHotkeys.value) return
+    if(!editorAreaFocus.value) return
     lockElement()
   }
   const combine = () => {
-    if(!editorAreaFocus.value && disableHotkeys.value) return
+    if(!editorAreaFocus.value) return
     combineElements()
   }
 
   const uncombine = () => {
-    if(!editorAreaFocus.value && disableHotkeys.value) return
+    if(!editorAreaFocus.value) return
     uncombineElements()
   }
 
   const remove = () => {
-    if(disableHotkeys.value) return
     if(activeElementIdList.value.length) deleteElement()
     else if(thumbnailsFocus.value) deleteSlide()
   }
 
   const move = (key: string) => {
-    if(disableHotkeys.value) return
     if(activeElementIdList.value.length) moveElement(key)
     else if(key === KEYS.UP || key === KEYS.DOWN) updateSlideIndex(key)
   }
 
   const create = () => {
-    if(!thumbnailsFocus.value || disableHotkeys.value) return
+    if(!thumbnailsFocus.value) return
     createSlide()
   }
 
@@ -109,58 +106,72 @@ export default () => {
     if(!editorAreaFocus.value && !thumbnailsFocus.value) return      
 
     if(ctrlKey && key === KEYS.C) {
+      if(disableHotkeys.value) return
       e.preventDefault()
       copy()
     }
     if(ctrlKey && key === KEYS.X) {
+      if(disableHotkeys.value) return
       e.preventDefault()
       cut()
     }
     if(ctrlKey && key === KEYS.Z) {
+      if(disableHotkeys.value) return
       e.preventDefault()
       undo()
     }
     if(ctrlKey && key === KEYS.Y) {
+      if(disableHotkeys.value) return
       e.preventDefault()
       redo()
     }
     if(ctrlKey && key === KEYS.A) {
+      if(disableHotkeys.value) return
       e.preventDefault()
       selectAll()
     }
     if(ctrlKey && key === KEYS.L) {
+      if(disableHotkeys.value) return
       e.preventDefault()
       lock()
     }
     if(!shiftKey && ctrlKey && key === KEYS.G) {
+      if(disableHotkeys.value) return
       e.preventDefault()
       combine()
     }
     if(shiftKey && ctrlKey && key === KEYS.G) {
+      if(disableHotkeys.value) return
       e.preventDefault()
       uncombine()
     }
     if(key === KEYS.DELETE) {
+      if(disableHotkeys.value) return
       e.preventDefault()
       remove()
     }
     if(key === KEYS.UP) {
+      if(disableHotkeys.value) return
       e.preventDefault()
       move(KEYS.UP)
     }
     if(key === KEYS.DOWN) {
+      if(disableHotkeys.value) return
       e.preventDefault()
       move(KEYS.DOWN)
     }
     if(key === KEYS.LEFT) {
+      if(disableHotkeys.value) return
       e.preventDefault()
       move(KEYS.LEFT)
     }
     if(key === KEYS.RIGHT) {
+      if(disableHotkeys.value) return
       e.preventDefault()
       move(KEYS.RIGHT)
     }
     if(key === KEYS.ENTER) {
+      if(disableHotkeys.value) return
       e.preventDefault()
       create()
     }

+ 2 - 1
src/views/components/element/LineElement/BaseLineElement.vue

@@ -1,5 +1,6 @@
 <template>
-  <div class="editable-element-shape"
+  <div 
+    class="editable-element-shape"
     :style="{
       top: elementInfo.top + 'px',
       left: elementInfo.left + 'px',

+ 2 - 1
src/views/components/element/LineElement/index.vue

@@ -1,5 +1,6 @@
 <template>
-  <div class="editable-element-shape"
+  <div 
+    class="editable-element-shape"
     :class="{ 'lock': elementInfo.lock }"
     :style="{
       top: elementInfo.top + 'px',

+ 2 - 1
src/views/components/element/ShapeElement/BaseShapeElement.vue

@@ -14,7 +14,8 @@
         filter: shadowStyle ? `drop-shadow(${shadowStyle})` : '',
       }"
     >
-      <SvgWrapper overflow="visible" 
+      <SvgWrapper 
+        overflow="visible" 
         :width="elementInfo.width"
         :height="elementInfo.height"
       >

+ 2 - 1
src/views/components/element/ShapeElement/index.vue

@@ -17,7 +17,8 @@
         filter: shadowStyle ? `drop-shadow(${shadowStyle})` : '',
       }"
     >
-      <SvgWrapper overflow="visible" 
+      <SvgWrapper 
+        overflow="visible" 
         :width="elementInfo.width"
         :height="elementInfo.height"
       >

+ 3 - 13
src/views/components/element/TextElement/BaseTextElement.vue

@@ -8,7 +8,8 @@
       transform: `rotate(${elementInfo.rotate}deg)`,
     }"
   >
-    <div class="element-content"
+    <div 
+      class="element-content"
       :style="{
         backgroundColor: elementInfo.fill,
         opacity: elementInfo.opacity,
@@ -20,7 +21,7 @@
         :height="elementInfo.height"
         :outline="elementInfo.outline"
       />
-      <div class="text" v-html="elementInfo.content"></div>
+      <div class="text ProseMirror-static" v-html="elementInfo.content"></div>
     </div>
   </div>
 </template>
@@ -68,15 +69,4 @@ export default defineComponent({
     position: relative;
   }
 }
-
-::v-deep(.text) {
-  word-break: break-word;
-  font-family: '微软雅黑';
-  outline: 0;
-
-  ::selection {
-    background-color: rgba(27, 110, 232, 0.3);
-    color: inherit;
-  }
-}
 </style>

+ 81 - 16
src/views/components/element/TextElement/index.vue

@@ -1,6 +1,7 @@
 <template>
   <div 
     class="editable-element-text" 
+    ref="elementRef"
     :class="{ 'lock': elementInfo.lock }"
     :style="{
       top: elementInfo.top + 'px',
@@ -10,7 +11,8 @@
     }"
     @mousedown="$event => handleSelectElement($event)"
   >
-    <div class="element-content"
+    <div 
+      class="element-content"
       :style="{
         backgroundColor: elementInfo.fill,
         opacity: elementInfo.opacity,
@@ -23,9 +25,9 @@
         :height="elementInfo.height"
         :outline="elementInfo.outline"
       />
-      <div class="text"
-        v-html="elementInfo.content" 
-        :contenteditable="!elementInfo.lock"
+      <div 
+        class="text"
+        ref="editorViewRef"
         @mousedown="$event => handleSelectElement($event, false)"
       ></div>
     </div>
@@ -33,10 +35,16 @@
 </template>
 
 <script lang="ts">
-import { computed, defineComponent, PropType } from 'vue'
+import { computed, defineComponent, onMounted, onUnmounted, PropType, ref, watch } from 'vue'
+import debounce from 'lodash/debounce'
+import { useStore } from 'vuex'
+import { MutationTypes, State } from '@/store'
+import { EditorView } from 'prosemirror-view'
 import { PPTTextElement } from '@/types/slides'
 import { ContextmenuItem } from '@/components/Contextmenu/types'
+import { initProsemirrorEditor, createDocument } from '@/prosemirror/'
 import useElementShadow from '@/views/components/element/hooks/useElementShadow'
+import useHistorySnapshot from '@/hooks/useHistorySnapshot'
 
 import ElementOutline from '@/views/components/element/ElementOutline.vue'
 
@@ -59,6 +67,72 @@ export default defineComponent({
     },
   },
   setup(props) {
+    const store = useStore<State>()
+    const { addHistorySnapshot } = useHistorySnapshot()
+
+    const elementRef = ref<HTMLElement | null>(null)
+
+    const debounceUpdateTextElementHeight = debounce(function(realHeight) {
+      store.commit(MutationTypes.UPDATE_ELEMENT, {
+        id: props.elementInfo.id,
+        props: { height: realHeight },
+      })
+    }, 500, { trailing: true })
+
+    const updateTextElementHeight = () => {
+      if(!elementRef.value) return
+
+      const realHeight = elementRef.value.clientHeight
+      if(props.elementInfo.height !== realHeight) {
+        debounceUpdateTextElementHeight(realHeight)
+      }
+    }
+    const resizeObserver = new ResizeObserver(updateTextElementHeight)
+
+    onMounted(() => {
+      if(elementRef.value) resizeObserver.observe(elementRef.value)
+    })
+    onUnmounted(() => {
+      if(elementRef.value) resizeObserver.unobserve(elementRef.value)
+    })
+    
+    const editorViewRef = ref<Element | null>(null)
+    let editorView: EditorView
+
+    const handleFocus = () => {
+      store.commit(MutationTypes.SET_DISABLE_HOTKEYS_STATE, true)
+    }
+    const handleBlur = () => {
+      store.commit(MutationTypes.SET_DISABLE_HOTKEYS_STATE, false)
+    }
+    const handleInput = debounce(function() {
+      store.commit(MutationTypes.UPDATE_ELEMENT, {
+        id: props.elementInfo.id, 
+        props: { content: editorView.dom.innerHTML },
+      })
+      addHistorySnapshot()
+    }, 500, { trailing: true })
+
+    const textContent = computed(() => props.elementInfo.content)
+    watch(textContent, () => {
+      if(!editorView) return
+      if(editorView.hasFocus()) return
+      editorView.dom.innerHTML = textContent.value
+    })
+
+    onMounted(() => {
+      editorView = initProsemirrorEditor((editorViewRef.value as Element), textContent.value, {
+        handleDOMEvents: {
+          focus: handleFocus,
+          blur: handleBlur,
+          keydown: handleInput,
+        },
+      })
+    })
+    onUnmounted(() => {
+      editorView && editorView.destroy()
+    })
+
     const handleSelectElement = (e: MouseEvent, canMove = true) => {
       if(props.elementInfo.lock) return
       e.stopPropagation()
@@ -70,6 +144,8 @@ export default defineComponent({
     const { shadowStyle } = useElementShadow(shadow)
 
     return {
+      elementRef,
+      editorViewRef,
       handleSelectElement,
       shadowStyle,
     }
@@ -97,15 +173,4 @@ export default defineComponent({
     cursor: text;
   }
 }
-
-::v-deep(.text) {
-  word-break: break-word;
-  font-family: '微软雅黑';
-  outline: 0;
-
-  ::selection {
-    background-color: rgba(27, 110, 232, 0.3);
-    color: inherit;
-  }
-}
 </style>