| <template> | |
| <div class="editor" v-click-outside="hideMenuInstance"> | |
| <div | |
| class="prosemirror-editor" | |
| ref="editorViewRef" | |
| ></div> | |
| <div class="menu" ref="menuRef"> | |
| <button :class="{ 'active': attr?.bold }" @click="execCommand('bold')"><IconTextBold /></button> | |
| <button :class="{ 'active': attr?.em }" @click="execCommand('em')"><IconTextItalic /></button> | |
| <button :class="{ 'active': attr?.underline }" @click="execCommand('underline')"><IconTextUnderline /></button> | |
| <button :class="{ 'active': attr?.strikethrough }" @click="execCommand('strikethrough')"><IconStrikethrough /></button> | |
| <Popover trigger="click" style="width: 30%;"> | |
| <template #content> | |
| <ColorPicker :modelValue="attr?.color" @update:modelValue="value => execCommand('color', value)" /> | |
| </template> | |
| <button><IconText /></button> | |
| </Popover> | |
| <Popover trigger="click" style="width: 30%;"> | |
| <template #content> | |
| <ColorPicker :modelValue="attr?.backcolor" @update:modelValue="value => execCommand('backcolor', value)" /> | |
| </template> | |
| <button><IconHighLight /></button> | |
| </Popover> | |
| <button :class="{ 'active': attr?.bulletList }" @click="execCommand('bulletList')"><IconList /></button> | |
| <button :class="{ 'active': attr?.orderedList }" @click="execCommand('orderedList')"><IconOrderedList /></button> | |
| <button @click="execCommand('clear')"><IconFormat /></button> | |
| </div> | |
| </div> | |
| </template> | |
| <script lang="ts" setup> | |
| import { onMounted, onUnmounted, ref } from 'vue' | |
| import { debounce } from 'lodash' | |
| import { useMainStore } from '@/store' | |
| import type { EditorView } from 'prosemirror-view' | |
| import { initProsemirrorEditor, createDocument } from '@/utils/prosemirror' | |
| import { addMark, autoSelectAll, getTextAttrs, type TextAttrs } from '@/utils/prosemirror/utils' | |
| import { toggleList } from '@/utils/prosemirror/commands/toggleList' | |
| import tippy, { type Instance } from 'tippy.js' | |
| import ColorPicker from '@/components/ColorPicker/index.vue' | |
| import Popover from '@/components/Popover.vue' | |
| import { toggleMark } from 'prosemirror-commands' | |
| const props = defineProps<{ | |
| value: string | |
| }>() | |
| const emit = defineEmits<{ | |
| (event: 'update', payload: string): void | |
| }>() | |
| const mainStore = useMainStore() | |
| const editorViewRef = ref<HTMLElement>() | |
| let editorView: EditorView | |
| const attr = ref<TextAttrs>() | |
| const menuInstance = ref<Instance>() | |
| const menuRef = ref<HTMLElement>() | |
| const hideMenuInstance = () => { | |
| if (menuInstance.value) menuInstance.value.hide() | |
| } | |
| const handleInput = debounce(function() { | |
| emit('update', editorView.dom.innerHTML) | |
| }, 300, { trailing: true }) | |
| const handleFocus = () => { | |
| mainStore.setDisableHotkeysState(true) | |
| } | |
| const handleBlur = () => { | |
| mainStore.setDisableHotkeysState(false) | |
| } | |
| const updateTextContent = () => { | |
| if (!editorView) return | |
| const { doc, tr } = editorView.state | |
| editorView.dispatch(tr.replaceRangeWith(0, doc.content.size, createDocument(props.value))) | |
| } | |
| defineExpose({ updateTextContent }) | |
| const handleMouseup = () => { | |
| const selection = window.getSelection() | |
| if ( | |
| !selection || | |
| !selection.anchorNode || | |
| !selection.focusNode || | |
| selection.isCollapsed || | |
| selection.type === 'Caret' || | |
| selection.type === 'None' | |
| ) return | |
| const range = selection.getRangeAt(0) | |
| if (menuInstance.value) { | |
| attr.value = getTextAttrs(editorView) | |
| const { x, y, left, top } = range.getBoundingClientRect() | |
| menuInstance.value.setProps({ | |
| getReferenceClientRect: () => ({ | |
| x, y, left, top, | |
| height: 0, | |
| width: 0, | |
| right: left, | |
| bottom: top, | |
| } as DOMRect), | |
| }) | |
| menuInstance.value.show() | |
| } | |
| } | |
| const execCommand = (command: string, value?: string) => { | |
| if (command === 'color' && value) { | |
| const mark = editorView.state.schema.marks.forecolor.create({ color: value }) | |
| autoSelectAll(editorView) | |
| addMark(editorView, mark) | |
| } | |
| else if (command === 'backcolor' && value) { | |
| const mark = editorView.state.schema.marks.backcolor.create({ backcolor: value }) | |
| autoSelectAll(editorView) | |
| addMark(editorView, mark) | |
| } | |
| else if (command === 'bold') { | |
| autoSelectAll(editorView) | |
| toggleMark(editorView.state.schema.marks.strong)(editorView.state, editorView.dispatch) | |
| } | |
| else if (command === 'em') { | |
| autoSelectAll(editorView) | |
| toggleMark(editorView.state.schema.marks.em)(editorView.state, editorView.dispatch) | |
| } | |
| else if (command === 'underline') { | |
| autoSelectAll(editorView) | |
| toggleMark(editorView.state.schema.marks.underline)(editorView.state, editorView.dispatch) | |
| } | |
| else if (command === 'strikethrough') { | |
| autoSelectAll(editorView) | |
| toggleMark(editorView.state.schema.marks.strikethrough)(editorView.state, editorView.dispatch) | |
| } | |
| else if (command === 'bulletList') { | |
| const { bullet_list: bulletList, list_item: listItem } = editorView.state.schema.nodes | |
| toggleList(bulletList, listItem, '')(editorView.state, editorView.dispatch) | |
| } | |
| else if (command === 'orderedList') { | |
| const { ordered_list: orderedList, list_item: listItem } = editorView.state.schema.nodes | |
| toggleList(orderedList, listItem, '')(editorView.state, editorView.dispatch) | |
| } | |
| else if (command === 'clear') { | |
| autoSelectAll(editorView) | |
| const { $from, $to } = editorView.state.selection | |
| editorView.dispatch(editorView.state.tr.removeMark($from.pos, $to.pos)) | |
| } | |
| editorView.focus() | |
| handleInput() | |
| attr.value = getTextAttrs(editorView) | |
| } | |
| onMounted(() => { | |
| editorView = initProsemirrorEditor((editorViewRef.value as Element), props.value, { | |
| handleDOMEvents: { | |
| focus: handleFocus, | |
| blur: handleBlur, | |
| mouseup: handleMouseup, | |
| mousedown: () => { | |
| window.getSelection()?.removeAllRanges() | |
| hideMenuInstance() | |
| }, | |
| keydown: hideMenuInstance, | |
| input: handleInput, | |
| }, | |
| }, { | |
| placeholder: '点击输入演讲者备注', | |
| }) | |
| menuInstance.value = tippy(editorViewRef.value!, { | |
| duration: 0, | |
| content: menuRef.value!, | |
| interactive: true, | |
| trigger: 'manual', | |
| placement: 'top-start', | |
| hideOnClick: 'toggle', | |
| offset: [0, 6], | |
| }) | |
| }) | |
| onUnmounted(() => { | |
| editorView && editorView.destroy() | |
| }) | |
| </script> | |
| <style lang="scss" scoped> | |
| .editor { | |
| height: 100%; | |
| overflow: auto; | |
| } | |
| .prosemirror-editor { | |
| height: 100%; | |
| cursor: text; | |
| ::v-deep(.ProseMirror) { | |
| height: 100%; | |
| font-size: 12px; | |
| overflow: auto; | |
| padding: 8px; | |
| line-height: 1.5; | |
| & > p[data-placeholder]::before { | |
| content: attr(data-placeholder); | |
| pointer-events: none; | |
| position: absolute; | |
| font-size: 12px; | |
| color: rgba(#666, 0.5); | |
| } | |
| } | |
| } | |
| .menu { | |
| display: flex; | |
| background-color: #fff; | |
| padding: 6px 4px; | |
| border-radius: $borderRadius; | |
| box-shadow: 0 0 20px 0 rgba(0, 0, 0, .15); | |
| button { | |
| outline: 0; | |
| border: 0; | |
| background-color: #fff; | |
| padding: 3px; | |
| border-radius: $borderRadius; | |
| font-size: 16px; | |
| margin: 0 3px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| cursor: pointer; | |
| &:hover, &.active { | |
| background-color: $themeColor; | |
| color: #fff; | |
| } | |
| } | |
| } | |
| </style> | |