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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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