mirror of https://github.com/nocodb/nocodb
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
223 lines
7.1 KiB
223 lines
7.1 KiB
<script lang="ts" setup> |
|
import type { Editor } from '@tiptap/vue-3' |
|
import { BubbleMenu } from '@tiptap/vue-3' |
|
import { getMarkRange } from '@tiptap/core' |
|
import type { Mark } from 'prosemirror-model' |
|
|
|
const { editor } = defineProps<Props>() |
|
|
|
interface Props { |
|
editor: Editor |
|
} |
|
|
|
const inputRef = ref<HTMLInputElement>() |
|
const linkNodeMark = ref<Mark | undefined>() |
|
const href = ref('') |
|
const isLinkOptionsVisible = ref(false) |
|
|
|
// This is used to prevent the menu from showing up after a link is deleted, an edge case when the link with empty placeholder text is deleted. |
|
// This is because checkLinkMark is not called in that case |
|
const justDeleted = ref(false) |
|
|
|
// This function is called by BubbleMenu on selection change |
|
// It is used to check if the link mark is active and only show the menu if it is |
|
const checkLinkMark = (editor: Editor) => { |
|
if (!editor.view.editable) return false |
|
|
|
if (justDeleted.value) { |
|
setTimeout(() => { |
|
justDeleted.value = false |
|
}, 100) |
|
return false |
|
} |
|
|
|
const activeNode = editor?.state?.selection?.$from?.nodeBefore || editor?.state?.selection?.$from?.nodeAfter |
|
|
|
const isLinkMarkedStoredInEditor = editor?.state?.storedMarks?.some((mark: Mark) => mark.type.name === 'link') |
|
|
|
const isActiveNodeMarkActive = activeNode?.marks?.some((mark: Mark) => mark.type.name === 'link') || isLinkMarkedStoredInEditor |
|
|
|
if (isActiveNodeMarkActive) { |
|
linkNodeMark.value = activeNode?.marks.find((mark: Mark) => mark.type.name === 'link') |
|
href.value = linkNodeMark.value?.attrs?.href |
|
} |
|
|
|
if (isLinkMarkedStoredInEditor) { |
|
linkNodeMark.value = editor?.state?.storedMarks?.find((mark: Mark) => mark.type.name === 'link') |
|
href.value = linkNodeMark.value?.attrs?.href |
|
} |
|
|
|
const isTextSelected = editor?.state?.selection?.from !== editor?.state?.selection?.to |
|
|
|
// check if active node is a text node |
|
const showLinkOptions = isActiveNodeMarkActive && !isTextSelected |
|
isLinkOptionsVisible.value = !!showLinkOptions |
|
|
|
return showLinkOptions |
|
} |
|
|
|
const onChange = () => { |
|
const isLinkMarkedStoredInEditor = editor?.state?.storedMarks?.some((mark: Mark) => mark.type.name === 'link') |
|
let formatedHref = href.value |
|
if (isValidURL(href.value) && href.value.length > 0 && !href.value.startsWith('/') && !href.value.startsWith('http')) { |
|
formatedHref = `https://${href.value}` |
|
} |
|
|
|
if (isLinkMarkedStoredInEditor) { |
|
editor.view.dispatch( |
|
editor.view.state.tr |
|
.removeStoredMark(editor?.schema.marks.link) |
|
.addStoredMark(editor?.schema.marks.link.create({ href: formatedHref })), |
|
) |
|
} else if (linkNodeMark.value) { |
|
const selection = editor?.state?.selection |
|
const markSelection = getMarkRange(selection.$anchor, editor?.schema.marks.link) as any |
|
|
|
editor.view.dispatch( |
|
editor.view.state.tr |
|
.removeMark(markSelection.from, markSelection.to, editor?.schema.marks.link) |
|
.addMark(markSelection.from, markSelection.to, editor?.schema.marks.link.create({ href: formatedHref })), |
|
) |
|
} |
|
} |
|
|
|
const onDelete = () => { |
|
const isLinkMarkedStoredInEditor = editor?.state?.storedMarks?.some((mark: Mark) => mark.type.name === 'link') |
|
|
|
if (isLinkMarkedStoredInEditor) { |
|
editor.view.dispatch(editor.view.state.tr.removeStoredMark(editor?.schema.marks.link)) |
|
} else if (linkNodeMark.value) { |
|
const selection = editor?.state?.selection |
|
const markSelection = getMarkRange(selection.$anchor, editor?.schema.marks.link) as any |
|
|
|
editor.view.dispatch(editor.view.state.tr.removeMark(markSelection.from, markSelection.to, editor?.schema.marks.link)) |
|
} |
|
|
|
justDeleted.value = true |
|
} |
|
|
|
const handleKeyDown = (e: any) => { |
|
// Ctrl + Z/ Meta + Z |
|
if ((e.ctrlKey || e.metaKey) && e.key === 'z') { |
|
e.preventDefault() |
|
editor.commands.undo() |
|
} |
|
|
|
// Ctrl + Shift + Z/ Meta + Shift + Z |
|
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'z') { |
|
e.preventDefault() |
|
editor.commands.redo() |
|
} |
|
} |
|
|
|
const onInputBoxEnter = () => { |
|
inputRef.value?.blur() |
|
editor.chain().focus().run() |
|
} |
|
|
|
const handleInputBoxKeyDown = (e: any) => { |
|
if (e.key === 'ArrowDown' || e.key === 'Escape') { |
|
editor.chain().focus().run() |
|
} |
|
} |
|
|
|
watch(isLinkOptionsVisible, (value, oldValue) => { |
|
if (value && !oldValue) { |
|
const isPlaceholderEmpty = |
|
!editor?.state?.selection.$from.nodeBefore?.textContent && !editor?.state?.selection.$from.nodeAfter?.textContent |
|
|
|
if (!isPlaceholderEmpty) return |
|
|
|
setTimeout(() => { |
|
inputRef.value?.focus() |
|
}, 100) |
|
} |
|
}) |
|
|
|
const openLink = () => { |
|
if (href.value) { |
|
window.open(href.value, '_blank', 'noopener,noreferrer') |
|
} |
|
} |
|
</script> |
|
|
|
<template> |
|
<BubbleMenu :editor="editor" :tippy-options="{ duration: 100, maxWidth: 600 }" :should-show="(checkLinkMark as any)"> |
|
<div |
|
v-if="!justDeleted" |
|
ref="wrapperRef" |
|
class="relative bubble-menu nc-text-area-rich-link-options flex flex-col bg-gray-50 py-1 px-1 rounded-lg" |
|
data-testid="nc-text-area-rich-link-options" |
|
@keydown="handleKeyDown" |
|
> |
|
<div class="flex items-center gap-x-1"> |
|
<div class="!border-1 !border-gray-200 !py-0.5 bg-gray-100 rounded-md !z-10"> |
|
<a-input |
|
ref="inputRef" |
|
v-model:value="href" |
|
class="nc-text-area-rich-link-option-input flex-1 !w-96 !mx-0.5 !px-1.5 !py-0.5 !rounded-md z-10" |
|
:bordered="false" |
|
placeholder="Enter a link" |
|
@change="onChange" |
|
@press-enter="onInputBoxEnter" |
|
@keydown="handleInputBoxKeyDown" |
|
/> |
|
</div> |
|
<NcTooltip overlay-class-name="nc-text-area-rich-link-options"> |
|
<template #title> Open link </template> |
|
<NcButton |
|
:class="{ |
|
'!text-gray-300 cursor-not-allowed': href.length === 0, |
|
}" |
|
data-testid="nc-text-area-rich-link-options-open-link" |
|
size="small" |
|
type="text" |
|
@click="openLink" |
|
> |
|
<IcBaselineArrowOutward /> |
|
</NcButton> |
|
</NcTooltip> |
|
<NcTooltip overlay-class-name="nc-text-area-rich-link-options"> |
|
<template #title> Delete link </template> |
|
<NcButton |
|
class="!duration-0 !hover:(text-red-400 bg-red-50)" |
|
data-testid="nc-text-area-rich-link-options-open-delete" |
|
size="small" |
|
type="text" |
|
@click="onDelete" |
|
> |
|
<MdiDeleteOutline /> |
|
</NcButton> |
|
</NcTooltip> |
|
<div class="absolute -bottom-1.5 left-0 right-0 w-full flex flex-row justify-center"> |
|
<div |
|
class="flex h-2.5 w-2.5 bg-gray-50 border-gray-100 border-r-1 border-b-1" |
|
:style="{ transform: 'rotate(45deg)' }" |
|
></div> |
|
</div> |
|
</div> |
|
</div> |
|
</BubbleMenu> |
|
</template> |
|
|
|
<style lang="scss"> |
|
.bubble-menu { |
|
// shadow |
|
@apply shadow-gray-200 shadow-sm; |
|
} |
|
|
|
.nc-text-area-rich-link-options { |
|
.ant-popover-inner-content { |
|
@apply !shadow-none !p-0; |
|
} |
|
.ant-popover-arrow { |
|
@apply !shadow-none; |
|
.ant-popover-arrow-content { |
|
@apply !shadow-none !bg-gray-100; |
|
} |
|
} |
|
.ant-popover-inner { |
|
@apply !shadow-none !bg-gray-100 py-1.5 px-2.5 text-xs !rounded-sm; |
|
} |
|
} |
|
</style>
|
|
|