mirror of https://github.com/nocodb/nocodb
3 changed files with 360 additions and 1 deletions
@ -0,0 +1,225 @@
|
||||
<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 |
||||
|
||||
console.log('showLinkOptions', 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> |
@ -0,0 +1,131 @@
|
||||
import TiptapLink from '@tiptap/extension-link' |
||||
import { mergeAttributes } from '@tiptap/core' |
||||
import { Plugin, TextSelection } from 'prosemirror-state' |
||||
import type { AddMarkStep, Step } from 'prosemirror-transform' |
||||
|
||||
export const Link = TiptapLink.extend({ |
||||
addOptions() { |
||||
return { |
||||
openOnClick: true, |
||||
linkOnPaste: true, |
||||
autolink: true, |
||||
protocols: [], |
||||
HTMLAttributes: { |
||||
target: '_blank', |
||||
rel: 'noopener noreferrer nofollow', |
||||
class: null, |
||||
}, |
||||
validate: undefined, |
||||
internal: false, |
||||
} |
||||
}, |
||||
addAttributes() { |
||||
return { |
||||
href: { |
||||
default: null, |
||||
}, |
||||
target: { |
||||
default: this.options.HTMLAttributes.target, |
||||
}, |
||||
class: { |
||||
default: this.options.HTMLAttributes.class, |
||||
}, |
||||
} |
||||
}, |
||||
|
||||
renderHTML({ HTMLAttributes }) { |
||||
const attr = mergeAttributes(this.options.HTMLAttributes, HTMLAttributes) |
||||
|
||||
return ['a', attr, 0] |
||||
}, |
||||
|
||||
addKeyboardShortcuts() { |
||||
return { |
||||
'Mod-j': () => { |
||||
const selection = this.editor.view.state.selection |
||||
this.editor |
||||
.chain() |
||||
.toggleLink({ |
||||
href: '', |
||||
}) |
||||
.setTextSelection(selection.to) |
||||
.run() |
||||
|
||||
setTimeout(() => { |
||||
const linkInput = document.querySelector('.docs-link-option-input') |
||||
if (linkInput) { |
||||
;(linkInput as any).focus() |
||||
} |
||||
}, 100) |
||||
}, |
||||
'Space': () => { |
||||
// If we press space twice we stop the link mark and have normal text
|
||||
const editor = this.editor |
||||
const selection = editor.view.state.selection |
||||
const nodeBefore = selection.$to.nodeBefore |
||||
const nodeAfter = selection.$to.nodeAfter |
||||
|
||||
if (!nodeBefore) return false |
||||
|
||||
const nodeBeforeText = nodeBefore.text! |
||||
|
||||
// If we are not inside a link, we don't do anything
|
||||
if ( |
||||
!nodeBefore?.marks.some((mark) => mark.type.name === 'link') || |
||||
nodeAfter?.marks.some((mark) => mark.type.name === 'link') |
||||
) { |
||||
return false |
||||
} |
||||
|
||||
// Last text character should be a space
|
||||
if (nodeBeforeText[nodeBeforeText.length - 1] !== ' ') { |
||||
return false |
||||
} |
||||
|
||||
editor.view.dispatch( |
||||
editor.view.state.tr.removeMark(selection.$to.pos - 1, selection.$to.pos, editor.view.state.schema.marks.link), |
||||
) |
||||
|
||||
return true |
||||
}, |
||||
} as any |
||||
}, |
||||
addProseMirrorPlugins() { |
||||
return [ |
||||
// To have proseMirror plugins from the parent extension
|
||||
...(this.parent?.() ?? []), |
||||
new Plugin({ |
||||
//
|
||||
// Put cursor at the end of the link when we add a link
|
||||
//
|
||||
appendTransaction: (transactions, _, newState) => { |
||||
try { |
||||
if (transactions.length !== 1) return null |
||||
const steps = transactions[0].steps |
||||
if (steps.length !== 1) return null |
||||
|
||||
const step: Step = steps[0] as Step |
||||
const stepJson = step.toJSON() |
||||
// Ignore we are not adding a mark(i.e link, bold, etc)
|
||||
if (stepJson.stepType !== 'addMark') return null |
||||
|
||||
const addMarkStep: AddMarkStep = step as AddMarkStep |
||||
if (!addMarkStep) return null |
||||
|
||||
if (addMarkStep.from === addMarkStep.to) return null |
||||
|
||||
if (addMarkStep.mark.type.name !== 'link') return null |
||||
|
||||
const { tr } = newState |
||||
return tr.setSelection(new TextSelection(tr.doc.resolve(addMarkStep.to))) |
||||
} catch (e) { |
||||
console.error(e) |
||||
return null |
||||
} |
||||
}, |
||||
}), |
||||
] |
||||
}, |
||||
}).configure({ |
||||
openOnClick: false, |
||||
}) |
Loading…
Reference in new issue