mirror of
https://github.com/pipipi-pikachu/PPTist.git
synced 2025-04-15 02:20:00 +08:00
feat: 文本框富文本支持链接(#83)
This commit is contained in:
parent
6c5adddd32
commit
7be6f75d47
@ -87,6 +87,7 @@ import {
|
|||||||
VideoTwo,
|
VideoTwo,
|
||||||
Formula,
|
Formula,
|
||||||
ElectronicPen,
|
ElectronicPen,
|
||||||
|
LinkOne,
|
||||||
} from '@icon-park/vue-next'
|
} from '@icon-park/vue-next'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@ -163,6 +164,7 @@ export default {
|
|||||||
app.component('IconAlignTextTopOne', AlignTextTopOne)
|
app.component('IconAlignTextTopOne', AlignTextTopOne)
|
||||||
app.component('IconAlignTextBottomOne', AlignTextBottomOne)
|
app.component('IconAlignTextBottomOne', AlignTextBottomOne)
|
||||||
app.component('IconAlignTextMiddleOne', AlignTextMiddleOne)
|
app.component('IconAlignTextMiddleOne', AlignTextMiddleOne)
|
||||||
|
app.component('IconLinkOne', LinkOne)
|
||||||
|
|
||||||
// 箭头与符号
|
// 箭头与符号
|
||||||
app.component('IconDown', Down)
|
app.component('IconDown', Down)
|
||||||
|
@ -59,6 +59,8 @@ const forecolor: MarkSpec = {
|
|||||||
attrs: {
|
attrs: {
|
||||||
color: {},
|
color: {},
|
||||||
},
|
},
|
||||||
|
inline: true,
|
||||||
|
group: 'inline',
|
||||||
parseDOM: [
|
parseDOM: [
|
||||||
{
|
{
|
||||||
style: 'color',
|
style: 'color',
|
||||||
@ -81,7 +83,7 @@ const backcolor: MarkSpec = {
|
|||||||
group: 'inline',
|
group: 'inline',
|
||||||
parseDOM: [
|
parseDOM: [
|
||||||
{
|
{
|
||||||
tag: 'span[style*=background-color]',
|
style: 'background-color',
|
||||||
getAttrs: backcolor => backcolor ? { backcolor } : {}
|
getAttrs: backcolor => backcolor ? { backcolor } : {}
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@ -135,6 +137,26 @@ const fontname: MarkSpec = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const link: MarkSpec = {
|
||||||
|
attrs: {
|
||||||
|
href: {},
|
||||||
|
title: { default: null },
|
||||||
|
target: { default: '_blank' },
|
||||||
|
},
|
||||||
|
inclusive: false,
|
||||||
|
parseDOM: [
|
||||||
|
{
|
||||||
|
tag: 'a[href]',
|
||||||
|
getAttrs: dom => {
|
||||||
|
const href = (dom as HTMLElement).getAttribute('href')
|
||||||
|
const title = (dom as HTMLElement).getAttribute('title')
|
||||||
|
return { href, title }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
toDOM: node => ['a', node.attrs, 0],
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
...marks,
|
...marks,
|
||||||
subscript,
|
subscript,
|
||||||
@ -145,4 +167,5 @@ export default {
|
|||||||
backcolor,
|
backcolor,
|
||||||
fontsize,
|
fontsize,
|
||||||
fontname,
|
fontname,
|
||||||
|
link,
|
||||||
}
|
}
|
@ -1,7 +1,67 @@
|
|||||||
import { Node, NodeType, ResolvedPos, Mark } from 'prosemirror-model'
|
import { Node, NodeType, ResolvedPos, Mark, MarkType } from 'prosemirror-model'
|
||||||
import { EditorState, Selection } from 'prosemirror-state'
|
import { EditorState, Selection } from 'prosemirror-state'
|
||||||
import { EditorView } from 'prosemirror-view'
|
import { EditorView } from 'prosemirror-view'
|
||||||
|
|
||||||
|
export const findNodesWithSameMark = (doc: Node, from: number, to: number, markType: MarkType) => {
|
||||||
|
let ii = from
|
||||||
|
const finder = (mark: Mark) => mark.type === markType
|
||||||
|
let firstMark = null
|
||||||
|
let fromNode = null
|
||||||
|
let toNode = null
|
||||||
|
|
||||||
|
while (ii <= to) {
|
||||||
|
const node = doc.nodeAt(ii)
|
||||||
|
if (!node || !node.marks) return null
|
||||||
|
|
||||||
|
const mark = node.marks.find(finder)
|
||||||
|
if (!mark) return null
|
||||||
|
|
||||||
|
if (firstMark && mark !== firstMark) return null
|
||||||
|
|
||||||
|
fromNode = fromNode || node
|
||||||
|
firstMark = firstMark || mark
|
||||||
|
toNode = node
|
||||||
|
ii++
|
||||||
|
}
|
||||||
|
|
||||||
|
let fromPos = from
|
||||||
|
let toPos = to
|
||||||
|
|
||||||
|
let jj = 0
|
||||||
|
ii = from - 1
|
||||||
|
while (ii > jj) {
|
||||||
|
const node = doc.nodeAt(ii)
|
||||||
|
const mark = node && node.marks.find(finder)
|
||||||
|
if (!mark || mark !== firstMark) break
|
||||||
|
fromPos = ii
|
||||||
|
fromNode = node
|
||||||
|
ii--
|
||||||
|
}
|
||||||
|
|
||||||
|
ii = to + 1
|
||||||
|
jj = doc.nodeSize - 2
|
||||||
|
while (ii < jj) {
|
||||||
|
const node = doc.nodeAt(ii)
|
||||||
|
const mark = node && node.marks.find(finder)
|
||||||
|
if (!mark || mark !== firstMark) break
|
||||||
|
toPos = ii
|
||||||
|
toNode = node
|
||||||
|
ii++
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
mark: firstMark,
|
||||||
|
from: {
|
||||||
|
node: fromNode,
|
||||||
|
pos: fromPos,
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
node: toNode,
|
||||||
|
pos: toPos,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const equalNodeType = (nodeType: NodeType, node: Node) => {
|
const equalNodeType = (nodeType: NodeType, node: Node) => {
|
||||||
return Array.isArray(nodeType) && nodeType.indexOf(node.type) > -1 || node.type === nodeType
|
return Array.isArray(nodeType) && nodeType.indexOf(node.type) > -1 || node.type === nodeType
|
||||||
}
|
}
|
||||||
@ -107,6 +167,7 @@ export const getTextAttrs = (view: EditorView, defaultAttrs: DefaultAttrs = {})
|
|||||||
const backcolor = getAttrValue(marks, 'backcolor', 'backcolor') || defaultAttrs.backcolor
|
const backcolor = getAttrValue(marks, 'backcolor', 'backcolor') || defaultAttrs.backcolor
|
||||||
const fontsize = getAttrValue(marks, 'fontsize', 'fontsize') || defaultAttrs.fontsize
|
const fontsize = getAttrValue(marks, 'fontsize', 'fontsize') || defaultAttrs.fontsize
|
||||||
const fontname = getAttrValue(marks, 'fontname', 'fontname') || defaultAttrs.fontname
|
const fontname = getAttrValue(marks, 'fontname', 'fontname') || defaultAttrs.fontname
|
||||||
|
const link = getAttrValue(marks, 'link', 'href') || ''
|
||||||
const align = getAttrValueInSelection(view, 'align') || defaultAttrs.align
|
const align = getAttrValueInSelection(view, 'align') || defaultAttrs.align
|
||||||
const isBulletList = isActiveOfParentNodeType('bullet_list', view.state)
|
const isBulletList = isActiveOfParentNodeType('bullet_list', view.state)
|
||||||
const isOrderedList = isActiveOfParentNodeType('ordered_list', view.state)
|
const isOrderedList = isActiveOfParentNodeType('ordered_list', view.state)
|
||||||
@ -124,6 +185,7 @@ export const getTextAttrs = (view: EditorView, defaultAttrs: DefaultAttrs = {})
|
|||||||
backcolor: backcolor,
|
backcolor: backcolor,
|
||||||
fontsize: fontsize,
|
fontsize: fontsize,
|
||||||
fontname: fontname,
|
fontname: fontname,
|
||||||
|
link: link,
|
||||||
align: align,
|
align: align,
|
||||||
bulletList: isBulletList,
|
bulletList: isBulletList,
|
||||||
orderedList: isOrderedList,
|
orderedList: isOrderedList,
|
||||||
@ -145,6 +207,7 @@ export const defaultRichTextAttrs: TextAttrs = {
|
|||||||
backcolor: '#000',
|
backcolor: '#000',
|
||||||
fontsize: '20px',
|
fontsize: '20px',
|
||||||
fontname: '微软雅黑',
|
fontname: '微软雅黑',
|
||||||
|
link: '',
|
||||||
align: 'left',
|
align: 'left',
|
||||||
bulletList: false,
|
bulletList: false,
|
||||||
orderedList: false,
|
orderedList: false,
|
||||||
|
@ -116,6 +116,12 @@
|
|||||||
@click="emitRichTextCommand('strikethrough')"
|
@click="emitRichTextCommand('strikethrough')"
|
||||||
><IconStrikethrough /></CheckboxButton>
|
><IconStrikethrough /></CheckboxButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<Tooltip :mouseLeaveDelay="0" :mouseEnterDelay="0.5" title="清除格式">
|
||||||
|
<CheckboxButton
|
||||||
|
style="flex: 1;"
|
||||||
|
@click="emitRichTextCommand('clear')"
|
||||||
|
><IconFormat /></CheckboxButton>
|
||||||
|
</Tooltip>
|
||||||
</CheckboxButtonGroup>
|
</CheckboxButtonGroup>
|
||||||
|
|
||||||
<CheckboxButtonGroup class="row">
|
<CheckboxButtonGroup class="row">
|
||||||
@ -147,11 +153,23 @@
|
|||||||
@click="emitRichTextCommand('blockquote')"
|
@click="emitRichTextCommand('blockquote')"
|
||||||
><IconQuote /></CheckboxButton>
|
><IconQuote /></CheckboxButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip :mouseLeaveDelay="0" :mouseEnterDelay="0.5" title="清除格式">
|
<Tooltip :mouseLeaveDelay="0" :mouseEnterDelay="0.5" title="超链接">
|
||||||
<CheckboxButton
|
<Popover placement="bottomRight" trigger="click" v-model:visible="linkPopoverVisible">
|
||||||
style="flex: 1;"
|
<template #content>
|
||||||
@click="emitRichTextCommand('clear')"
|
<div class="link-popover">
|
||||||
><IconFormat /></CheckboxButton>
|
<Input v-model:value="link" placeholder="请输入超链接" />
|
||||||
|
<div class="btns">
|
||||||
|
<Button size="small" :disabled="!richTextAttrs.link" @click="updateLink()" style="margin-right: 5px;">移除</Button>
|
||||||
|
<Button size="small" type="primary" @click="updateLink(link)">确认</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<CheckboxButton
|
||||||
|
style="flex: 1;"
|
||||||
|
:checked="!!richTextAttrs.link"
|
||||||
|
@click="openLinkPopover()"
|
||||||
|
><IconLinkOne /></CheckboxButton>
|
||||||
|
</Popover>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</CheckboxButtonGroup>
|
</CheckboxButtonGroup>
|
||||||
|
|
||||||
@ -316,6 +334,13 @@ export default defineComponent({
|
|||||||
const slidesStore = useSlidesStore()
|
const slidesStore = useSlidesStore()
|
||||||
const { handleElement, handleElementId, richTextAttrs, availableFonts } = storeToRefs(useMainStore())
|
const { handleElement, handleElementId, richTextAttrs, availableFonts } = storeToRefs(useMainStore())
|
||||||
|
|
||||||
|
const { addHistorySnapshot } = useHistorySnapshot()
|
||||||
|
|
||||||
|
const updateElement = (props: Partial<PPTTextElement>) => {
|
||||||
|
slidesStore.updateElement({ id: handleElementId.value, props })
|
||||||
|
addHistorySnapshot()
|
||||||
|
}
|
||||||
|
|
||||||
const fill = ref<string>()
|
const fill = ref<string>()
|
||||||
const lineHeight = ref<number>()
|
const lineHeight = ref<number>()
|
||||||
const wordSpace = ref<number>()
|
const wordSpace = ref<number>()
|
||||||
@ -336,23 +361,6 @@ export default defineComponent({
|
|||||||
const lineHeightOptions = [0.9, 1.0, 1.15, 1.2, 1.4, 1.5, 1.8, 2.0, 2.5, 3.0]
|
const lineHeightOptions = [0.9, 1.0, 1.15, 1.2, 1.4, 1.5, 1.8, 2.0, 2.5, 3.0]
|
||||||
const wordSpaceOptions = [0, 1, 2, 3, 4, 5, 6, 8, 10]
|
const wordSpaceOptions = [0, 1, 2, 3, 4, 5, 6, 8, 10]
|
||||||
|
|
||||||
// 发射富文本设置命令
|
|
||||||
const emitRichTextCommand = (command: string, value?: string) => {
|
|
||||||
emitter.emit(EmitterEvents.RICH_TEXT_COMMAND, { command, value })
|
|
||||||
}
|
|
||||||
|
|
||||||
// 发射富文本设置命令(批量)
|
|
||||||
const emitBatchRichTextCommand = (payload: RichTextCommand[]) => {
|
|
||||||
emitter.emit(EmitterEvents.RICH_TEXT_COMMAND, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
const { addHistorySnapshot } = useHistorySnapshot()
|
|
||||||
|
|
||||||
const updateElement = (props: Partial<PPTTextElement>) => {
|
|
||||||
slidesStore.updateElement({ id: handleElementId.value, props })
|
|
||||||
addHistorySnapshot()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 设置行高
|
// 设置行高
|
||||||
const updateLineHeight = (value: number) => {
|
const updateLineHeight = (value: number) => {
|
||||||
updateElement({ lineHeight: value })
|
updateElement({ lineHeight: value })
|
||||||
@ -368,6 +376,31 @@ export default defineComponent({
|
|||||||
updateElement({ fill: value })
|
updateElement({ fill: value })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 发射富文本设置命令
|
||||||
|
const emitRichTextCommand = (command: string, value?: string) => {
|
||||||
|
emitter.emit(EmitterEvents.RICH_TEXT_COMMAND, { command, value })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发射富文本设置命令(批量)
|
||||||
|
const emitBatchRichTextCommand = (payload: RichTextCommand[]) => {
|
||||||
|
emitter.emit(EmitterEvents.RICH_TEXT_COMMAND, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置富文本超链接
|
||||||
|
const link = ref('')
|
||||||
|
const linkPopoverVisible = ref(false)
|
||||||
|
|
||||||
|
watch(richTextAttrs, () => linkPopoverVisible.value = false)
|
||||||
|
|
||||||
|
const openLinkPopover = () => {
|
||||||
|
link.value = richTextAttrs.value.link
|
||||||
|
linkPopoverVisible.value = true
|
||||||
|
}
|
||||||
|
const updateLink = (link: string) => {
|
||||||
|
emitRichTextCommand('link', link)
|
||||||
|
linkPopoverVisible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fill,
|
fill,
|
||||||
lineHeight,
|
lineHeight,
|
||||||
@ -384,6 +417,10 @@ export default defineComponent({
|
|||||||
emitRichTextCommand,
|
emitRichTextCommand,
|
||||||
emitBatchRichTextCommand,
|
emitBatchRichTextCommand,
|
||||||
presetStyles,
|
presetStyles,
|
||||||
|
link,
|
||||||
|
linkPopoverVisible,
|
||||||
|
openLinkPopover,
|
||||||
|
updateLink,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ -440,4 +477,12 @@ export default defineComponent({
|
|||||||
height: 3px;
|
height: 3px;
|
||||||
margin-top: 1px;
|
margin-top: 1px;
|
||||||
}
|
}
|
||||||
|
.link-popover {
|
||||||
|
width: 240px;
|
||||||
|
|
||||||
|
.btns {
|
||||||
|
margin-top: 10px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
@ -13,7 +13,7 @@ import { useMainStore } from '@/store'
|
|||||||
import { EditorView } from 'prosemirror-view'
|
import { EditorView } from 'prosemirror-view'
|
||||||
import { toggleMark, wrapIn, selectAll } from 'prosemirror-commands'
|
import { toggleMark, wrapIn, selectAll } from 'prosemirror-commands'
|
||||||
import { initProsemirrorEditor, createDocument } from '@/utils/prosemirror'
|
import { initProsemirrorEditor, createDocument } from '@/utils/prosemirror'
|
||||||
import { getTextAttrs } from '@/utils/prosemirror/utils'
|
import { findNodesWithSameMark, getTextAttrs } from '@/utils/prosemirror/utils'
|
||||||
import emitter, { EmitterEvents, RichTextCommand } from '@/utils/emitter'
|
import emitter, { EmitterEvents, RichTextCommand } from '@/utils/emitter'
|
||||||
import { alignmentCommand } from '@/utils/prosemirror/commands/setTextAlign'
|
import { alignmentCommand } from '@/utils/prosemirror/commands/setTextAlign'
|
||||||
import { toggleList } from '@/utils/prosemirror/commands/toggleList'
|
import { toggleList } from '@/utils/prosemirror/commands/toggleList'
|
||||||
@ -207,6 +207,23 @@ export default defineComponent({
|
|||||||
const { $from, $to } = editorView.state.selection
|
const { $from, $to } = editorView.state.selection
|
||||||
editorView.dispatch(editorView.state.tr.removeMark($from.pos, $to.pos))
|
editorView.dispatch(editorView.state.tr.removeMark($from.pos, $to.pos))
|
||||||
}
|
}
|
||||||
|
else if (item.command === 'link') {
|
||||||
|
const markType = editorView.state.schema.marks.link
|
||||||
|
const { from, to } = editorView.state.selection
|
||||||
|
const result = findNodesWithSameMark(editorView.state.doc, from, to, markType)
|
||||||
|
if (result) {
|
||||||
|
if (item.value) {
|
||||||
|
const mark = editorView.state.schema.marks.link.create({ href: item.value, title: item.value })
|
||||||
|
editorView.dispatch(editorView.state.tr.addMark(result.from.pos, result.to.pos + 1, mark))
|
||||||
|
}
|
||||||
|
else editorView.dispatch(editorView.state.tr.removeMark(result.from.pos, result.to.pos + 1, markType))
|
||||||
|
}
|
||||||
|
else if (item.value) {
|
||||||
|
const { empty } = editorView.state.selection
|
||||||
|
if (empty) selectAll(editorView.state, editorView.dispatch)
|
||||||
|
toggleMark(markType, { href: item.value, title: item.value })(editorView.state, editorView.dispatch)
|
||||||
|
}
|
||||||
|
}
|
||||||
else if (item.command === 'insert' && item.value) {
|
else if (item.command === 'insert' && item.value) {
|
||||||
editorView.dispatch(editorView.state.tr.insertText(item.value))
|
editorView.dispatch(editorView.state.tr.insertText(item.value))
|
||||||
}
|
}
|
||||||
|
@ -183,6 +183,10 @@ export default defineComponent({
|
|||||||
.text {
|
.text {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
::v-deep(a) {
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.drag-handler {
|
.drag-handler {
|
||||||
height: 10px;
|
height: 10px;
|
||||||
|
Loading…
x
Reference in New Issue
Block a user