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)
|
pptxSlide.addText(textProps, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
else if (el.type === 'image') {
|
else if (el.type === 'image') {
|
||||||
const options: pptxgen.ImageProps = {
|
const options: pptxgen.ImageProps = {
|
||||||
path: el.src,
|
path: el.src,
|
||||||
@ -260,6 +261,7 @@ export default () => {
|
|||||||
|
|
||||||
pptxSlide.addImage(options)
|
pptxSlide.addImage(options)
|
||||||
}
|
}
|
||||||
|
|
||||||
else if (el.type === 'shape') {
|
else if (el.type === 'shape') {
|
||||||
if (el.special) {
|
if (el.special) {
|
||||||
const svgRef = document.querySelector(`.thumbnail-list .base-element-${el.id} svg`) as HTMLElement
|
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)
|
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') {
|
else if (el.type === 'line') {
|
||||||
const path = getLineElementPath(el)
|
const path = getLineElementPath(el)
|
||||||
const points = formatPoints(toPoints(path))
|
const points = formatPoints(toPoints(path))
|
||||||
@ -341,6 +363,7 @@ export default () => {
|
|||||||
}
|
}
|
||||||
pptxSlide.addShape('custGeom' as pptxgen.ShapeType, options)
|
pptxSlide.addShape('custGeom' as pptxgen.ShapeType, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
else if (el.type === 'chart') {
|
else if (el.type === 'chart') {
|
||||||
const chartData = []
|
const chartData = []
|
||||||
for (let i = 0; i < el.data.series.length; i++) {
|
for (let i = 0; i < el.data.series.length; i++) {
|
||||||
@ -388,6 +411,7 @@ export default () => {
|
|||||||
|
|
||||||
pptxSlide.addChart(type, chartData, options)
|
pptxSlide.addChart(type, chartData, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
else if (el.type === 'table') {
|
else if (el.type === 'table') {
|
||||||
const hiddenCells = []
|
const hiddenCells = []
|
||||||
for (let i = 0; i < el.data.length; i++) {
|
for (let i = 0; i < el.data.length; i++) {
|
||||||
|
@ -81,6 +81,9 @@ import {
|
|||||||
Erase,
|
Erase,
|
||||||
Clear,
|
Clear,
|
||||||
FolderClose,
|
FolderClose,
|
||||||
|
AlignTextTopOne,
|
||||||
|
AlignTextBottomOne,
|
||||||
|
AlignTextMiddleOne,
|
||||||
} from '@icon-park/vue-next'
|
} from '@icon-park/vue-next'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@ -152,6 +155,9 @@ export default {
|
|||||||
app.component('IconUpOne', UpOne)
|
app.component('IconUpOne', UpOne)
|
||||||
app.component('IconDownOne', DownOne)
|
app.component('IconDownOne', DownOne)
|
||||||
app.component('IconFormat', Format)
|
app.component('IconFormat', Format)
|
||||||
|
app.component('IconAlignTextTopOne', AlignTextTopOne)
|
||||||
|
app.component('IconAlignTextBottomOne', AlignTextBottomOne)
|
||||||
|
app.component('IconAlignTextMiddleOne', AlignTextMiddleOne)
|
||||||
|
|
||||||
// 箭头与符号
|
// 箭头与符号
|
||||||
app.component('IconDown', Down)
|
app.component('IconDown', Down)
|
||||||
|
@ -17,6 +17,7 @@ export const enum MutationTypes {
|
|||||||
SET_RICHTEXT_ATTRS = 'setRichTextAttrs',
|
SET_RICHTEXT_ATTRS = 'setRichTextAttrs',
|
||||||
SET_SELECTED_TABLE_CELLS = 'setSelectedTableCells',
|
SET_SELECTED_TABLE_CELLS = 'setSelectedTableCells',
|
||||||
SET_SCALING_STATE = 'setScalingState',
|
SET_SCALING_STATE = 'setScalingState',
|
||||||
|
SET_EDITING_SHAPE_ELEMENT_ID = 'setEditingShapeElementId',
|
||||||
|
|
||||||
// slides
|
// slides
|
||||||
SET_THEME = 'setTheme',
|
SET_THEME = 'setTheme',
|
||||||
|
@ -90,6 +90,10 @@ export const mutations: MutationTree<State> = {
|
|||||||
state.isScaling = isScaling
|
state.isScaling = isScaling
|
||||||
},
|
},
|
||||||
|
|
||||||
|
[MutationTypes.SET_EDITING_SHAPE_ELEMENT_ID](state, ellId: string) {
|
||||||
|
state.editingShapeElementId = ellId
|
||||||
|
},
|
||||||
|
|
||||||
// slides
|
// slides
|
||||||
|
|
||||||
[MutationTypes.SET_THEME](state, themeProps: Partial<SlideTheme>) {
|
[MutationTypes.SET_THEME](state, themeProps: Partial<SlideTheme>) {
|
||||||
|
@ -33,6 +33,7 @@ export interface State {
|
|||||||
richTextAttrs: TextAttrs;
|
richTextAttrs: TextAttrs;
|
||||||
selectedTableCells: string[];
|
selectedTableCells: string[];
|
||||||
isScaling: boolean;
|
isScaling: boolean;
|
||||||
|
editingShapeElementId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const state: State = {
|
export const state: State = {
|
||||||
@ -62,4 +63,5 @@ export const state: State = {
|
|||||||
richTextAttrs: defaultRichTextAttrs, // 富文本状态
|
richTextAttrs: defaultRichTextAttrs, // 富文本状态
|
||||||
selectedTableCells: [], // 选中的表格单元格
|
selectedTableCells: [], // 选中的表格单元格
|
||||||
isScaling: false, // 正在进行元素缩放
|
isScaling: false, // 正在进行元素缩放
|
||||||
|
editingShapeElementId: '', // 当前正处在编辑文字状态的形状ID
|
||||||
}
|
}
|
@ -82,6 +82,12 @@ export interface ShapeGradient {
|
|||||||
color: [string, string];
|
color: [string, string];
|
||||||
rotate: number;
|
rotate: number;
|
||||||
}
|
}
|
||||||
|
export interface ShapeText {
|
||||||
|
content: string;
|
||||||
|
defaultFontName: string;
|
||||||
|
defaultColor: string;
|
||||||
|
align: 'top' | 'middle' | 'bottom';
|
||||||
|
}
|
||||||
export interface PPTShapeElement extends PPTBaseElement {
|
export interface PPTShapeElement extends PPTBaseElement {
|
||||||
type: 'shape';
|
type: 'shape';
|
||||||
viewBox: number;
|
viewBox: number;
|
||||||
@ -96,6 +102,7 @@ export interface PPTShapeElement extends PPTBaseElement {
|
|||||||
flipV?: boolean;
|
flipV?: boolean;
|
||||||
shadow?: PPTElementShadow;
|
shadow?: PPTElementShadow;
|
||||||
special?: boolean;
|
special?: boolean;
|
||||||
|
text?: ShapeText;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PPTLineElement extends Omit<PPTBaseElement, 'height'> {
|
export interface PPTLineElement extends Omit<PPTBaseElement, 'height'> {
|
||||||
|
@ -70,6 +70,122 @@
|
|||||||
|
|
||||||
<ElementFlip />
|
<ElementFlip />
|
||||||
<Divider />
|
<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 />
|
<ElementOutline />
|
||||||
<Divider />
|
<Divider />
|
||||||
<ElementShadow />
|
<ElementShadow />
|
||||||
@ -81,7 +197,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { computed, defineComponent, ref, watch } from 'vue'
|
import { computed, defineComponent, ref, watch } from 'vue'
|
||||||
import { MutationTypes, useStore } from '@/store'
|
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 useHistorySnapshot from '@/hooks/useHistorySnapshot'
|
||||||
|
|
||||||
import ElementOpacity from '../common/ElementOpacity.vue'
|
import ElementOpacity from '../common/ElementOpacity.vue'
|
||||||
@ -90,6 +208,8 @@ import ElementShadow from '../common/ElementShadow.vue'
|
|||||||
import ElementFlip from '../common/ElementFlip.vue'
|
import ElementFlip from '../common/ElementFlip.vue'
|
||||||
import ColorButton from '../common/ColorButton.vue'
|
import ColorButton from '../common/ColorButton.vue'
|
||||||
|
|
||||||
|
const webFonts = WEB_FONTS
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: 'shape-style-panel',
|
name: 'shape-style-panel',
|
||||||
components: {
|
components: {
|
||||||
@ -102,10 +222,16 @@ export default defineComponent({
|
|||||||
setup() {
|
setup() {
|
||||||
const store = useStore()
|
const store = useStore()
|
||||||
const handleElement = computed<PPTShapeElement>(() => store.getters.handleElement)
|
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 fill = ref<string>()
|
||||||
const gradient = ref<ShapeGradient>()
|
const gradient = ref<ShapeGradient>()
|
||||||
const fillType = ref('fill')
|
const fillType = ref('fill')
|
||||||
|
const textAlign = ref('middle')
|
||||||
|
|
||||||
watch(handleElement, () => {
|
watch(handleElement, () => {
|
||||||
if (!handleElement.value || handleElement.value.type !== 'shape') return
|
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'] }
|
gradient.value = handleElement.value.gradient || { type: 'linear', rotate: 0, color: [fill.value, '#fff'] }
|
||||||
|
|
||||||
fillType.value = handleElement.value.gradient ? 'gradient' : 'fill'
|
fillType.value = handleElement.value.gradient ? 'gradient' : 'fill'
|
||||||
|
|
||||||
|
textAlign.value = handleElement.value?.text?.align || 'middle'
|
||||||
}, { deep: true, immediate: true })
|
}, { deep: true, immediate: true })
|
||||||
|
|
||||||
const { addHistorySnapshot } = useHistorySnapshot()
|
const { addHistorySnapshot } = useHistorySnapshot()
|
||||||
@ -147,23 +275,70 @@ export default defineComponent({
|
|||||||
addHistorySnapshot()
|
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 {
|
return {
|
||||||
fill,
|
fill,
|
||||||
gradient,
|
gradient,
|
||||||
fillType,
|
fillType,
|
||||||
|
textAlign,
|
||||||
|
richTextAttrs,
|
||||||
|
availableFonts,
|
||||||
|
fontSizeOptions,
|
||||||
|
webFonts,
|
||||||
|
showTextTools,
|
||||||
|
emitRichTextCommand,
|
||||||
updateFillType,
|
updateFillType,
|
||||||
updateFill,
|
updateFill,
|
||||||
updateGradient,
|
updateGradient,
|
||||||
|
updateTextAlign,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
.shape-style-panel {
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
.row {
|
.row {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 10px;
|
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>
|
</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,
|
opacity: elementInfo.opacity,
|
||||||
filter: shadowStyle ? `drop-shadow(${shadowStyle})` : '',
|
filter: shadowStyle ? `drop-shadow(${shadowStyle})` : '',
|
||||||
transform: flipStyle,
|
transform: flipStyle,
|
||||||
|
color: text.defaultColor,
|
||||||
|
fontFamily: text.defaultFontName,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<SvgWrapper
|
<SvgWrapper
|
||||||
@ -50,6 +52,10 @@
|
|||||||
></path>
|
></path>
|
||||||
</g>
|
</g>
|
||||||
</SvgWrapper>
|
</SvgWrapper>
|
||||||
|
|
||||||
|
<div class="shape-text" :class="text.align">
|
||||||
|
<div class="ProseMirror-static" v-html="text.content"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -57,7 +63,7 @@
|
|||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { computed, defineComponent, PropType } from 'vue'
|
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 useElementOutline from '@/views/components/element/hooks/useElementOutline'
|
||||||
import useElementShadow from '@/views/components/element/hooks/useElementShadow'
|
import useElementShadow from '@/views/components/element/hooks/useElementShadow'
|
||||||
import useElementFlip from '@/views/components/element/hooks/useElementFlip'
|
import useElementFlip from '@/views/components/element/hooks/useElementFlip'
|
||||||
@ -86,12 +92,25 @@ export default defineComponent({
|
|||||||
const flipV = computed(() => props.elementInfo.flipV)
|
const flipV = computed(() => props.elementInfo.flipV)
|
||||||
const { flipStyle } = useElementFlip(flipH, 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 {
|
return {
|
||||||
shadowStyle,
|
shadowStyle,
|
||||||
outlineWidth,
|
outlineWidth,
|
||||||
outlineStyle,
|
outlineStyle,
|
||||||
outlineColor,
|
outlineColor,
|
||||||
flipStyle,
|
flipStyle,
|
||||||
|
text,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ -115,4 +134,26 @@ export default defineComponent({
|
|||||||
overflow: visible;
|
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>
|
</style>
|
||||||
|
@ -19,9 +19,12 @@
|
|||||||
opacity: elementInfo.opacity,
|
opacity: elementInfo.opacity,
|
||||||
filter: shadowStyle ? `drop-shadow(${shadowStyle})` : '',
|
filter: shadowStyle ? `drop-shadow(${shadowStyle})` : '',
|
||||||
transform: flipStyle,
|
transform: flipStyle,
|
||||||
|
color: text.defaultColor,
|
||||||
|
fontFamily: text.defaultFontName,
|
||||||
}"
|
}"
|
||||||
v-contextmenu="contextmenus"
|
v-contextmenu="contextmenus"
|
||||||
@mousedown="$event => handleSelectElement($event)"
|
@mousedown="$event => handleSelectElement($event)"
|
||||||
|
@dblclick="enterEditing()"
|
||||||
>
|
>
|
||||||
<SvgWrapper
|
<SvgWrapper
|
||||||
overflow="visible"
|
overflow="visible"
|
||||||
@ -53,25 +56,47 @@
|
|||||||
></path>
|
></path>
|
||||||
</g>
|
</g>
|
||||||
</SvgWrapper>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { computed, defineComponent, PropType } from 'vue'
|
import { computed, defineComponent, PropType, ref, watch } from 'vue'
|
||||||
import { PPTShapeElement } from '@/types/slides'
|
import { MutationTypes, useStore } from '@/store'
|
||||||
|
import { PPTShapeElement, ShapeText } from '@/types/slides'
|
||||||
import { ContextmenuItem } from '@/components/Contextmenu/types'
|
import { ContextmenuItem } from '@/components/Contextmenu/types'
|
||||||
import useElementOutline from '@/views/components/element/hooks/useElementOutline'
|
import useElementOutline from '@/views/components/element/hooks/useElementOutline'
|
||||||
import useElementShadow from '@/views/components/element/hooks/useElementShadow'
|
import useElementShadow from '@/views/components/element/hooks/useElementShadow'
|
||||||
import useElementFlip from '@/views/components/element/hooks/useElementFlip'
|
import useElementFlip from '@/views/components/element/hooks/useElementFlip'
|
||||||
|
import useHistorySnapshot from '@/hooks/useHistorySnapshot'
|
||||||
|
|
||||||
import GradientDefs from './GradientDefs.vue'
|
import GradientDefs from './GradientDefs.vue'
|
||||||
|
import ProsemirrorEditor from '@/views/components/element/ProsemirrorEditor.vue'
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: 'editable-element-shape',
|
name: 'editable-element-shape',
|
||||||
components: {
|
components: {
|
||||||
GradientDefs,
|
GradientDefs,
|
||||||
|
ProsemirrorEditor,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
elementInfo: {
|
elementInfo: {
|
||||||
@ -87,6 +112,10 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
setup(props) {
|
setup(props) {
|
||||||
|
const store = useStore()
|
||||||
|
|
||||||
|
const { addHistorySnapshot } = useHistorySnapshot()
|
||||||
|
|
||||||
const handleSelectElement = (e: MouseEvent) => {
|
const handleSelectElement = (e: MouseEvent) => {
|
||||||
if (props.elementInfo.lock) return
|
if (props.elementInfo.lock) return
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
@ -104,13 +133,58 @@ export default defineComponent({
|
|||||||
const flipV = computed(() => props.elementInfo.flipV)
|
const flipV = computed(() => props.elementInfo.flipV)
|
||||||
const { flipStyle } = useElementFlip(flipH, 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 {
|
return {
|
||||||
handleSelectElement,
|
|
||||||
shadowStyle,
|
shadowStyle,
|
||||||
outlineWidth,
|
outlineWidth,
|
||||||
outlineStyle,
|
outlineStyle,
|
||||||
outlineColor,
|
outlineColor,
|
||||||
flipStyle,
|
flipStyle,
|
||||||
|
editable,
|
||||||
|
text,
|
||||||
|
handleSelectElement,
|
||||||
|
updateText,
|
||||||
|
enterEditing,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ -139,4 +213,29 @@ export default defineComponent({
|
|||||||
overflow: visible;
|
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>
|
</style>
|
||||||
|
@ -32,11 +32,16 @@
|
|||||||
:height="elementInfo.height"
|
:height="elementInfo.height"
|
||||||
:outline="elementInfo.outline"
|
:outline="elementInfo.outline"
|
||||||
/>
|
/>
|
||||||
<div
|
<ProsemirrorEditor
|
||||||
class="text"
|
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)"
|
@mousedown="$event => handleSelectElement($event, false)"
|
||||||
></div>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -44,26 +49,20 @@
|
|||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { computed, defineComponent, onMounted, onUnmounted, PropType, ref, watch } from 'vue'
|
import { computed, defineComponent, onMounted, onUnmounted, PropType, ref, watch } from 'vue'
|
||||||
import { debounce } from 'lodash'
|
|
||||||
import { MutationTypes, useStore } from '@/store'
|
import { MutationTypes, useStore } from '@/store'
|
||||||
import { EditorView } from 'prosemirror-view'
|
|
||||||
import { toggleMark, wrapIn, selectAll } from 'prosemirror-commands'
|
|
||||||
import { PPTTextElement } from '@/types/slides'
|
import { PPTTextElement } from '@/types/slides'
|
||||||
import { ContextmenuItem } from '@/components/Contextmenu/types'
|
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 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 useHistorySnapshot from '@/hooks/useHistorySnapshot'
|
||||||
|
|
||||||
import ElementOutline from '@/views/components/element/ElementOutline.vue'
|
import ElementOutline from '@/views/components/element/ElementOutline.vue'
|
||||||
|
import ProsemirrorEditor from '@/views/components/element/ProsemirrorEditor.vue'
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: 'editable-element-text',
|
name: 'editable-element-text',
|
||||||
components: {
|
components: {
|
||||||
ElementOutline,
|
ElementOutline,
|
||||||
|
ProsemirrorEditor,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
elementInfo: {
|
elementInfo: {
|
||||||
@ -84,9 +83,6 @@ export default defineComponent({
|
|||||||
|
|
||||||
const elementRef = ref<HTMLElement>()
|
const elementRef = ref<HTMLElement>()
|
||||||
|
|
||||||
const editorViewRef = ref<HTMLElement>()
|
|
||||||
let editorView: EditorView
|
|
||||||
|
|
||||||
const shadow = computed(() => props.elementInfo.shadow)
|
const shadow = computed(() => props.elementInfo.shadow)
|
||||||
const { shadowStyle } = useElementShadow(shadow)
|
const { shadowStyle } = useElementShadow(shadow)
|
||||||
|
|
||||||
@ -142,176 +138,20 @@ export default defineComponent({
|
|||||||
if (elementRef.value) resizeObserver.unobserve(elementRef.value)
|
if (elementRef.value) resizeObserver.unobserve(elementRef.value)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 富文本的各种交互事件监听:
|
const updateContent = (content: string) => {
|
||||||
// 聚焦时取消全局快捷键事件
|
|
||||||
// 输入文字时同步数据到vuex
|
|
||||||
// 点击鼠标和键盘时同步富文本状态到工具栏
|
|
||||||
const handleInput = debounce(function() {
|
|
||||||
store.commit(MutationTypes.UPDATE_ELEMENT, {
|
store.commit(MutationTypes.UPDATE_ELEMENT, {
|
||||||
id: props.elementInfo.id,
|
id: props.elementInfo.id,
|
||||||
props: { content: editorView.dom.innerHTML },
|
props: { content },
|
||||||
})
|
})
|
||||||
|
|
||||||
addHistorySnapshot()
|
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 {
|
return {
|
||||||
elementRef,
|
elementRef,
|
||||||
editorViewRef,
|
|
||||||
handleSelectElement,
|
|
||||||
shadowStyle,
|
shadowStyle,
|
||||||
|
updateContent,
|
||||||
|
handleSelectElement,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ -338,7 +178,6 @@ export default defineComponent({
|
|||||||
|
|
||||||
.text {
|
.text {
|
||||||
position: relative;
|
position: relative;
|
||||||
cursor: text;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user