mirror of
https://github.com/pipipi-pikachu/PPTist.git
synced 2025-04-15 02:20:00 +08:00
feat: 形状内支持输入文字(#46)
This commit is contained in:
parent
bc1aaefa2f
commit
a7afcc8232
@ -244,6 +244,7 @@ export default () => {
|
||||
|
||||
pptxSlide.addText(textProps, options)
|
||||
}
|
||||
|
||||
else if (el.type === 'image') {
|
||||
const options: pptxgen.ImageProps = {
|
||||
path: el.src,
|
||||
@ -260,6 +261,7 @@ export default () => {
|
||||
|
||||
pptxSlide.addImage(options)
|
||||
}
|
||||
|
||||
else if (el.type === 'shape') {
|
||||
if (el.special) {
|
||||
const svgRef = document.querySelector(`.thumbnail-list .base-element-${el.id} svg`) as HTMLElement
|
||||
@ -319,7 +321,27 @@ export default () => {
|
||||
|
||||
pptxSlide.addShape('custGeom' as pptxgen.ShapeType, options)
|
||||
}
|
||||
if (el.text) {
|
||||
const textProps = formatHTML(el.text.content)
|
||||
|
||||
const options: pptxgen.TextPropsOptions = {
|
||||
x: el.left / 100,
|
||||
y: el.top / 100,
|
||||
w: el.width / 100,
|
||||
h: el.height / 100,
|
||||
fontSize: 20 * 0.75,
|
||||
fontFace: '微软雅黑',
|
||||
color: '#000000',
|
||||
valign: el.text.align,
|
||||
}
|
||||
if (el.rotate) options.rotate = el.rotate
|
||||
if (el.text.defaultColor) options.color = formatColor(el.text.defaultColor).color
|
||||
if (el.text.defaultFontName) options.fontFace = el.text.defaultFontName
|
||||
|
||||
pptxSlide.addText(textProps, options)
|
||||
}
|
||||
}
|
||||
|
||||
else if (el.type === 'line') {
|
||||
const path = getLineElementPath(el)
|
||||
const points = formatPoints(toPoints(path))
|
||||
@ -341,6 +363,7 @@ export default () => {
|
||||
}
|
||||
pptxSlide.addShape('custGeom' as pptxgen.ShapeType, options)
|
||||
}
|
||||
|
||||
else if (el.type === 'chart') {
|
||||
const chartData = []
|
||||
for (let i = 0; i < el.data.series.length; i++) {
|
||||
@ -388,6 +411,7 @@ export default () => {
|
||||
|
||||
pptxSlide.addChart(type, chartData, options)
|
||||
}
|
||||
|
||||
else if (el.type === 'table') {
|
||||
const hiddenCells = []
|
||||
for (let i = 0; i < el.data.length; i++) {
|
||||
|
@ -81,6 +81,9 @@ import {
|
||||
Erase,
|
||||
Clear,
|
||||
FolderClose,
|
||||
AlignTextTopOne,
|
||||
AlignTextBottomOne,
|
||||
AlignTextMiddleOne,
|
||||
} from '@icon-park/vue-next'
|
||||
|
||||
export default {
|
||||
@ -152,6 +155,9 @@ export default {
|
||||
app.component('IconUpOne', UpOne)
|
||||
app.component('IconDownOne', DownOne)
|
||||
app.component('IconFormat', Format)
|
||||
app.component('IconAlignTextTopOne', AlignTextTopOne)
|
||||
app.component('IconAlignTextBottomOne', AlignTextBottomOne)
|
||||
app.component('IconAlignTextMiddleOne', AlignTextMiddleOne)
|
||||
|
||||
// 箭头与符号
|
||||
app.component('IconDown', Down)
|
||||
|
@ -17,6 +17,7 @@ export const enum MutationTypes {
|
||||
SET_RICHTEXT_ATTRS = 'setRichTextAttrs',
|
||||
SET_SELECTED_TABLE_CELLS = 'setSelectedTableCells',
|
||||
SET_SCALING_STATE = 'setScalingState',
|
||||
SET_EDITING_SHAPE_ELEMENT_ID = 'setEditingShapeElementId',
|
||||
|
||||
// slides
|
||||
SET_THEME = 'setTheme',
|
||||
|
@ -90,6 +90,10 @@ export const mutations: MutationTree<State> = {
|
||||
state.isScaling = isScaling
|
||||
},
|
||||
|
||||
[MutationTypes.SET_EDITING_SHAPE_ELEMENT_ID](state, ellId: string) {
|
||||
state.editingShapeElementId = ellId
|
||||
},
|
||||
|
||||
// slides
|
||||
|
||||
[MutationTypes.SET_THEME](state, themeProps: Partial<SlideTheme>) {
|
||||
|
@ -33,6 +33,7 @@ export interface State {
|
||||
richTextAttrs: TextAttrs;
|
||||
selectedTableCells: string[];
|
||||
isScaling: boolean;
|
||||
editingShapeElementId: string;
|
||||
}
|
||||
|
||||
export const state: State = {
|
||||
@ -62,4 +63,5 @@ export const state: State = {
|
||||
richTextAttrs: defaultRichTextAttrs, // 富文本状态
|
||||
selectedTableCells: [], // 选中的表格单元格
|
||||
isScaling: false, // 正在进行元素缩放
|
||||
editingShapeElementId: '', // 当前正处在编辑文字状态的形状ID
|
||||
}
|
@ -82,6 +82,12 @@ export interface ShapeGradient {
|
||||
color: [string, string];
|
||||
rotate: number;
|
||||
}
|
||||
export interface ShapeText {
|
||||
content: string;
|
||||
defaultFontName: string;
|
||||
defaultColor: string;
|
||||
align: 'top' | 'middle' | 'bottom';
|
||||
}
|
||||
export interface PPTShapeElement extends PPTBaseElement {
|
||||
type: 'shape';
|
||||
viewBox: number;
|
||||
@ -96,6 +102,7 @@ export interface PPTShapeElement extends PPTBaseElement {
|
||||
flipV?: boolean;
|
||||
shadow?: PPTElementShadow;
|
||||
special?: boolean;
|
||||
text?: ShapeText;
|
||||
}
|
||||
|
||||
export interface PPTLineElement extends Omit<PPTBaseElement, 'height'> {
|
||||
|
@ -70,6 +70,122 @@
|
||||
|
||||
<ElementFlip />
|
||||
<Divider />
|
||||
|
||||
<template v-if="showTextTools">
|
||||
<InputGroup compact class="row">
|
||||
<Select
|
||||
style="flex: 3;"
|
||||
:value="richTextAttrs.fontname"
|
||||
@change="value => emitRichTextCommand('fontname', value)"
|
||||
>
|
||||
<template #suffixIcon><IconFontSize /></template>
|
||||
<SelectOptGroup label="系统字体">
|
||||
<SelectOption v-for="font in availableFonts" :key="font.value" :value="font.value">
|
||||
<span :style="{ fontFamily: font.value }">{{font.label}}</span>
|
||||
</SelectOption>
|
||||
</SelectOptGroup>
|
||||
<SelectOptGroup label="在线字体">
|
||||
<SelectOption v-for="font in webFonts" :key="font.value" :value="font.value">
|
||||
<span>{{font.label}}</span>
|
||||
</SelectOption>
|
||||
</SelectOptGroup>
|
||||
</Select>
|
||||
<Select
|
||||
style="flex: 2;"
|
||||
:value="richTextAttrs.fontsize"
|
||||
@change="value => emitRichTextCommand('fontsize', value)"
|
||||
>
|
||||
<template #suffixIcon><IconAddText /></template>
|
||||
<SelectOption v-for="fontsize in fontSizeOptions" :key="fontsize" :value="fontsize">
|
||||
{{fontsize}}
|
||||
</SelectOption>
|
||||
</Select>
|
||||
</InputGroup>
|
||||
|
||||
<ButtonGroup class="row">
|
||||
<Popover trigger="click">
|
||||
<template #content>
|
||||
<ColorPicker
|
||||
:modelValue="richTextAttrs.color"
|
||||
@update:modelValue="value => emitRichTextCommand('color', value)"
|
||||
/>
|
||||
</template>
|
||||
<Tooltip :mouseLeaveDelay="0" :mouseEnterDelay="0.5" title="文字颜色">
|
||||
<Button class="text-color-btn" style="flex: 1;">
|
||||
<IconText />
|
||||
<div class="text-color-block" :style="{ backgroundColor: richTextAttrs.color }"></div>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
</ButtonGroup>
|
||||
|
||||
<CheckboxButtonGroup class="row">
|
||||
<Tooltip :mouseLeaveDelay="0" :mouseEnterDelay="0.5" title="加粗">
|
||||
<CheckboxButton
|
||||
style="flex: 1;"
|
||||
:checked="richTextAttrs.bold"
|
||||
@click="emitRichTextCommand('bold')"
|
||||
><IconTextBold /></CheckboxButton>
|
||||
</Tooltip>
|
||||
<Tooltip :mouseLeaveDelay="0" :mouseEnterDelay="0.5" title="斜体">
|
||||
<CheckboxButton
|
||||
style="flex: 1;"
|
||||
:checked="richTextAttrs.em"
|
||||
@click="emitRichTextCommand('em')"
|
||||
><IconTextItalic /></CheckboxButton>
|
||||
</Tooltip>
|
||||
<Tooltip :mouseLeaveDelay="0" :mouseEnterDelay="0.5" title="下划线">
|
||||
<CheckboxButton
|
||||
style="flex: 1;"
|
||||
:checked="richTextAttrs.underline"
|
||||
@click="emitRichTextCommand('underline')"
|
||||
><IconTextUnderline /></CheckboxButton>
|
||||
</Tooltip>
|
||||
<Tooltip :mouseLeaveDelay="0" :mouseEnterDelay="0.5" title="清除格式">
|
||||
<CheckboxButton
|
||||
style="flex: 1;"
|
||||
@click="emitRichTextCommand('clear')"
|
||||
><IconFormat /></CheckboxButton>
|
||||
</Tooltip>
|
||||
</CheckboxButtonGroup>
|
||||
|
||||
<RadioGroup
|
||||
class="row"
|
||||
button-style="solid"
|
||||
:value="richTextAttrs.align"
|
||||
@change="e => emitRichTextCommand('align', e.target.value)"
|
||||
>
|
||||
<Tooltip :mouseLeaveDelay="0" :mouseEnterDelay="0.5" title="左对齐">
|
||||
<RadioButton value="left" style="flex: 1;"><IconAlignTextLeft /></RadioButton>
|
||||
</Tooltip>
|
||||
<Tooltip :mouseLeaveDelay="0" :mouseEnterDelay="0.5" title="居中">
|
||||
<RadioButton value="center" style="flex: 1;"><IconAlignTextCenter /></RadioButton>
|
||||
</Tooltip>
|
||||
<Tooltip :mouseLeaveDelay="0" :mouseEnterDelay="0.5" title="右对齐">
|
||||
<RadioButton value="right" style="flex: 1;"><IconAlignTextRight /></RadioButton>
|
||||
</Tooltip>
|
||||
</RadioGroup>
|
||||
|
||||
<RadioGroup
|
||||
class="row"
|
||||
button-style="solid"
|
||||
:value="textAlign"
|
||||
@change="e => updateTextAlign(e.target.value)"
|
||||
>
|
||||
<Tooltip :mouseLeaveDelay="0" :mouseEnterDelay="0.5" title="顶对齐">
|
||||
<RadioButton value="top" style="flex: 1;"><IconAlignTextTopOne /></RadioButton>
|
||||
</Tooltip>
|
||||
<Tooltip :mouseLeaveDelay="0" :mouseEnterDelay="0.5" title="居中">
|
||||
<RadioButton value="middle" style="flex: 1;"><IconAlignTextMiddleOne /></RadioButton>
|
||||
</Tooltip>
|
||||
<Tooltip :mouseLeaveDelay="0" :mouseEnterDelay="0.5" title="底对齐">
|
||||
<RadioButton value="bottom" style="flex: 1;"><IconAlignTextBottomOne /></RadioButton>
|
||||
</Tooltip>
|
||||
</RadioGroup>
|
||||
|
||||
<Divider />
|
||||
</template>
|
||||
|
||||
<ElementOutline />
|
||||
<Divider />
|
||||
<ElementShadow />
|
||||
@ -81,7 +197,9 @@
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, ref, watch } from 'vue'
|
||||
import { MutationTypes, useStore } from '@/store'
|
||||
import { PPTShapeElement, ShapeGradient } from '@/types/slides'
|
||||
import { PPTShapeElement, ShapeGradient, ShapeText } from '@/types/slides'
|
||||
import { WEB_FONTS } from '@/configs/font'
|
||||
import emitter, { EmitterEvents } from '@/utils/emitter'
|
||||
import useHistorySnapshot from '@/hooks/useHistorySnapshot'
|
||||
|
||||
import ElementOpacity from '../common/ElementOpacity.vue'
|
||||
@ -90,6 +208,8 @@ import ElementShadow from '../common/ElementShadow.vue'
|
||||
import ElementFlip from '../common/ElementFlip.vue'
|
||||
import ColorButton from '../common/ColorButton.vue'
|
||||
|
||||
const webFonts = WEB_FONTS
|
||||
|
||||
export default defineComponent({
|
||||
name: 'shape-style-panel',
|
||||
components: {
|
||||
@ -102,10 +222,16 @@ export default defineComponent({
|
||||
setup() {
|
||||
const store = useStore()
|
||||
const handleElement = computed<PPTShapeElement>(() => store.getters.handleElement)
|
||||
const editingShapeElementId = computed(() => store.state.editingShapeElementId)
|
||||
|
||||
const showTextTools = computed(() => {
|
||||
return editingShapeElementId.value === handleElement.value.id
|
||||
})
|
||||
|
||||
const fill = ref<string>()
|
||||
const gradient = ref<ShapeGradient>()
|
||||
const fillType = ref('fill')
|
||||
const textAlign = ref('middle')
|
||||
|
||||
watch(handleElement, () => {
|
||||
if (!handleElement.value || handleElement.value.type !== 'shape') return
|
||||
@ -114,6 +240,8 @@ export default defineComponent({
|
||||
gradient.value = handleElement.value.gradient || { type: 'linear', rotate: 0, color: [fill.value, '#fff'] }
|
||||
|
||||
fillType.value = handleElement.value.gradient ? 'gradient' : 'fill'
|
||||
|
||||
textAlign.value = handleElement.value?.text?.align || 'middle'
|
||||
}, { deep: true, immediate: true })
|
||||
|
||||
const { addHistorySnapshot } = useHistorySnapshot()
|
||||
@ -147,23 +275,70 @@ export default defineComponent({
|
||||
addHistorySnapshot()
|
||||
}
|
||||
|
||||
const updateTextAlign = (align: 'top' | 'middle' | 'bottom') => {
|
||||
const defaultText: ShapeText = {
|
||||
content: '',
|
||||
defaultFontName: '微软雅黑',
|
||||
defaultColor: '#000',
|
||||
align: 'middle',
|
||||
}
|
||||
const _text = handleElement.value.text || defaultText
|
||||
const props = { text: { ..._text, align } }
|
||||
store.commit(MutationTypes.UPDATE_ELEMENT, { id: handleElement.value.id, props })
|
||||
addHistorySnapshot()
|
||||
}
|
||||
|
||||
const richTextAttrs = computed(() => store.state.richTextAttrs)
|
||||
const availableFonts = computed(() => store.state.availableFonts)
|
||||
const fontSizeOptions = [
|
||||
'12px', '14px', '16px', '18px', '20px', '22px', '24px', '28px', '32px',
|
||||
'36px', '40px', '44px', '48px', '54px', '60px', '66px', '72px', '76px',
|
||||
'80px', '88px', '96px', '104px', '112px', '120px',
|
||||
]
|
||||
|
||||
const emitRichTextCommand = (command: string, value?: string) => {
|
||||
emitter.emit(EmitterEvents.RICH_TEXT_COMMAND, { command, value })
|
||||
}
|
||||
|
||||
return {
|
||||
fill,
|
||||
gradient,
|
||||
fillType,
|
||||
textAlign,
|
||||
richTextAttrs,
|
||||
availableFonts,
|
||||
fontSizeOptions,
|
||||
webFonts,
|
||||
showTextTools,
|
||||
emitRichTextCommand,
|
||||
updateFillType,
|
||||
updateFill,
|
||||
updateGradient,
|
||||
updateTextAlign,
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.shape-style-panel {
|
||||
user-select: none;
|
||||
}
|
||||
.row {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.text-color-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.text-color-block {
|
||||
width: 16px;
|
||||
height: 3px;
|
||||
margin-top: 1px;
|
||||
}
|
||||
</style>
|
223
src/views/components/element/ProsemirrorEditor.vue
Normal file
223
src/views/components/element/ProsemirrorEditor.vue
Normal file
@ -0,0 +1,223 @@
|
||||
<template>
|
||||
<div
|
||||
class="prosemirror-editor"
|
||||
ref="editorViewRef"
|
||||
></div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { debounce } from 'lodash'
|
||||
import { MutationTypes, useStore } from '@/store'
|
||||
import { EditorView } from 'prosemirror-view'
|
||||
import { toggleMark, wrapIn, selectAll } from 'prosemirror-commands'
|
||||
import { initProsemirrorEditor } from '@/utils/prosemirror/'
|
||||
import { getTextAttrs } from '@/utils/prosemirror/utils'
|
||||
import emitter, { EmitterEvents, RichTextCommand } from '@/utils/emitter'
|
||||
import { alignmentCommand } from '@/utils/prosemirror/commands/setTextAlign'
|
||||
import { toggleList } from '@/utils/prosemirror/commands/toggleList'
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
elementId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
defaultColor: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
defaultFontName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
editable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
value: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const store = useStore()
|
||||
const handleElementId = computed(() => store.state.handleElementId)
|
||||
|
||||
const editorViewRef = ref<HTMLElement>()
|
||||
let editorView: EditorView
|
||||
|
||||
// 富文本的各种交互事件监听:
|
||||
// 聚焦时取消全局快捷键事件
|
||||
// 输入文字时同步数据到vuex
|
||||
// 点击鼠标和键盘时同步富文本状态到工具栏
|
||||
const handleInput = debounce(function() {
|
||||
emit('update', editorView.dom.innerHTML)
|
||||
}, 300, { trailing: true })
|
||||
|
||||
const handleFocus = () => {
|
||||
if (props.value === '请输入内容') {
|
||||
setTimeout(() => {
|
||||
selectAll(editorView.state, editorView.dispatch)
|
||||
}, 0)
|
||||
}
|
||||
store.commit(MutationTypes.SET_DISABLE_HOTKEYS_STATE, true)
|
||||
emit('focus')
|
||||
}
|
||||
|
||||
const handleBlur = () => {
|
||||
store.commit(MutationTypes.SET_DISABLE_HOTKEYS_STATE, false)
|
||||
emit('blur')
|
||||
}
|
||||
|
||||
const handleClick = debounce(function() {
|
||||
const attrs = getTextAttrs(editorView, {
|
||||
color: props.defaultColor,
|
||||
fontname: props.defaultFontName,
|
||||
})
|
||||
store.commit(MutationTypes.SET_RICHTEXT_ATTRS, attrs)
|
||||
}, 30, { trailing: true })
|
||||
|
||||
const handleKeydown = () => {
|
||||
handleInput()
|
||||
handleClick()
|
||||
}
|
||||
|
||||
// 将富文本内容同步到DOM
|
||||
const textContent = computed(() => props.value)
|
||||
watch(textContent, () => {
|
||||
if (!editorView) return
|
||||
if (editorView.hasFocus()) return
|
||||
editorView.dom.innerHTML = textContent.value
|
||||
})
|
||||
|
||||
// 打开/关闭编辑器的编辑模式
|
||||
watch(() => props.editable, () => {
|
||||
editorView.setProps({ editable: () => props.editable })
|
||||
})
|
||||
|
||||
// Prosemirror编辑器的初始化和卸载
|
||||
onMounted(() => {
|
||||
editorView = initProsemirrorEditor((editorViewRef.value as Element), textContent.value, {
|
||||
handleDOMEvents: {
|
||||
focus: handleFocus,
|
||||
blur: handleBlur,
|
||||
keydown: handleKeydown,
|
||||
click: handleClick,
|
||||
},
|
||||
editable: () => props.editable,
|
||||
})
|
||||
})
|
||||
onUnmounted(() => {
|
||||
editorView && editorView.destroy()
|
||||
})
|
||||
|
||||
// 执行富文本命令(可以是一个或多个)
|
||||
// 部分命令在执行前先判断当前选区是否为空,如果选区为空先进行全选操作
|
||||
const execCommand = (payload: RichTextCommand | RichTextCommand[]) => {
|
||||
if (handleElementId.value !== props.elementId) return
|
||||
|
||||
const commands = ('command' in payload) ? [payload] : payload
|
||||
|
||||
for (const item of commands) {
|
||||
if (item.command === 'fontname' && item.value) {
|
||||
const mark = editorView.state.schema.marks.fontname.create({ fontname: item.value })
|
||||
const { empty } = editorView.state.selection
|
||||
if (empty) selectAll(editorView.state, editorView.dispatch)
|
||||
const { $from, $to } = editorView.state.selection
|
||||
editorView.dispatch(editorView.state.tr.addMark($from.pos, $to.pos, mark))
|
||||
}
|
||||
else if (item.command === 'fontsize' && item.value) {
|
||||
const mark = editorView.state.schema.marks.fontsize.create({ fontsize: item.value })
|
||||
const { empty } = editorView.state.selection
|
||||
if (empty) selectAll(editorView.state, editorView.dispatch)
|
||||
const { $from, $to } = editorView.state.selection
|
||||
editorView.dispatch(editorView.state.tr.addMark($from.pos, $to.pos, mark))
|
||||
}
|
||||
else if (item.command === 'color' && item.value) {
|
||||
const mark = editorView.state.schema.marks.forecolor.create({ color: item.value })
|
||||
const { empty } = editorView.state.selection
|
||||
if (empty) selectAll(editorView.state, editorView.dispatch)
|
||||
const { $from, $to } = editorView.state.selection
|
||||
editorView.dispatch(editorView.state.tr.addMark($from.pos, $to.pos, mark))
|
||||
}
|
||||
else if (item.command === 'backcolor' && item.value) {
|
||||
const mark = editorView.state.schema.marks.backcolor.create({ backcolor: item.value })
|
||||
const { empty } = editorView.state.selection
|
||||
if (empty) selectAll(editorView.state, editorView.dispatch)
|
||||
const { $from, $to } = editorView.state.selection
|
||||
editorView.dispatch(editorView.state.tr.addMark($from.pos, $to.pos, mark))
|
||||
}
|
||||
else if (item.command === 'bold') {
|
||||
const { empty } = editorView.state.selection
|
||||
if (empty) selectAll(editorView.state, editorView.dispatch)
|
||||
toggleMark(editorView.state.schema.marks.strong)(editorView.state, editorView.dispatch)
|
||||
}
|
||||
else if (item.command === 'em') {
|
||||
const { empty } = editorView.state.selection
|
||||
if (empty) selectAll(editorView.state, editorView.dispatch)
|
||||
toggleMark(editorView.state.schema.marks.em)(editorView.state, editorView.dispatch)
|
||||
}
|
||||
else if (item.command === 'underline') {
|
||||
const { empty } = editorView.state.selection
|
||||
if (empty) selectAll(editorView.state, editorView.dispatch)
|
||||
toggleMark(editorView.state.schema.marks.underline)(editorView.state, editorView.dispatch)
|
||||
}
|
||||
else if (item.command === 'strikethrough') {
|
||||
const { empty } = editorView.state.selection
|
||||
if (empty) selectAll(editorView.state, editorView.dispatch)
|
||||
toggleMark(editorView.state.schema.marks.strikethrough)(editorView.state, editorView.dispatch)
|
||||
}
|
||||
else if (item.command === 'subscript') {
|
||||
toggleMark(editorView.state.schema.marks.subscript)(editorView.state, editorView.dispatch)
|
||||
}
|
||||
else if (item.command === 'superscript') {
|
||||
toggleMark(editorView.state.schema.marks.superscript)(editorView.state, editorView.dispatch)
|
||||
}
|
||||
else if (item.command === 'blockquote') {
|
||||
wrapIn(editorView.state.schema.nodes.blockquote)(editorView.state, editorView.dispatch)
|
||||
}
|
||||
else if (item.command === 'code') {
|
||||
toggleMark(editorView.state.schema.marks.code)(editorView.state, editorView.dispatch)
|
||||
}
|
||||
else if (item.command === 'align' && item.value) {
|
||||
alignmentCommand(editorView, item.value)
|
||||
}
|
||||
else if (item.command === 'bulletList') {
|
||||
const { bullet_list: bulletList, list_item: listItem } = editorView.state.schema.nodes
|
||||
toggleList(bulletList, listItem)(editorView.state, editorView.dispatch)
|
||||
}
|
||||
else if (item.command === 'orderedList') {
|
||||
const { ordered_list: orderedList, list_item: listItem } = editorView.state.schema.nodes
|
||||
toggleList(orderedList, listItem)(editorView.state, editorView.dispatch)
|
||||
}
|
||||
else if (item.command === 'clear') {
|
||||
const { empty } = editorView.state.selection
|
||||
if (empty) selectAll(editorView.state, editorView.dispatch)
|
||||
const { $from, $to } = editorView.state.selection
|
||||
editorView.dispatch(editorView.state.tr.removeMark($from.pos, $to.pos))
|
||||
}
|
||||
}
|
||||
|
||||
editorView.focus()
|
||||
handleInput()
|
||||
handleClick()
|
||||
}
|
||||
|
||||
emitter.on(EmitterEvents.RICH_TEXT_COMMAND, execCommand)
|
||||
onUnmounted(() => {
|
||||
emitter.off(EmitterEvents.RICH_TEXT_COMMAND, execCommand)
|
||||
})
|
||||
|
||||
return {
|
||||
editorViewRef,
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.prosemirror-editor {
|
||||
cursor: text;
|
||||
}
|
||||
</style>
|
@ -18,6 +18,8 @@
|
||||
opacity: elementInfo.opacity,
|
||||
filter: shadowStyle ? `drop-shadow(${shadowStyle})` : '',
|
||||
transform: flipStyle,
|
||||
color: text.defaultColor,
|
||||
fontFamily: text.defaultFontName,
|
||||
}"
|
||||
>
|
||||
<SvgWrapper
|
||||
@ -50,6 +52,10 @@
|
||||
></path>
|
||||
</g>
|
||||
</SvgWrapper>
|
||||
|
||||
<div class="shape-text" :class="text.align">
|
||||
<div class="ProseMirror-static" v-html="text.content"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -57,7 +63,7 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, PropType } from 'vue'
|
||||
import { PPTShapeElement } from '@/types/slides'
|
||||
import { PPTShapeElement, ShapeText } from '@/types/slides'
|
||||
import useElementOutline from '@/views/components/element/hooks/useElementOutline'
|
||||
import useElementShadow from '@/views/components/element/hooks/useElementShadow'
|
||||
import useElementFlip from '@/views/components/element/hooks/useElementFlip'
|
||||
@ -86,12 +92,25 @@ export default defineComponent({
|
||||
const flipV = computed(() => props.elementInfo.flipV)
|
||||
const { flipStyle } = useElementFlip(flipH, flipV)
|
||||
|
||||
const text = computed<ShapeText>(() => {
|
||||
const defaultText: ShapeText = {
|
||||
content: '',
|
||||
defaultFontName: '微软雅黑',
|
||||
defaultColor: '#000',
|
||||
align: 'middle',
|
||||
}
|
||||
if (!props.elementInfo.text) return defaultText
|
||||
|
||||
return props.elementInfo.text
|
||||
})
|
||||
|
||||
return {
|
||||
shadowStyle,
|
||||
outlineWidth,
|
||||
outlineStyle,
|
||||
outlineColor,
|
||||
flipStyle,
|
||||
text,
|
||||
}
|
||||
},
|
||||
})
|
||||
@ -115,4 +134,26 @@ export default defineComponent({
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
.shape-text {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 10px;
|
||||
line-height: 1.2;
|
||||
word-break: break-word;
|
||||
|
||||
&.top {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
&.middle {
|
||||
justify-content: center;
|
||||
}
|
||||
&.bottom {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@ -19,9 +19,12 @@
|
||||
opacity: elementInfo.opacity,
|
||||
filter: shadowStyle ? `drop-shadow(${shadowStyle})` : '',
|
||||
transform: flipStyle,
|
||||
color: text.defaultColor,
|
||||
fontFamily: text.defaultFontName,
|
||||
}"
|
||||
v-contextmenu="contextmenus"
|
||||
@mousedown="$event => handleSelectElement($event)"
|
||||
@dblclick="enterEditing()"
|
||||
>
|
||||
<SvgWrapper
|
||||
overflow="visible"
|
||||
@ -53,25 +56,47 @@
|
||||
></path>
|
||||
</g>
|
||||
</SvgWrapper>
|
||||
|
||||
<div class="shape-text" :class="text.align">
|
||||
<ProsemirrorEditor
|
||||
v-if="editable"
|
||||
:elementId="elementInfo.id"
|
||||
:defaultColor="text.defaultColor"
|
||||
:defaultFontName="text.defaultFontName"
|
||||
:editable="!elementInfo.lock"
|
||||
:value="text.content"
|
||||
@update="value => updateText(value)"
|
||||
@mousedown.stop
|
||||
/>
|
||||
<div
|
||||
class="show-text ProseMirror-static"
|
||||
v-else
|
||||
v-html="text.content"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, PropType } from 'vue'
|
||||
import { PPTShapeElement } from '@/types/slides'
|
||||
import { computed, defineComponent, PropType, ref, watch } from 'vue'
|
||||
import { MutationTypes, useStore } from '@/store'
|
||||
import { PPTShapeElement, ShapeText } from '@/types/slides'
|
||||
import { ContextmenuItem } from '@/components/Contextmenu/types'
|
||||
import useElementOutline from '@/views/components/element/hooks/useElementOutline'
|
||||
import useElementShadow from '@/views/components/element/hooks/useElementShadow'
|
||||
import useElementFlip from '@/views/components/element/hooks/useElementFlip'
|
||||
import useHistorySnapshot from '@/hooks/useHistorySnapshot'
|
||||
|
||||
import GradientDefs from './GradientDefs.vue'
|
||||
import ProsemirrorEditor from '@/views/components/element/ProsemirrorEditor.vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'editable-element-shape',
|
||||
components: {
|
||||
GradientDefs,
|
||||
ProsemirrorEditor,
|
||||
},
|
||||
props: {
|
||||
elementInfo: {
|
||||
@ -87,6 +112,10 @@ export default defineComponent({
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const store = useStore()
|
||||
|
||||
const { addHistorySnapshot } = useHistorySnapshot()
|
||||
|
||||
const handleSelectElement = (e: MouseEvent) => {
|
||||
if (props.elementInfo.lock) return
|
||||
e.stopPropagation()
|
||||
@ -104,13 +133,58 @@ export default defineComponent({
|
||||
const flipV = computed(() => props.elementInfo.flipV)
|
||||
const { flipStyle } = useElementFlip(flipH, flipV)
|
||||
|
||||
const editable = ref(false)
|
||||
|
||||
const enterEditing = () => {
|
||||
editable.value = true
|
||||
store.commit(MutationTypes.SET_EDITING_SHAPE_ELEMENT_ID, props.elementInfo.id)
|
||||
}
|
||||
|
||||
const exitEditing = () => {
|
||||
editable.value = false
|
||||
store.commit(MutationTypes.SET_EDITING_SHAPE_ELEMENT_ID, '')
|
||||
}
|
||||
|
||||
const handleElementId = computed(() => store.state.handleElementId)
|
||||
watch(handleElementId, () => {
|
||||
if (handleElementId.value !== props.elementInfo.id) {
|
||||
if (editable.value) exitEditing()
|
||||
}
|
||||
})
|
||||
|
||||
const text = computed<ShapeText>(() => {
|
||||
const defaultText: ShapeText = {
|
||||
content: '',
|
||||
defaultFontName: '微软雅黑',
|
||||
defaultColor: '#000',
|
||||
align: 'middle',
|
||||
}
|
||||
if (!props.elementInfo.text) return defaultText
|
||||
|
||||
return props.elementInfo.text
|
||||
})
|
||||
|
||||
const updateText = (content: string) => {
|
||||
const _text = { ...text.value, content }
|
||||
store.commit(MutationTypes.UPDATE_ELEMENT, {
|
||||
id: props.elementInfo.id,
|
||||
props: { text: _text },
|
||||
})
|
||||
|
||||
addHistorySnapshot()
|
||||
}
|
||||
|
||||
return {
|
||||
handleSelectElement,
|
||||
shadowStyle,
|
||||
outlineWidth,
|
||||
outlineStyle,
|
||||
outlineColor,
|
||||
flipStyle,
|
||||
editable,
|
||||
text,
|
||||
handleSelectElement,
|
||||
updateText,
|
||||
enterEditing,
|
||||
}
|
||||
},
|
||||
})
|
||||
@ -139,4 +213,29 @@ export default defineComponent({
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
.shape-text {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 10px;
|
||||
line-height: 1.2;
|
||||
word-break: break-word;
|
||||
|
||||
&.top {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
&.middle {
|
||||
justify-content: center;
|
||||
}
|
||||
&.bottom {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
.show-text {
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
|
@ -32,11 +32,16 @@
|
||||
:height="elementInfo.height"
|
||||
:outline="elementInfo.outline"
|
||||
/>
|
||||
<div
|
||||
<ProsemirrorEditor
|
||||
class="text"
|
||||
ref="editorViewRef"
|
||||
:elementId="elementInfo.id"
|
||||
:defaultColor="elementInfo.defaultColor"
|
||||
:defaultFontName="elementInfo.defaultFontName"
|
||||
:editable="!elementInfo.lock"
|
||||
:value="elementInfo.content"
|
||||
@update="value => updateContent(value)"
|
||||
@mousedown="$event => handleSelectElement($event, false)"
|
||||
></div>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -44,26 +49,20 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, onMounted, onUnmounted, PropType, ref, watch } from 'vue'
|
||||
import { debounce } from 'lodash'
|
||||
import { MutationTypes, useStore } from '@/store'
|
||||
import { EditorView } from 'prosemirror-view'
|
||||
import { toggleMark, wrapIn, selectAll } from 'prosemirror-commands'
|
||||
import { PPTTextElement } from '@/types/slides'
|
||||
import { ContextmenuItem } from '@/components/Contextmenu/types'
|
||||
import { initProsemirrorEditor } from '@/utils/prosemirror/'
|
||||
import { getTextAttrs } from '@/utils/prosemirror/utils'
|
||||
import emitter, { EmitterEvents, RichTextCommand } from '@/utils/emitter'
|
||||
import useElementShadow from '@/views/components/element/hooks/useElementShadow'
|
||||
import { alignmentCommand } from '@/utils/prosemirror/commands/setTextAlign'
|
||||
import { toggleList } from '@/utils/prosemirror/commands/toggleList'
|
||||
import useHistorySnapshot from '@/hooks/useHistorySnapshot'
|
||||
|
||||
import ElementOutline from '@/views/components/element/ElementOutline.vue'
|
||||
import ProsemirrorEditor from '@/views/components/element/ProsemirrorEditor.vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'editable-element-text',
|
||||
components: {
|
||||
ElementOutline,
|
||||
ProsemirrorEditor,
|
||||
},
|
||||
props: {
|
||||
elementInfo: {
|
||||
@ -84,9 +83,6 @@ export default defineComponent({
|
||||
|
||||
const elementRef = ref<HTMLElement>()
|
||||
|
||||
const editorViewRef = ref<HTMLElement>()
|
||||
let editorView: EditorView
|
||||
|
||||
const shadow = computed(() => props.elementInfo.shadow)
|
||||
const { shadowStyle } = useElementShadow(shadow)
|
||||
|
||||
@ -142,176 +138,20 @@ export default defineComponent({
|
||||
if (elementRef.value) resizeObserver.unobserve(elementRef.value)
|
||||
})
|
||||
|
||||
// 富文本的各种交互事件监听:
|
||||
// 聚焦时取消全局快捷键事件
|
||||
// 输入文字时同步数据到vuex
|
||||
// 点击鼠标和键盘时同步富文本状态到工具栏
|
||||
const handleInput = debounce(function() {
|
||||
const updateContent = (content: string) => {
|
||||
store.commit(MutationTypes.UPDATE_ELEMENT, {
|
||||
id: props.elementInfo.id,
|
||||
props: { content: editorView.dom.innerHTML },
|
||||
props: { content },
|
||||
})
|
||||
|
||||
addHistorySnapshot()
|
||||
}, 300, { trailing: true })
|
||||
|
||||
const handleFocus = () => {
|
||||
if (props.elementInfo.content === '请输入内容') {
|
||||
setTimeout(() => {
|
||||
selectAll(editorView.state, editorView.dispatch)
|
||||
}, 0)
|
||||
}
|
||||
store.commit(MutationTypes.SET_DISABLE_HOTKEYS_STATE, true)
|
||||
}
|
||||
|
||||
const handleBlur = () => {
|
||||
store.commit(MutationTypes.SET_DISABLE_HOTKEYS_STATE, false)
|
||||
}
|
||||
|
||||
const handleClick = debounce(function() {
|
||||
const attrs = getTextAttrs(editorView, {
|
||||
color: props.elementInfo.defaultColor,
|
||||
fontname: props.elementInfo.defaultFontName,
|
||||
})
|
||||
store.commit(MutationTypes.SET_RICHTEXT_ATTRS, attrs)
|
||||
}, 30, { trailing: true })
|
||||
|
||||
const handleKeydown = () => {
|
||||
handleInput()
|
||||
handleClick()
|
||||
}
|
||||
|
||||
// 将富文本内容同步到DOM
|
||||
const textContent = computed(() => props.elementInfo.content)
|
||||
watch(textContent, () => {
|
||||
if (!editorView) return
|
||||
if (editorView.hasFocus()) return
|
||||
editorView.dom.innerHTML = textContent.value
|
||||
})
|
||||
|
||||
// 打开/关闭编辑器的编辑模式
|
||||
const editable = computed(() => !props.elementInfo.lock)
|
||||
watch(editable, () => {
|
||||
editorView.setProps({ editable: () => editable.value })
|
||||
})
|
||||
|
||||
// Prosemirror编辑器的初始化和卸载
|
||||
onMounted(() => {
|
||||
editorView = initProsemirrorEditor((editorViewRef.value as Element), textContent.value, {
|
||||
handleDOMEvents: {
|
||||
focus: handleFocus,
|
||||
blur: handleBlur,
|
||||
keydown: handleKeydown,
|
||||
click: handleClick,
|
||||
},
|
||||
editable: () => editable.value,
|
||||
})
|
||||
})
|
||||
onUnmounted(() => {
|
||||
editorView && editorView.destroy()
|
||||
})
|
||||
|
||||
// 执行富文本命令(可以是一个或多个)
|
||||
// 部分命令在执行前先判断当前选区是否为空,如果选区为空先进行全选操作
|
||||
const execCommand = (payload: RichTextCommand | RichTextCommand[]) => {
|
||||
if (handleElementId.value !== props.elementInfo.id) return
|
||||
|
||||
const commands = ('command' in payload) ? [payload] : payload
|
||||
|
||||
for (const item of commands) {
|
||||
if (item.command === 'fontname' && item.value) {
|
||||
const mark = editorView.state.schema.marks.fontname.create({ fontname: item.value })
|
||||
const { empty } = editorView.state.selection
|
||||
if (empty) selectAll(editorView.state, editorView.dispatch)
|
||||
const { $from, $to } = editorView.state.selection
|
||||
editorView.dispatch(editorView.state.tr.addMark($from.pos, $to.pos, mark))
|
||||
}
|
||||
else if (item.command === 'fontsize' && item.value) {
|
||||
const mark = editorView.state.schema.marks.fontsize.create({ fontsize: item.value })
|
||||
const { empty } = editorView.state.selection
|
||||
if (empty) selectAll(editorView.state, editorView.dispatch)
|
||||
const { $from, $to } = editorView.state.selection
|
||||
editorView.dispatch(editorView.state.tr.addMark($from.pos, $to.pos, mark))
|
||||
}
|
||||
else if (item.command === 'color' && item.value) {
|
||||
const mark = editorView.state.schema.marks.forecolor.create({ color: item.value })
|
||||
const { empty } = editorView.state.selection
|
||||
if (empty) selectAll(editorView.state, editorView.dispatch)
|
||||
const { $from, $to } = editorView.state.selection
|
||||
editorView.dispatch(editorView.state.tr.addMark($from.pos, $to.pos, mark))
|
||||
}
|
||||
else if (item.command === 'backcolor' && item.value) {
|
||||
const mark = editorView.state.schema.marks.backcolor.create({ backcolor: item.value })
|
||||
const { empty } = editorView.state.selection
|
||||
if (empty) selectAll(editorView.state, editorView.dispatch)
|
||||
const { $from, $to } = editorView.state.selection
|
||||
editorView.dispatch(editorView.state.tr.addMark($from.pos, $to.pos, mark))
|
||||
}
|
||||
else if (item.command === 'bold') {
|
||||
const { empty } = editorView.state.selection
|
||||
if (empty) selectAll(editorView.state, editorView.dispatch)
|
||||
toggleMark(editorView.state.schema.marks.strong)(editorView.state, editorView.dispatch)
|
||||
}
|
||||
else if (item.command === 'em') {
|
||||
const { empty } = editorView.state.selection
|
||||
if (empty) selectAll(editorView.state, editorView.dispatch)
|
||||
toggleMark(editorView.state.schema.marks.em)(editorView.state, editorView.dispatch)
|
||||
}
|
||||
else if (item.command === 'underline') {
|
||||
const { empty } = editorView.state.selection
|
||||
if (empty) selectAll(editorView.state, editorView.dispatch)
|
||||
toggleMark(editorView.state.schema.marks.underline)(editorView.state, editorView.dispatch)
|
||||
}
|
||||
else if (item.command === 'strikethrough') {
|
||||
const { empty } = editorView.state.selection
|
||||
if (empty) selectAll(editorView.state, editorView.dispatch)
|
||||
toggleMark(editorView.state.schema.marks.strikethrough)(editorView.state, editorView.dispatch)
|
||||
}
|
||||
else if (item.command === 'subscript') {
|
||||
toggleMark(editorView.state.schema.marks.subscript)(editorView.state, editorView.dispatch)
|
||||
}
|
||||
else if (item.command === 'superscript') {
|
||||
toggleMark(editorView.state.schema.marks.superscript)(editorView.state, editorView.dispatch)
|
||||
}
|
||||
else if (item.command === 'blockquote') {
|
||||
wrapIn(editorView.state.schema.nodes.blockquote)(editorView.state, editorView.dispatch)
|
||||
}
|
||||
else if (item.command === 'code') {
|
||||
toggleMark(editorView.state.schema.marks.code)(editorView.state, editorView.dispatch)
|
||||
}
|
||||
else if (item.command === 'align' && item.value) {
|
||||
alignmentCommand(editorView, item.value)
|
||||
}
|
||||
else if (item.command === 'bulletList') {
|
||||
const { bullet_list: bulletList, list_item: listItem } = editorView.state.schema.nodes
|
||||
toggleList(bulletList, listItem)(editorView.state, editorView.dispatch)
|
||||
}
|
||||
else if (item.command === 'orderedList') {
|
||||
const { ordered_list: orderedList, list_item: listItem } = editorView.state.schema.nodes
|
||||
toggleList(orderedList, listItem)(editorView.state, editorView.dispatch)
|
||||
}
|
||||
else if (item.command === 'clear') {
|
||||
const { empty } = editorView.state.selection
|
||||
if (empty) selectAll(editorView.state, editorView.dispatch)
|
||||
const { $from, $to } = editorView.state.selection
|
||||
editorView.dispatch(editorView.state.tr.removeMark($from.pos, $to.pos))
|
||||
}
|
||||
}
|
||||
|
||||
editorView.focus()
|
||||
handleInput()
|
||||
handleClick()
|
||||
}
|
||||
|
||||
emitter.on(EmitterEvents.RICH_TEXT_COMMAND, execCommand)
|
||||
onUnmounted(() => {
|
||||
emitter.off(EmitterEvents.RICH_TEXT_COMMAND, execCommand)
|
||||
})
|
||||
|
||||
return {
|
||||
elementRef,
|
||||
editorViewRef,
|
||||
handleSelectElement,
|
||||
shadowStyle,
|
||||
updateContent,
|
||||
handleSelectElement,
|
||||
}
|
||||
},
|
||||
})
|
||||
@ -338,7 +178,6 @@ export default defineComponent({
|
||||
|
||||
.text {
|
||||
position: relative;
|
||||
cursor: text;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
Loading…
x
Reference in New Issue
Block a user