feat: 文本框富文本支持链接(#83)

This commit is contained in:
pipipi-pikachu 2022-03-20 11:12:59 +08:00
parent 6c5adddd32
commit 7be6f75d47
6 changed files with 179 additions and 25 deletions

View File

@ -87,6 +87,7 @@ import {
VideoTwo,
Formula,
ElectronicPen,
LinkOne,
} from '@icon-park/vue-next'
export default {
@ -163,6 +164,7 @@ export default {
app.component('IconAlignTextTopOne', AlignTextTopOne)
app.component('IconAlignTextBottomOne', AlignTextBottomOne)
app.component('IconAlignTextMiddleOne', AlignTextMiddleOne)
app.component('IconLinkOne', LinkOne)
// 箭头与符号
app.component('IconDown', Down)

View File

@ -59,6 +59,8 @@ const forecolor: MarkSpec = {
attrs: {
color: {},
},
inline: true,
group: 'inline',
parseDOM: [
{
style: 'color',
@ -81,7 +83,7 @@ const backcolor: MarkSpec = {
group: 'inline',
parseDOM: [
{
tag: 'span[style*=background-color]',
style: 'background-color',
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 {
...marks,
subscript,
@ -145,4 +167,5 @@ export default {
backcolor,
fontsize,
fontname,
link,
}

View File

@ -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 { 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) => {
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 fontsize = getAttrValue(marks, 'fontsize', 'fontsize') || defaultAttrs.fontsize
const fontname = getAttrValue(marks, 'fontname', 'fontname') || defaultAttrs.fontname
const link = getAttrValue(marks, 'link', 'href') || ''
const align = getAttrValueInSelection(view, 'align') || defaultAttrs.align
const isBulletList = isActiveOfParentNodeType('bullet_list', view.state)
const isOrderedList = isActiveOfParentNodeType('ordered_list', view.state)
@ -124,6 +185,7 @@ export const getTextAttrs = (view: EditorView, defaultAttrs: DefaultAttrs = {})
backcolor: backcolor,
fontsize: fontsize,
fontname: fontname,
link: link,
align: align,
bulletList: isBulletList,
orderedList: isOrderedList,
@ -145,6 +207,7 @@ export const defaultRichTextAttrs: TextAttrs = {
backcolor: '#000',
fontsize: '20px',
fontname: '微软雅黑',
link: '',
align: 'left',
bulletList: false,
orderedList: false,

View File

@ -116,6 +116,12 @@
@click="emitRichTextCommand('strikethrough')"
><IconStrikethrough /></CheckboxButton>
</Tooltip>
<Tooltip :mouseLeaveDelay="0" :mouseEnterDelay="0.5" title="清除格式">
<CheckboxButton
style="flex: 1;"
@click="emitRichTextCommand('clear')"
><IconFormat /></CheckboxButton>
</Tooltip>
</CheckboxButtonGroup>
<CheckboxButtonGroup class="row">
@ -147,11 +153,23 @@
@click="emitRichTextCommand('blockquote')"
><IconQuote /></CheckboxButton>
</Tooltip>
<Tooltip :mouseLeaveDelay="0" :mouseEnterDelay="0.5" title="清除格式">
<CheckboxButton
style="flex: 1;"
@click="emitRichTextCommand('clear')"
><IconFormat /></CheckboxButton>
<Tooltip :mouseLeaveDelay="0" :mouseEnterDelay="0.5" title="超链接">
<Popover placement="bottomRight" trigger="click" v-model:visible="linkPopoverVisible">
<template #content>
<div class="link-popover">
<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>
</CheckboxButtonGroup>
@ -316,6 +334,13 @@ export default defineComponent({
const slidesStore = useSlidesStore()
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 lineHeight = 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 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) => {
updateElement({ lineHeight: value })
@ -368,6 +376,31 @@ export default defineComponent({
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 {
fill,
lineHeight,
@ -384,6 +417,10 @@ export default defineComponent({
emitRichTextCommand,
emitBatchRichTextCommand,
presetStyles,
link,
linkPopoverVisible,
openLinkPopover,
updateLink,
}
},
})
@ -440,4 +477,12 @@ export default defineComponent({
height: 3px;
margin-top: 1px;
}
.link-popover {
width: 240px;
.btns {
margin-top: 10px;
text-align: right;
}
}
</style>

View File

@ -13,7 +13,7 @@ import { useMainStore } from '@/store'
import { EditorView } from 'prosemirror-view'
import { toggleMark, wrapIn, selectAll } from 'prosemirror-commands'
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 { alignmentCommand } from '@/utils/prosemirror/commands/setTextAlign'
import { toggleList } from '@/utils/prosemirror/commands/toggleList'
@ -207,6 +207,23 @@ export default defineComponent({
const { $from, $to } = editorView.state.selection
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) {
editorView.dispatch(editorView.state.tr.insertText(item.value))
}

View File

@ -183,6 +183,10 @@ export default defineComponent({
.text {
position: relative;
}
::v-deep(a) {
cursor: text;
}
}
.drag-handler {
height: 10px;