文本元素接入ProseMirror

This commit is contained in:
pipipi-pikachu 2021-01-01 15:21:12 +08:00
parent 22887f022e
commit 1c7b2181bc
16 changed files with 277 additions and 113 deletions

View File

@ -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;
}
}

View File

@ -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'

View File

@ -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
src/prosemirror/index.ts Normal file
View File

@ -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,
})
}

View File

@ -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],
)
)

View File

@ -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]

View File

@ -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',
}
paragraph: {
attrs: {
align: {
default: '',
},
const _bulletList: NodeSpec = {
...bulletList,
content: 'list_item+',
group: 'block',
}
const _listItem: NodeSpec = {
...listItem,
content: 'paragraph block*',
group: 'block',
}
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
src/utils/selection.ts Normal file
View File

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

View File

@ -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 = () => {

View File

@ -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()
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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>

View File

@ -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>