feat: 形状内支持输入文字(#46)

This commit is contained in:
pipipi-pikachu 2021-07-31 22:00:20 +08:00
parent bc1aaefa2f
commit a7afcc8232
11 changed files with 602 additions and 181 deletions

View File

@ -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++) {

View File

@ -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)

View File

@ -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',

View File

@ -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>) {

View File

@ -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
}

View File

@ -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'> {

View File

@ -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>

View 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>

View File

@ -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>

View File

@ -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>

View File

@ -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>