mirror of
https://github.com/pipipi-pikachu/PPTist.git
synced 2025-04-15 02:20:00 +08:00
feat: 演讲者备注支持富文本
This commit is contained in:
parent
3450843878
commit
d218755b4c
@ -1,8 +1,7 @@
|
|||||||
import { EditorState } from 'prosemirror-state'
|
import { EditorState } from 'prosemirror-state'
|
||||||
import { EditorView } from 'prosemirror-view'
|
import { type DirectEditorProps, EditorView } from 'prosemirror-view'
|
||||||
import { Schema, DOMParser } from 'prosemirror-model'
|
import { Schema, DOMParser } from 'prosemirror-model'
|
||||||
|
import { buildPlugins, type PluginOptions } from './plugins/index'
|
||||||
import { buildPlugins } from './plugins/index'
|
|
||||||
import { schemaNodes, schemaMarks } from './schema/index'
|
import { schemaNodes, schemaMarks } from './schema/index'
|
||||||
|
|
||||||
const schema = new Schema({
|
const schema = new Schema({
|
||||||
@ -17,11 +16,16 @@ export const createDocument = (content: string) => {
|
|||||||
return DOMParser.fromSchema(schema).parse(element as Element)
|
return DOMParser.fromSchema(schema).parse(element as Element)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const initProsemirrorEditor = (dom: Element, content: string, props = {}) => {
|
export const initProsemirrorEditor = (
|
||||||
|
dom: Element,
|
||||||
|
content: string,
|
||||||
|
props: Omit<DirectEditorProps, 'state'>,
|
||||||
|
pluginOptions?: PluginOptions,
|
||||||
|
) => {
|
||||||
return new EditorView(dom, {
|
return new EditorView(dom, {
|
||||||
state: EditorState.create({
|
state: EditorState.create({
|
||||||
doc: createDocument(content),
|
doc: createDocument(content),
|
||||||
plugins: buildPlugins(schema),
|
plugins: buildPlugins(schema, pluginOptions),
|
||||||
}),
|
}),
|
||||||
...props,
|
...props,
|
||||||
})
|
})
|
||||||
|
@ -7,9 +7,16 @@ import { gapCursor } from 'prosemirror-gapcursor'
|
|||||||
|
|
||||||
import { buildKeymap } from './keymap'
|
import { buildKeymap } from './keymap'
|
||||||
import { buildInputRules } from './inputrules'
|
import { buildInputRules } from './inputrules'
|
||||||
|
import { placeholderPlugin } from './placeholder'
|
||||||
|
|
||||||
export const buildPlugins = (schema: Schema) => {
|
export interface PluginOptions {
|
||||||
return [
|
placeholder?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildPlugins = (schema: Schema, options?: PluginOptions) => {
|
||||||
|
const placeholder = options?.placeholder
|
||||||
|
|
||||||
|
const plugins = [
|
||||||
buildInputRules(schema),
|
buildInputRules(schema),
|
||||||
keymap(buildKeymap(schema)),
|
keymap(buildKeymap(schema)),
|
||||||
keymap(baseKeymap),
|
keymap(baseKeymap),
|
||||||
@ -17,4 +24,8 @@ export const buildPlugins = (schema: Schema) => {
|
|||||||
gapCursor(),
|
gapCursor(),
|
||||||
history(),
|
history(),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if (placeholder) plugins.push(placeholderPlugin(placeholder))
|
||||||
|
|
||||||
|
return plugins
|
||||||
}
|
}
|
23
src/utils/prosemirror/plugins/placeholder.ts
Normal file
23
src/utils/prosemirror/plugins/placeholder.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { Plugin } from 'prosemirror-state'
|
||||||
|
import { Decoration, DecorationSet } from 'prosemirror-view'
|
||||||
|
import type { Node } from 'prosemirror-model'
|
||||||
|
|
||||||
|
const isEmptyParagraph = (node: Node) => {
|
||||||
|
return node.type.name === 'paragraph' && node.nodeSize === 2
|
||||||
|
}
|
||||||
|
|
||||||
|
export const placeholderPlugin = (placeholder: string) => {
|
||||||
|
return new Plugin({
|
||||||
|
props: {
|
||||||
|
decorations(state) {
|
||||||
|
const { $from } = state.selection
|
||||||
|
if (isEmptyParagraph($from.parent)) {
|
||||||
|
const decoration = Decoration.node($from.before(), $from.after(), {
|
||||||
|
'data-placeholder': placeholder,
|
||||||
|
})
|
||||||
|
return DecorationSet.create(state.doc, [decoration])
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
223
src/views/Editor/Remark/Editor.vue
Normal file
223
src/views/Editor/Remark/Editor.vue
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
<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 @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 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)
|
||||||
|
|
||||||
|
menuInstance.value.setProps({
|
||||||
|
getReferenceClientRect: () => range.getBoundingClientRect(),
|
||||||
|
})
|
||||||
|
menuInstance.value.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMousedown = () => {
|
||||||
|
hideMenuInstance()
|
||||||
|
window.getSelection()?.removeAllRanges()
|
||||||
|
}
|
||||||
|
|
||||||
|
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 === '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: handleMousedown,
|
||||||
|
input: handleInput,
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
placeholder: '点击输入演讲者备注',
|
||||||
|
})
|
||||||
|
|
||||||
|
menuInstance.value = tippy(editorViewRef.value!, {
|
||||||
|
duration: 0,
|
||||||
|
content: menuRef.value!,
|
||||||
|
interactive: true,
|
||||||
|
trigger: 'manual',
|
||||||
|
placement: 'top',
|
||||||
|
hideOnClick: 'toggle',
|
||||||
|
offset: [0, 6],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
editorView && editorView.destroy()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.prosemirror-editor {
|
||||||
|
cursor: text;
|
||||||
|
|
||||||
|
::v-deep(.ProseMirror) {
|
||||||
|
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: 5px;
|
||||||
|
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: 15px;
|
||||||
|
margin: 0 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover, &.active {
|
||||||
|
background-color: $themeColor;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
@ -4,19 +4,21 @@
|
|||||||
class="resize-handler"
|
class="resize-handler"
|
||||||
@mousedown="$event => resize($event)"
|
@mousedown="$event => resize($event)"
|
||||||
></div>
|
></div>
|
||||||
<textarea
|
<Editor
|
||||||
:value="remark"
|
:value="remark"
|
||||||
placeholder="点击输入演讲者备注"
|
ref="editorRef"
|
||||||
@input="$event => handleInput($event)"
|
@update="value => handleInput(value)"
|
||||||
></textarea>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed } from 'vue'
|
import { computed, nextTick, ref, watch } from 'vue'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { useSlidesStore } from '@/store'
|
import { useSlidesStore } from '@/store'
|
||||||
|
|
||||||
|
import Editor from './Editor.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
height: number
|
height: number
|
||||||
}>()
|
}>()
|
||||||
@ -28,11 +30,19 @@ const emit = defineEmits<{
|
|||||||
const slidesStore = useSlidesStore()
|
const slidesStore = useSlidesStore()
|
||||||
const { currentSlide } = storeToRefs(slidesStore)
|
const { currentSlide } = storeToRefs(slidesStore)
|
||||||
|
|
||||||
|
const editorRef = ref<InstanceType<typeof Editor>>()
|
||||||
|
watch(() => currentSlide.value.id, () => {
|
||||||
|
nextTick(() => {
|
||||||
|
editorRef.value!.updateTextContent()
|
||||||
|
})
|
||||||
|
}, {
|
||||||
|
immediate: true,
|
||||||
|
})
|
||||||
|
|
||||||
const remark = computed(() => currentSlide.value?.remark || '')
|
const remark = computed(() => currentSlide.value?.remark || '')
|
||||||
|
|
||||||
const handleInput = (e: Event) => {
|
const handleInput = (content: string) => {
|
||||||
const value = (e.target as HTMLTextAreaElement).value
|
slidesStore.updateSlide({ remark: content })
|
||||||
slidesStore.updateSlide({ remark: value })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const resize = (e: MouseEvent) => {
|
const resize = (e: MouseEvent) => {
|
||||||
@ -66,22 +76,6 @@ const resize = (e: MouseEvent) => {
|
|||||||
.remark {
|
.remark {
|
||||||
position: relative;
|
position: relative;
|
||||||
border-top: 1px solid $borderColor;
|
border-top: 1px solid $borderColor;
|
||||||
|
|
||||||
textarea {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
overflow-y: auto;
|
|
||||||
resize: none;
|
|
||||||
border: 0;
|
|
||||||
outline: 0;
|
|
||||||
padding: 8px;
|
|
||||||
font-size: 12px;
|
|
||||||
background-color: transparent;
|
|
||||||
box-sizing: border-box;
|
|
||||||
line-height: 1.5;
|
|
||||||
|
|
||||||
@include absolute-0();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.resize-handler {
|
.resize-handler {
|
||||||
height: 7px;
|
height: 7px;
|
||||||
|
Loading…
x
Reference in New Issue
Block a user