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 { EditorView } from 'prosemirror-view'
|
||||
import { type DirectEditorProps, EditorView } from 'prosemirror-view'
|
||||
import { Schema, DOMParser } from 'prosemirror-model'
|
||||
|
||||
import { buildPlugins } from './plugins/index'
|
||||
import { buildPlugins, type PluginOptions } from './plugins/index'
|
||||
import { schemaNodes, schemaMarks } from './schema/index'
|
||||
|
||||
const schema = new Schema({
|
||||
@ -17,11 +16,16 @@ export const createDocument = (content: string) => {
|
||||
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, {
|
||||
state: EditorState.create({
|
||||
doc: createDocument(content),
|
||||
plugins: buildPlugins(schema),
|
||||
plugins: buildPlugins(schema, pluginOptions),
|
||||
}),
|
||||
...props,
|
||||
})
|
||||
|
@ -7,9 +7,16 @@ import { gapCursor } from 'prosemirror-gapcursor'
|
||||
|
||||
import { buildKeymap } from './keymap'
|
||||
import { buildInputRules } from './inputrules'
|
||||
import { placeholderPlugin } from './placeholder'
|
||||
|
||||
export const buildPlugins = (schema: Schema) => {
|
||||
return [
|
||||
export interface PluginOptions {
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export const buildPlugins = (schema: Schema, options?: PluginOptions) => {
|
||||
const placeholder = options?.placeholder
|
||||
|
||||
const plugins = [
|
||||
buildInputRules(schema),
|
||||
keymap(buildKeymap(schema)),
|
||||
keymap(baseKeymap),
|
||||
@ -17,4 +24,8 @@ export const buildPlugins = (schema: Schema) => {
|
||||
gapCursor(),
|
||||
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"
|
||||
@mousedown="$event => resize($event)"
|
||||
></div>
|
||||
<textarea
|
||||
<Editor
|
||||
:value="remark"
|
||||
placeholder="点击输入演讲者备注"
|
||||
@input="$event => handleInput($event)"
|
||||
></textarea>
|
||||
ref="editorRef"
|
||||
@update="value => handleInput(value)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useSlidesStore } from '@/store'
|
||||
|
||||
import Editor from './Editor.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
height: number
|
||||
}>()
|
||||
@ -28,11 +30,19 @@ const emit = defineEmits<{
|
||||
const slidesStore = useSlidesStore()
|
||||
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 handleInput = (e: Event) => {
|
||||
const value = (e.target as HTMLTextAreaElement).value
|
||||
slidesStore.updateSlide({ remark: value })
|
||||
const handleInput = (content: string) => {
|
||||
slidesStore.updateSlide({ remark: content })
|
||||
}
|
||||
|
||||
const resize = (e: MouseEvent) => {
|
||||
@ -66,22 +76,6 @@ const resize = (e: MouseEvent) => {
|
||||
.remark {
|
||||
position: relative;
|
||||
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 {
|
||||
height: 7px;
|
||||
|
Loading…
x
Reference in New Issue
Block a user