mirror of https://github.com/nocodb/nocodb
Raju Udava
1 year ago
committed by
GitHub
20 changed files with 2491 additions and 290 deletions
@ -0,0 +1,347 @@ |
|||||||
|
<script lang="ts" setup> |
||||||
|
import StarterKit from '@tiptap/starter-kit' |
||||||
|
import TaskList from '@tiptap/extension-task-list' |
||||||
|
import { EditorContent, useEditor } from '@tiptap/vue-3' |
||||||
|
import TurndownService from 'turndown' |
||||||
|
import { marked } from 'marked' |
||||||
|
import { generateJSON } from '@tiptap/html' |
||||||
|
import Underline from '@tiptap/extension-underline' |
||||||
|
import { TaskItem } from '@/helpers/dbTiptapExtensions/task-item' |
||||||
|
import { Link } from '@/helpers/dbTiptapExtensions/links' |
||||||
|
|
||||||
|
const props = defineProps<{ |
||||||
|
value?: string | null |
||||||
|
readonly?: boolean |
||||||
|
syncValueChange?: boolean |
||||||
|
showMenu?: boolean |
||||||
|
fullMode?: boolean |
||||||
|
}>() |
||||||
|
|
||||||
|
const emits = defineEmits(['update:value']) |
||||||
|
|
||||||
|
const turndownService = new TurndownService({}) |
||||||
|
|
||||||
|
turndownService.addRule('lineBreak', { |
||||||
|
filter: (node) => { |
||||||
|
return node.nodeName === 'BR' |
||||||
|
}, |
||||||
|
replacement: () => { |
||||||
|
return '<br />' |
||||||
|
}, |
||||||
|
}) |
||||||
|
|
||||||
|
turndownService.addRule('taskList', { |
||||||
|
filter: (node) => { |
||||||
|
return node.nodeName === 'LI' && !!node.getAttribute('data-checked') |
||||||
|
}, |
||||||
|
replacement: (content, node: any) => { |
||||||
|
// Remove the first \n\n and last \n\n |
||||||
|
const processContent = content.replace(/^\n\n/, '').replace(/\n\n$/, '') |
||||||
|
|
||||||
|
const isChecked = node.getAttribute('data-checked') === 'true' |
||||||
|
|
||||||
|
return `[${isChecked ? 'x' : ' '}] ${processContent}\n\n` |
||||||
|
}, |
||||||
|
}) |
||||||
|
|
||||||
|
const checkListItem = { |
||||||
|
name: 'checkListItem', |
||||||
|
level: 'block', |
||||||
|
tokenizer(src: string) { |
||||||
|
src = src.split('\n\n')[0] |
||||||
|
const isMatched = src.startsWith('[ ]') || src.startsWith('[x]') || src.startsWith('[X]') |
||||||
|
|
||||||
|
if (isMatched) { |
||||||
|
const isNotChecked = src.startsWith('[ ]') |
||||||
|
let text = src.slice(3) |
||||||
|
if (text[0] === ' ') text = text.slice(1) |
||||||
|
|
||||||
|
const token = { |
||||||
|
// Token to generate |
||||||
|
type: 'checkListItem', |
||||||
|
raw: src, |
||||||
|
text, |
||||||
|
tokens: [], |
||||||
|
checked: !isNotChecked, |
||||||
|
} |
||||||
|
|
||||||
|
;(this as any).lexer.inline(token.text, token.tokens) // Queue this data to be processed for inline tokens |
||||||
|
return token |
||||||
|
} |
||||||
|
|
||||||
|
return false |
||||||
|
}, |
||||||
|
renderer(token: any) { |
||||||
|
return `<ul data-type="taskList"><li data-checked="${ |
||||||
|
token.checked ? 'true' : 'false' |
||||||
|
}" data-type="taskItem"><label><input type="checkbox" ${ |
||||||
|
token.checked ? 'checked="checked"' : '' |
||||||
|
}><span></span></label><div>${(this as any).parser.parseInline(token.tokens)}</div></li></ul>` // parseInline to turn child tokens into HTML |
||||||
|
}, |
||||||
|
} |
||||||
|
|
||||||
|
marked.use({ extensions: [checkListItem] }) |
||||||
|
|
||||||
|
const editorDom = ref<HTMLElement | null>(null) |
||||||
|
|
||||||
|
const vModel = useVModel(props, 'value', emits, { defaultValue: '' }) |
||||||
|
|
||||||
|
const tiptapExtensions = [ |
||||||
|
StarterKit, |
||||||
|
TaskList, |
||||||
|
TaskItem.configure({ |
||||||
|
nested: true, |
||||||
|
}), |
||||||
|
Underline, |
||||||
|
Link, |
||||||
|
] |
||||||
|
|
||||||
|
const editor = useEditor({ |
||||||
|
extensions: tiptapExtensions, |
||||||
|
onUpdate: ({ editor }) => { |
||||||
|
const markdown = turndownService |
||||||
|
.turndown(editor.getHTML().replaceAll(/<p><\/p>/g, '<br />')) |
||||||
|
.replaceAll(/\n\n<br \/>\n\n/g, '<br>\n\n') |
||||||
|
|
||||||
|
vModel.value = markdown |
||||||
|
}, |
||||||
|
editable: !props.readonly, |
||||||
|
}) |
||||||
|
|
||||||
|
const setEditorContent = (contentMd: any) => { |
||||||
|
if (!editor.value) return |
||||||
|
|
||||||
|
const selection = editor.value.view.state.selection |
||||||
|
|
||||||
|
const contentHtml = contentMd ? marked.parse(contentMd) : '<p></p>' |
||||||
|
|
||||||
|
const content = generateJSON(contentHtml, tiptapExtensions) |
||||||
|
|
||||||
|
editor.value.chain().setContent(content).setTextSelection(selection.to).run() |
||||||
|
|
||||||
|
setTimeout(() => { |
||||||
|
;(editor.value!.state as any).history$.prevRanges = null |
||||||
|
;(editor.value!.state as any).history$.done.eventCount = 0 |
||||||
|
}, 100) |
||||||
|
} |
||||||
|
|
||||||
|
if (props.syncValueChange) { |
||||||
|
watch(vModel, () => { |
||||||
|
setEditorContent(vModel.value) |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
watch(editorDom, () => { |
||||||
|
if (!editorDom.value) return |
||||||
|
|
||||||
|
setEditorContent(vModel.value) |
||||||
|
|
||||||
|
// Focus editor after editor is mounted |
||||||
|
setTimeout(() => { |
||||||
|
editor.value?.chain().focus().run() |
||||||
|
}, 50) |
||||||
|
}) |
||||||
|
</script> |
||||||
|
|
||||||
|
<template> |
||||||
|
<div |
||||||
|
class="h-full" |
||||||
|
:class="{ |
||||||
|
'flex flex-col flex-grow nc-rich-text-full': props.fullMode, |
||||||
|
'nc-rich-text-embed': !props.fullMode, |
||||||
|
}" |
||||||
|
> |
||||||
|
<div v-if="props.showMenu" class="absolute top-0 right-0.5"> |
||||||
|
<CellRichTextSelectedBubbleMenu v-if="editor" :editor="editor" embed-mode /> |
||||||
|
</div> |
||||||
|
<CellRichTextSelectedBubbleMenuPopup v-if="editor" :editor="editor" /> |
||||||
|
<CellRichTextLinkOptions v-if="editor" :editor="editor" /> |
||||||
|
<EditorContent |
||||||
|
ref="editorDom" |
||||||
|
:editor="editor" |
||||||
|
class="flex flex-col nc-textarea-rich-editor w-full flex-grow" |
||||||
|
:class="{ |
||||||
|
'ml-1 mt-2.5': props.fullMode, |
||||||
|
}" |
||||||
|
/> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
|
||||||
|
<style lang="scss"> |
||||||
|
.nc-text-rich-scroll { |
||||||
|
&::-webkit-scrollbar-thumb { |
||||||
|
@apply bg-transparent; |
||||||
|
} |
||||||
|
} |
||||||
|
.nc-text-rich-scroll:hover { |
||||||
|
&::-webkit-scrollbar-thumb { |
||||||
|
@apply bg-gray-200; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
.nc-rich-text-embed { |
||||||
|
.ProseMirror { |
||||||
|
@apply !border-transparent; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
.nc-rich-text-full { |
||||||
|
@apply px-1.75; |
||||||
|
.ProseMirror { |
||||||
|
@apply !p-2; |
||||||
|
|
||||||
|
max-height: calc(min(60vh, 100rem)); |
||||||
|
min-height: 8rem; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
.nc-textarea-rich-editor { |
||||||
|
.ProseMirror { |
||||||
|
@apply flex-grow pt-1 border-1 border-gray-200 rounded-lg pr-1 mr-2; |
||||||
|
|
||||||
|
> * { |
||||||
|
@apply ml-1; |
||||||
|
} |
||||||
|
} |
||||||
|
.ProseMirror-focused { |
||||||
|
// remove all border |
||||||
|
outline: none; |
||||||
|
@apply border-brand-500; |
||||||
|
} |
||||||
|
|
||||||
|
p { |
||||||
|
@apply !mb-1; |
||||||
|
} |
||||||
|
|
||||||
|
ul { |
||||||
|
li { |
||||||
|
@apply ml-4; |
||||||
|
list-style-type: disc; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
ol { |
||||||
|
@apply -ml-6 !pl-4; |
||||||
|
li { |
||||||
|
list-style-type: decimal; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
ul, |
||||||
|
ol { |
||||||
|
@apply !my-0; |
||||||
|
} |
||||||
|
|
||||||
|
ul[data-type='taskList'] { |
||||||
|
@apply; |
||||||
|
li { |
||||||
|
@apply !ml-0 flex flex-row gap-x-2; |
||||||
|
list-style-type: none; |
||||||
|
|
||||||
|
input { |
||||||
|
@apply mt-0.75 flex rounded-sm; |
||||||
|
z-index: -10; |
||||||
|
} |
||||||
|
// Unchecked |
||||||
|
input:not(:checked) { |
||||||
|
// Add border to checkbox |
||||||
|
border-width: 1.5px; |
||||||
|
@apply border-gray-700; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Pre tag is the parent wrapper for Code block |
||||||
|
pre { |
||||||
|
border-color: #d0d5dd; |
||||||
|
border: 1px; |
||||||
|
color: black; |
||||||
|
font-family: 'JetBrainsMono', monospace; |
||||||
|
padding: 1rem; |
||||||
|
border-radius: 0.5rem; |
||||||
|
@apply overflow-auto mt-3 bg-gray-100; |
||||||
|
|
||||||
|
code { |
||||||
|
@apply !px-0; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
code { |
||||||
|
@apply rounded-md px-2 py-1 bg-gray-100; |
||||||
|
color: inherit; |
||||||
|
font-size: 0.8rem; |
||||||
|
} |
||||||
|
|
||||||
|
h1 { |
||||||
|
font-weight: 700; |
||||||
|
font-size: 1.85rem; |
||||||
|
margin-bottom: 0.1rem; |
||||||
|
} |
||||||
|
|
||||||
|
h2 { |
||||||
|
font-weight: 600; |
||||||
|
font-size: 1.55rem; |
||||||
|
margin-bottom: 0.1em; |
||||||
|
} |
||||||
|
|
||||||
|
h3 { |
||||||
|
font-weight: 600; |
||||||
|
font-size: 1.15rem; |
||||||
|
margin-bottom: 0.1em; |
||||||
|
} |
||||||
|
|
||||||
|
blockquote { |
||||||
|
border-left: 3px solid #d0d5dd; |
||||||
|
padding: 0 1em; |
||||||
|
color: #666; |
||||||
|
margin: 1em 0; |
||||||
|
font-style: italic; |
||||||
|
} |
||||||
|
|
||||||
|
hr { |
||||||
|
@apply !border-gray-300; |
||||||
|
border: 0; |
||||||
|
border-top: 1px solid #ccc; |
||||||
|
margin: 1.5em 0; |
||||||
|
} |
||||||
|
|
||||||
|
pre { |
||||||
|
height: fit-content; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
.nc-rich-text-full { |
||||||
|
.ProseMirror { |
||||||
|
overflow-y: scroll; |
||||||
|
overflow-x: hidden; |
||||||
|
scrollbar-width: thin !important; |
||||||
|
|
||||||
|
&::-webkit-scrollbar { |
||||||
|
width: 4px; |
||||||
|
height: 4px; |
||||||
|
} |
||||||
|
&::-webkit-scrollbar-track { |
||||||
|
-webkit-border-radius: 10px; |
||||||
|
border-radius: 10px; |
||||||
|
margin-top: 4px; |
||||||
|
margin-bottom: 4px; |
||||||
|
} |
||||||
|
&::-webkit-scrollbar-track-piece { |
||||||
|
width: 0px; |
||||||
|
} |
||||||
|
&::-webkit-scrollbar { |
||||||
|
@apply bg-transparent; |
||||||
|
} |
||||||
|
&::-webkit-scrollbar-thumb { |
||||||
|
-webkit-border-radius: 10px; |
||||||
|
border-radius: 10px; |
||||||
|
|
||||||
|
width: 4px; |
||||||
|
@apply bg-gray-300; |
||||||
|
} |
||||||
|
&::-webkit-scrollbar-thumb:hover { |
||||||
|
@apply bg-gray-400; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
</style> |
@ -0,0 +1,244 @@ |
|||||||
|
<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 props = defineProps<Props>() |
||||||
|
|
||||||
|
interface Props { |
||||||
|
editor: Editor |
||||||
|
} |
||||||
|
|
||||||
|
const editor = computed(() => props.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 |
||||||
|
} |
||||||
|
|
||||||
|
function notStartingWithNetworkProtocol(inputString: string) { |
||||||
|
const pattern = /^(?![^:]+:\/\/).*/ |
||||||
|
|
||||||
|
const isMatch = pattern.test(inputString) |
||||||
|
|
||||||
|
return isMatch |
||||||
|
} |
||||||
|
|
||||||
|
const onChange = () => { |
||||||
|
const isLinkMarkedStoredInEditor = editor.value.state?.storedMarks?.some((mark: Mark) => mark.type.name === 'link') |
||||||
|
let formatedHref = href.value |
||||||
|
if ( |
||||||
|
isValidURL(href.value) && |
||||||
|
href.value.length > 0 && |
||||||
|
!href.value.startsWith('/') && |
||||||
|
notStartingWithNetworkProtocol(href.value) |
||||||
|
) { |
||||||
|
formatedHref = `https://${href.value}` |
||||||
|
} |
||||||
|
|
||||||
|
if (isLinkMarkedStoredInEditor) { |
||||||
|
editor.value.view.dispatch( |
||||||
|
editor.value.view.state.tr |
||||||
|
.removeStoredMark(editor.value.schema.marks.link) |
||||||
|
.addStoredMark(editor.value.schema.marks.link.create({ href: formatedHref })), |
||||||
|
) |
||||||
|
} else if (linkNodeMark.value) { |
||||||
|
const selection = editor.value.state?.selection |
||||||
|
const markSelection = getMarkRange(selection.$anchor, editor.value.schema.marks.link) as any |
||||||
|
|
||||||
|
editor.value.view.dispatch( |
||||||
|
editor.value.view.state.tr |
||||||
|
.removeMark(markSelection.from, markSelection.to, editor.value.schema.marks.link) |
||||||
|
.addMark(markSelection.from, markSelection.to, editor.value.schema.marks.link.create({ href: formatedHref })), |
||||||
|
) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const onDelete = () => { |
||||||
|
const isLinkMarkedStoredInEditor = editor.value.state?.storedMarks?.some((mark: Mark) => mark.type.name === 'link') |
||||||
|
|
||||||
|
if (isLinkMarkedStoredInEditor) { |
||||||
|
editor.value.view.dispatch(editor.value.view.state.tr.removeStoredMark(editor.value.schema.marks.link)) |
||||||
|
} else if (linkNodeMark.value) { |
||||||
|
const selection = editor.value.state.selection |
||||||
|
const markSelection = getMarkRange(selection.$anchor, editor.value.schema.marks.link) as any |
||||||
|
|
||||||
|
editor.value.view.dispatch( |
||||||
|
editor.value.view.state.tr.removeMark(markSelection.from, markSelection.to, editor.value.schema.marks.link), |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
justDeleted.value = true |
||||||
|
} |
||||||
|
|
||||||
|
const handleKeyDown = (e: any) => { |
||||||
|
const isCtrlPressed = isMac() ? e.metaKey : e.ctrlKey |
||||||
|
|
||||||
|
// Ctrl + Z/ Meta + Z |
||||||
|
if (isCtrlPressed && e.key === 'z') { |
||||||
|
e.preventDefault() |
||||||
|
editor.value.commands.undo() |
||||||
|
} |
||||||
|
|
||||||
|
// Ctrl + Shift + Z/ Meta + Shift + Z |
||||||
|
if (isCtrlPressed && e.shiftKey && e.key === 'z') { |
||||||
|
e.preventDefault() |
||||||
|
editor.value.commands.redo() |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const onInputBoxEnter = () => { |
||||||
|
inputRef.value?.blur() |
||||||
|
editor.value.chain().focus().run() |
||||||
|
} |
||||||
|
|
||||||
|
const handleInputBoxKeyDown = (e: any) => { |
||||||
|
if (e.key === 'ArrowDown' || e.key === 'Escape') { |
||||||
|
editor.value.chain().focus().run() |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
watch(isLinkOptionsVisible, (value, oldValue) => { |
||||||
|
if (value && !oldValue) { |
||||||
|
const isPlaceholderEmpty = |
||||||
|
!editor.value.state.selection.$from.nodeBefore?.textContent && !editor.value.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.stop="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-white border-gray-200 border-r-1 border-b-1 transform rotate-45" |
||||||
|
:style="{ |
||||||
|
boxShadow: '1px 1px 3px rgba(231, 231, 233, 1)', |
||||||
|
}" |
||||||
|
></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,381 @@ |
|||||||
|
<script lang="ts" setup> |
||||||
|
import type { Editor } from '@tiptap/vue-3' |
||||||
|
import MdiFormatBulletList from '~icons/mdi/format-list-bulleted' |
||||||
|
import MdiFormatStrikeThrough from '~icons/mdi/format-strikethrough' |
||||||
|
import MdiFormatListNumber from '~icons/mdi/format-list-numbered' |
||||||
|
import MdiFormatListCheckbox from '~icons/mdi/format-list-checkbox' |
||||||
|
import MsFormatH1 from '~icons/material-symbols/format-h1' |
||||||
|
import MsFormatH2 from '~icons/material-symbols/format-h2' |
||||||
|
import MsFormatH3 from '~icons/material-symbols/format-h3' |
||||||
|
import TablerBlockQuote from '~icons/tabler/blockquote' |
||||||
|
import MsCode from '~icons/material-symbols/code' |
||||||
|
import MsFormatQuote from '~icons/material-symbols/format-quote' |
||||||
|
|
||||||
|
interface Props { |
||||||
|
editor: Editor |
||||||
|
embedMode?: boolean |
||||||
|
} |
||||||
|
|
||||||
|
const props = defineProps<Props>() |
||||||
|
|
||||||
|
const editor = computed(() => props.editor) |
||||||
|
|
||||||
|
const embedMode = computed(() => props.embedMode) |
||||||
|
|
||||||
|
const cmdOrCtrlKey = computed(() => { |
||||||
|
return isMac() ? '⌘' : 'CTRL' |
||||||
|
}) |
||||||
|
|
||||||
|
const shiftKey = computed(() => { |
||||||
|
return isMac() ? '⇧' : 'Shift' |
||||||
|
}) |
||||||
|
|
||||||
|
const altKey = computed(() => { |
||||||
|
return isMac() ? '⌥' : 'Alt' |
||||||
|
}) |
||||||
|
|
||||||
|
const onToggleLink = () => { |
||||||
|
const activeNode = editor.value?.state?.selection?.$from?.nodeBefore || editor.value?.state?.selection?.$from?.nodeAfter |
||||||
|
|
||||||
|
const isLinkMarkedStoredInEditor = editor.value?.state?.storedMarks?.some((mark: any) => mark.type.name === 'link') |
||||||
|
|
||||||
|
const isActiveNodeMarkActive = activeNode?.marks?.some((mark: any) => mark.type.name === 'link') || isLinkMarkedStoredInEditor |
||||||
|
|
||||||
|
if (isActiveNodeMarkActive) { |
||||||
|
editor.value!.chain().focus().unsetLink().run() |
||||||
|
} else { |
||||||
|
if (editor.value.state.selection.empty) { |
||||||
|
editor |
||||||
|
.value!.chain() |
||||||
|
.focus() |
||||||
|
.insertContent(' ') |
||||||
|
.setTextSelection({ from: editor.value!.state.selection.$from.pos, to: editor.value!.state.selection.$from.pos + 1 }) |
||||||
|
.toggleLink({ |
||||||
|
href: '', |
||||||
|
}) |
||||||
|
.setTextSelection({ from: editor.value!.state.selection.$from.pos, to: editor.value!.state.selection.$from.pos + 1 }) |
||||||
|
.deleteSelection() |
||||||
|
.run() |
||||||
|
} else { |
||||||
|
editor |
||||||
|
.value!.chain() |
||||||
|
.focus() |
||||||
|
.setLink({ |
||||||
|
href: '', |
||||||
|
}) |
||||||
|
.selectTextblockEnd() |
||||||
|
.run() |
||||||
|
} |
||||||
|
|
||||||
|
setTimeout(() => { |
||||||
|
const linkInput = document.querySelector('.nc-text-area-rich-link-option-input') |
||||||
|
if (linkInput) { |
||||||
|
;(linkInput as any).focus() |
||||||
|
} |
||||||
|
}, 100) |
||||||
|
} |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<template> |
||||||
|
<div |
||||||
|
class="bubble-menu flex flex-row gap-x-1 bg-gray-100 py-1 rounded-lg px-1" |
||||||
|
:class="{ |
||||||
|
'embed-mode': embedMode, |
||||||
|
'full-mode': !embedMode, |
||||||
|
}" |
||||||
|
> |
||||||
|
<NcTooltip :disabled="editor.isActive('codeBlock')"> |
||||||
|
<template #title> |
||||||
|
<div class="flex flex-col items-center"> |
||||||
|
<div> |
||||||
|
{{ $t('labels.bold') }} |
||||||
|
</div> |
||||||
|
<div class="text-xs">{{ cmdOrCtrlKey }} B</div> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
<NcButton |
||||||
|
size="small" |
||||||
|
type="text" |
||||||
|
:class="{ 'is-active': editor.isActive('bold') }" |
||||||
|
:disabled="editor.isActive('codeBlock')" |
||||||
|
@click="editor!.chain().focus().toggleBold().run()" |
||||||
|
> |
||||||
|
<MdiFormatBold /> |
||||||
|
</NcButton> |
||||||
|
</NcTooltip> |
||||||
|
|
||||||
|
<NcTooltip :disabled="editor.isActive('codeBlock')"> |
||||||
|
<template #title> |
||||||
|
<div class="flex flex-col items-center"> |
||||||
|
<div> |
||||||
|
{{ $t('labels.italic') }} |
||||||
|
</div> |
||||||
|
<div>{{ cmdOrCtrlKey }} I</div> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
<NcButton |
||||||
|
size="small" |
||||||
|
type="text" |
||||||
|
:disabled="editor.isActive('codeBlock')" |
||||||
|
:class="{ 'is-active': editor.isActive('italic') }" |
||||||
|
@click=";(editor!.chain().focus() as any).toggleItalic().run()" |
||||||
|
> |
||||||
|
<MdiFormatItalic /> |
||||||
|
</NcButton> |
||||||
|
</NcTooltip> |
||||||
|
<NcTooltip :disabled="editor.isActive('codeBlock')"> |
||||||
|
<template #title> |
||||||
|
<div class="flex flex-col items-center"> |
||||||
|
<div> |
||||||
|
{{ $t('labels.underline') }} |
||||||
|
</div> |
||||||
|
<div>{{ cmdOrCtrlKey }} U</div> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
|
||||||
|
<NcButton |
||||||
|
size="small" |
||||||
|
type="text" |
||||||
|
:class="{ 'is-active': editor.isActive('underline') }" |
||||||
|
:disabled="editor.isActive('codeBlock')" |
||||||
|
@click="editor!.chain().focus().toggleUnderline().run()" |
||||||
|
> |
||||||
|
<MdiFormatUnderline /> |
||||||
|
</NcButton> |
||||||
|
</NcTooltip> |
||||||
|
<NcTooltip :disabled="editor.isActive('codeBlock')"> |
||||||
|
<template #title> |
||||||
|
<div class="flex flex-col items-center"> |
||||||
|
<div> |
||||||
|
{{ $t('labels.strike') }} |
||||||
|
</div> |
||||||
|
<div>{{ shiftKey }} {{ cmdOrCtrlKey }} S</div> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
<NcButton |
||||||
|
size="small" |
||||||
|
type="text" |
||||||
|
:class="{ 'is-active': editor.isActive('strike') }" |
||||||
|
:disabled="editor.isActive('codeBlock')" |
||||||
|
@click="editor!.chain().focus().toggleStrike().run()" |
||||||
|
> |
||||||
|
<MdiFormatStrikeThrough /> |
||||||
|
</NcButton> |
||||||
|
</NcTooltip> |
||||||
|
<NcTooltip v-if="embedMode"> |
||||||
|
<template #title> {{ $t('general.code') }}</template> |
||||||
|
<NcButton |
||||||
|
size="small" |
||||||
|
type="text" |
||||||
|
:class="{ 'is-active': editor.isActive('codeBlock') }" |
||||||
|
@click="editor!.chain().focus().toggleCodeBlock().run()" |
||||||
|
> |
||||||
|
<MsCode /> |
||||||
|
</NcButton> |
||||||
|
</NcTooltip> |
||||||
|
<NcTooltip v-else :disabled="editor.isActive('codeBlock')"> |
||||||
|
<template #title> {{ $t('general.quote') }}</template> |
||||||
|
<NcButton |
||||||
|
size="small" |
||||||
|
type="text" |
||||||
|
:class="{ 'is-active': editor.isActive('code') }" |
||||||
|
:disabled="editor.isActive('codeBlock')" |
||||||
|
@click="editor!.chain().focus().toggleCode().run()" |
||||||
|
> |
||||||
|
<MsFormatQuote /> |
||||||
|
</NcButton> |
||||||
|
</NcTooltip> |
||||||
|
<div class="divider"></div> |
||||||
|
|
||||||
|
<template v-if="embedMode"> |
||||||
|
<NcTooltip> |
||||||
|
<template #title> |
||||||
|
<div class="flex flex-col items-center"> |
||||||
|
<div> |
||||||
|
{{ $t('labels.heading1') }} |
||||||
|
</div> |
||||||
|
<div>{{ cmdOrCtrlKey }} {{ altKey }} 1</div> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
<NcButton |
||||||
|
size="small" |
||||||
|
type="text" |
||||||
|
:class="{ 'is-active': editor.isActive('heading', { level: 1 }) }" |
||||||
|
@click="editor!.chain().focus().toggleHeading({ level: 1 }).run()" |
||||||
|
> |
||||||
|
<MsFormatH1 /> |
||||||
|
</NcButton> |
||||||
|
</NcTooltip> |
||||||
|
<NcTooltip> |
||||||
|
<template #title> |
||||||
|
<div class="flex flex-col items-center"> |
||||||
|
<div> |
||||||
|
{{ $t('labels.heading2') }} |
||||||
|
</div> |
||||||
|
<div>{{ cmdOrCtrlKey }} {{ altKey }} 2</div> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
<NcButton |
||||||
|
size="small" |
||||||
|
type="text" |
||||||
|
:class="{ 'is-active': editor.isActive('heading', { level: 2 }) }" |
||||||
|
@click="editor!.chain().focus().toggleHeading({ level: 2 }).run()" |
||||||
|
> |
||||||
|
<MsFormatH2 /> |
||||||
|
</NcButton> |
||||||
|
</NcTooltip> |
||||||
|
<NcTooltip> |
||||||
|
<template #title> |
||||||
|
<div class="flex flex-col items-center"> |
||||||
|
<div> |
||||||
|
{{ $t('labels.heading3') }} |
||||||
|
</div> |
||||||
|
<div>{{ cmdOrCtrlKey }} {{ altKey }} 3</div> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
<NcButton |
||||||
|
size="small" |
||||||
|
type="text" |
||||||
|
:class="{ 'is-active': editor.isActive('heading', { level: 3 }) }" |
||||||
|
@click="editor!.chain().focus().toggleHeading({ level: 3 }).run()" |
||||||
|
> |
||||||
|
<MsFormatH3 /> |
||||||
|
</NcButton> |
||||||
|
</NcTooltip> |
||||||
|
|
||||||
|
<div class="divider"></div> |
||||||
|
</template> |
||||||
|
|
||||||
|
<NcTooltip v-if="embedMode"> |
||||||
|
<template #title> {{ $t('labels.blockQuote') }}</template> |
||||||
|
<NcButton |
||||||
|
size="small" |
||||||
|
type="text" |
||||||
|
:class="{ 'is-active': editor.isActive('blockquote') }" |
||||||
|
@click="editor!.chain().focus().toggleBlockquote().run()" |
||||||
|
> |
||||||
|
<TablerBlockQuote class="-mt-0.25" /> |
||||||
|
</NcButton> |
||||||
|
</NcTooltip> |
||||||
|
|
||||||
|
<NcTooltip> |
||||||
|
<template #title> {{ $t('labels.bulletList') }}</template> |
||||||
|
<NcButton |
||||||
|
size="small" |
||||||
|
type="text" |
||||||
|
:class="{ 'is-active': editor.isActive('bulletList') }" |
||||||
|
@click="editor!.chain().focus().toggleBulletList().run()" |
||||||
|
> |
||||||
|
<MdiFormatBulletList /> |
||||||
|
</NcButton> |
||||||
|
</NcTooltip> |
||||||
|
|
||||||
|
<NcTooltip> |
||||||
|
<template #title> {{ $t('labels.numberedList') }}</template> |
||||||
|
<NcButton |
||||||
|
size="small" |
||||||
|
type="text" |
||||||
|
:class="{ 'is-active': editor.isActive('orderedList') }" |
||||||
|
@click="editor!.chain().focus().toggleOrderedList().run()" |
||||||
|
> |
||||||
|
<MdiFormatListNumber /> |
||||||
|
</NcButton> |
||||||
|
</NcTooltip> |
||||||
|
|
||||||
|
<NcTooltip> |
||||||
|
<template #title> {{ $t('labels.taskList') }}</template> |
||||||
|
<NcButton |
||||||
|
size="small" |
||||||
|
type="text" |
||||||
|
:class="{ 'is-active': editor.isActive('taskList') }" |
||||||
|
@click="editor!.chain().focus().toggleTaskList().run()" |
||||||
|
> |
||||||
|
<MdiFormatListCheckbox /> |
||||||
|
</NcButton> |
||||||
|
</NcTooltip> |
||||||
|
|
||||||
|
<div class="divider"></div> |
||||||
|
|
||||||
|
<NcTooltip :disabled="editor.isActive('codeBlock')"> |
||||||
|
<template #title> {{ $t('general.link') }}</template> |
||||||
|
<NcButton |
||||||
|
size="small" |
||||||
|
type="text" |
||||||
|
:class="{ 'is-active': editor.isActive('link') }" |
||||||
|
:disabled="editor.isActive('codeBlock')" |
||||||
|
@click="onToggleLink" |
||||||
|
> |
||||||
|
<div class="flex flex-row items-center px-0.5"> |
||||||
|
<MdiLink /> |
||||||
|
<div class="!text-xs !ml-1">{{ $t('general.link') }}</div> |
||||||
|
</div> |
||||||
|
</NcButton> |
||||||
|
</NcTooltip> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
|
||||||
|
<style lang="scss"> |
||||||
|
.bubble-menu-hidden { |
||||||
|
[data-tippy-root] { |
||||||
|
opacity: 0; |
||||||
|
height: 0; |
||||||
|
overflow: hidden; |
||||||
|
z-index: -1; |
||||||
|
user-select: none; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
.bubble-text-format-button-icon { |
||||||
|
@apply px-1.5 py-0 border-1 border-gray-300 rounded-sm items-center justify-center; |
||||||
|
font-size: 0.8rem; |
||||||
|
font-weight: 600; |
||||||
|
} |
||||||
|
.bubble-text-format-button { |
||||||
|
@apply rounded-md py-1 my-0 pl-2.5 pr-3 cursor-pointer items-center gap-x-2.5 hover:bg-gray-100; |
||||||
|
} |
||||||
|
|
||||||
|
.bubble-menu.full-mode { |
||||||
|
@apply border-gray-100 |
||||||
|
box-shadow: 0px 0px 1.2rem 0 rgb(230, 230, 230) !important; |
||||||
|
} |
||||||
|
|
||||||
|
.bubble-menu.embed-mode { |
||||||
|
@apply border-transparent !shadow-none; |
||||||
|
} |
||||||
|
|
||||||
|
.embed-mode.bubble-menu { |
||||||
|
@apply !py-0 !my-0 !border-0; |
||||||
|
|
||||||
|
.divider { |
||||||
|
@apply my-0 !h-11 border-gray-100; |
||||||
|
} |
||||||
|
|
||||||
|
.nc-button { |
||||||
|
@apply !mt-1.65; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
.bubble-menu { |
||||||
|
// shadow |
||||||
|
@apply bg-white; |
||||||
|
border-width: 1px; |
||||||
|
|
||||||
|
.nc-button.is-active { |
||||||
|
@apply !hover:outline-gray-200 bg-gray-100 text-brand-500; |
||||||
|
outline: 1px; |
||||||
|
} |
||||||
|
.divider { |
||||||
|
@apply border-r-1 border-gray-200 !h-6 !mx-0.5 my-1; |
||||||
|
} |
||||||
|
.ant-select-selector { |
||||||
|
@apply !rounded-md; |
||||||
|
} |
||||||
|
.ant-select-selector .ant-select-selection-item { |
||||||
|
@apply !text-xs; |
||||||
|
} |
||||||
|
.ant-btn-loading-icon { |
||||||
|
@apply pb-0.5; |
||||||
|
} |
||||||
|
} |
||||||
|
</style> |
@ -0,0 +1,68 @@ |
|||||||
|
<script lang="ts" setup> |
||||||
|
import type { Editor } from '@tiptap/vue-3' |
||||||
|
import { BubbleMenu } from '@tiptap/vue-3' |
||||||
|
|
||||||
|
const props = defineProps<Props>() |
||||||
|
|
||||||
|
interface Props { |
||||||
|
editor: Editor |
||||||
|
} |
||||||
|
|
||||||
|
const editor = computed(() => props.editor) |
||||||
|
|
||||||
|
// Debounce show menu to prevent flickering |
||||||
|
const showMenu = computed(() => { |
||||||
|
if (!editor) return false |
||||||
|
|
||||||
|
return !editor.value.state.selection.empty |
||||||
|
}) |
||||||
|
const showMenuDebounced = ref(false) |
||||||
|
|
||||||
|
watchDebounced( |
||||||
|
() => showMenu.value, |
||||||
|
(value) => { |
||||||
|
showMenuDebounced.value = value |
||||||
|
}, |
||||||
|
{ |
||||||
|
debounce: 200, |
||||||
|
maxWait: 800, |
||||||
|
immediate: true, |
||||||
|
}, |
||||||
|
) |
||||||
|
|
||||||
|
const handleEditorMouseDown = (e: MouseEvent) => { |
||||||
|
const domsInEvent = document.elementsFromPoint(e.clientX, e.clientY) as HTMLElement[] |
||||||
|
const isBubble = domsInEvent.some((dom) => dom?.classList?.contains('bubble-menu')) |
||||||
|
if (isBubble) return |
||||||
|
|
||||||
|
const pageContent = document.querySelector('.nc-textarea-rich-editor') |
||||||
|
pageContent?.classList.add('bubble-menu-hidden') |
||||||
|
} |
||||||
|
|
||||||
|
const handleEditorMouseUp = (e: MouseEvent) => { |
||||||
|
const domsInEvent = document.elementsFromPoint(e.clientX, e.clientY) as HTMLElement[] |
||||||
|
const isBubble = domsInEvent.some((dom) => dom?.classList?.contains('bubble-menu')) |
||||||
|
if (isBubble) return |
||||||
|
|
||||||
|
setTimeout(() => { |
||||||
|
const pageContent = document.querySelector('.nc-textarea-rich-editor') |
||||||
|
pageContent?.classList.remove('bubble-menu-hidden') |
||||||
|
}, 100) |
||||||
|
} |
||||||
|
|
||||||
|
onMounted(() => { |
||||||
|
document.addEventListener('mouseup', handleEditorMouseUp) |
||||||
|
document.addEventListener('mousedown', handleEditorMouseDown) |
||||||
|
}) |
||||||
|
|
||||||
|
onUnmounted(() => { |
||||||
|
document.removeEventListener('mouseup', handleEditorMouseUp) |
||||||
|
document.removeEventListener('mousedown', handleEditorMouseDown) |
||||||
|
}) |
||||||
|
</script> |
||||||
|
|
||||||
|
<template> |
||||||
|
<BubbleMenu :editor="editor" :update-delay="300" :tippy-options="{ duration: 100, maxWidth: 600 }"> |
||||||
|
<CellRichTextSelectedBubbleMenu v-if="showMenuDebounced" :editor="editor" /> |
||||||
|
</BubbleMenu> |
||||||
|
</template> |
@ -0,0 +1,38 @@ |
|||||||
|
<!-- File not in use for now --> |
||||||
|
|
||||||
|
<script setup lang="ts"> |
||||||
|
import { useVModel } from '#imports' |
||||||
|
|
||||||
|
const props = defineProps<{ |
||||||
|
value: any |
||||||
|
}>() |
||||||
|
|
||||||
|
const emit = defineEmits(['update:value']) |
||||||
|
|
||||||
|
const vModel = useVModel(props, 'value', emit) |
||||||
|
|
||||||
|
const richMode = computed({ |
||||||
|
get: () => vModel.value.meta?.richMode, |
||||||
|
set: (value) => { |
||||||
|
if (!vModel.value.meta) vModel.value.meta = {} |
||||||
|
|
||||||
|
vModel.value.meta.richMode = value |
||||||
|
}, |
||||||
|
}) |
||||||
|
|
||||||
|
watch(richMode, () => { |
||||||
|
vModel.value.cdf = null |
||||||
|
}) |
||||||
|
</script> |
||||||
|
|
||||||
|
<template> |
||||||
|
<div class="flex flex-col mt-2 gap-2"> |
||||||
|
<a-form-item> |
||||||
|
<div class="flex flex-row items-center"> |
||||||
|
<NcSwitch v-model:checked="richMode" :name="$t('labels.enableRichText')" size="small"> |
||||||
|
<div class="text-xs">{{ $t('labels.enableRichText') }}</div> |
||||||
|
</NcSwitch> |
||||||
|
</div> |
||||||
|
</a-form-item> |
||||||
|
</div> |
||||||
|
</template> |
@ -0,0 +1,26 @@ |
|||||||
|
<script lang="ts" setup> |
||||||
|
const props = defineProps<{ |
||||||
|
value: any |
||||||
|
}>() |
||||||
|
const emits = defineEmits(['update:value']) |
||||||
|
|
||||||
|
const vModel = useVModel(props, 'value', emits) |
||||||
|
|
||||||
|
const cdfValue = computed({ |
||||||
|
get: () => vModel.value.cdf, |
||||||
|
set: (value) => { |
||||||
|
vModel.value.cdf = value |
||||||
|
}, |
||||||
|
}) |
||||||
|
</script> |
||||||
|
|
||||||
|
<template> |
||||||
|
<div> |
||||||
|
<div class="!my-3 text-xs">{{ $t('placeholder.defaultValue') }}</div> |
||||||
|
<div class="flex flex-row gap-2"> |
||||||
|
<div class="border-1 relative pt-11 flex items-center w-full px-0 my-[-4px] border-gray-300 rounded-md !max-h-70 !pb-2.5"> |
||||||
|
<LazyCellRichText v-model:value="cdfValue" class="border-t-1 border-gray-100" show-menu full-mode /> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</template> |
@ -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('.nc-text-area-rich-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, |
||||||
|
}) |
@ -0,0 +1,186 @@ |
|||||||
|
import type { KeyboardShortcutCommand } from '@tiptap/core' |
||||||
|
import { Node, mergeAttributes, wrappingInputRule } from '@tiptap/core' |
||||||
|
import type { Node as ProseMirrorNode } from '@tiptap/pm/model' |
||||||
|
|
||||||
|
export interface TaskItemOptions { |
||||||
|
onReadOnlyChecked?: (node: ProseMirrorNode, checked: boolean) => boolean |
||||||
|
nested: boolean |
||||||
|
HTMLAttributes: Record<string, any> |
||||||
|
taskListTypeName: string |
||||||
|
} |
||||||
|
|
||||||
|
export const inputRegex = /^\s*\[( |x)?\]\s$/i |
||||||
|
|
||||||
|
export const TaskItem = Node.create<TaskItemOptions>({ |
||||||
|
name: 'taskItem', |
||||||
|
|
||||||
|
addOptions() { |
||||||
|
return { |
||||||
|
nested: false, |
||||||
|
HTMLAttributes: {}, |
||||||
|
taskListTypeName: 'taskList', |
||||||
|
} |
||||||
|
}, |
||||||
|
|
||||||
|
content() { |
||||||
|
return this.options.nested ? 'paragraph block*' : 'paragraph+' |
||||||
|
}, |
||||||
|
|
||||||
|
defining: true, |
||||||
|
|
||||||
|
addAttributes() { |
||||||
|
return { |
||||||
|
checked: { |
||||||
|
default: false, |
||||||
|
keepOnSplit: false, |
||||||
|
parseHTML: (element) => element.getAttribute('data-checked') === 'true', |
||||||
|
renderHTML: (attributes) => ({ |
||||||
|
'data-checked': attributes.checked, |
||||||
|
}), |
||||||
|
}, |
||||||
|
} |
||||||
|
}, |
||||||
|
|
||||||
|
parseHTML() { |
||||||
|
return [ |
||||||
|
{ |
||||||
|
tag: `li[data-type="${this.name}"]`, |
||||||
|
priority: 51, |
||||||
|
}, |
||||||
|
] |
||||||
|
}, |
||||||
|
|
||||||
|
renderHTML({ node, HTMLAttributes }) { |
||||||
|
return [ |
||||||
|
'li', |
||||||
|
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { |
||||||
|
'data-type': this.name, |
||||||
|
}), |
||||||
|
[ |
||||||
|
'label', |
||||||
|
[ |
||||||
|
'input', |
||||||
|
{ |
||||||
|
type: 'checkbox', |
||||||
|
checked: node.attrs.checked ? 'checked' : null, |
||||||
|
}, |
||||||
|
], |
||||||
|
['span'], |
||||||
|
], |
||||||
|
['div', 0], |
||||||
|
] |
||||||
|
}, |
||||||
|
|
||||||
|
addKeyboardShortcuts() { |
||||||
|
const shortcuts: { |
||||||
|
[key: string]: KeyboardShortcutCommand |
||||||
|
} = { |
||||||
|
'Enter': () => this.editor.commands.splitListItem(this.name), |
||||||
|
'Shift-Tab': () => this.editor.commands.liftListItem(this.name), |
||||||
|
} |
||||||
|
|
||||||
|
if (!this.options.nested) { |
||||||
|
return shortcuts |
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
...shortcuts, |
||||||
|
Tab: () => this.editor.commands.sinkListItem(this.name), |
||||||
|
} |
||||||
|
}, |
||||||
|
|
||||||
|
addNodeView() { |
||||||
|
return ({ node, HTMLAttributes, getPos, editor }) => { |
||||||
|
const listItem = document.createElement('li') |
||||||
|
const checkboxWrapper = document.createElement('label') |
||||||
|
const checkboxStyler = document.createElement('span') |
||||||
|
const checkbox = document.createElement('input') |
||||||
|
const content = document.createElement('div') |
||||||
|
|
||||||
|
checkboxWrapper.contentEditable = 'false' |
||||||
|
checkbox.type = 'checkbox' |
||||||
|
checkbox.addEventListener('change', (event) => { |
||||||
|
// if the editor isn’t editable and we don't have a handler for
|
||||||
|
// readonly checks we have to undo the latest change
|
||||||
|
if (!editor.isEditable && !this.options.onReadOnlyChecked) { |
||||||
|
checkbox.checked = !checkbox.checked |
||||||
|
|
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
const { checked } = event.target as any |
||||||
|
|
||||||
|
if (editor.isEditable && typeof getPos === 'function') { |
||||||
|
editor |
||||||
|
.chain() |
||||||
|
.focus(undefined, { scrollIntoView: false }) |
||||||
|
.command(({ tr }) => { |
||||||
|
const position = getPos() |
||||||
|
const currentNode = tr.doc.nodeAt(position) |
||||||
|
|
||||||
|
tr.setNodeMarkup(position, undefined, { |
||||||
|
...currentNode?.attrs, |
||||||
|
checked, |
||||||
|
}) |
||||||
|
|
||||||
|
return true |
||||||
|
}) |
||||||
|
.run() |
||||||
|
} |
||||||
|
if (!editor.isEditable && this.options.onReadOnlyChecked) { |
||||||
|
// Reset state if onReadOnlyChecked returns false
|
||||||
|
if (!this.options.onReadOnlyChecked(node, checked)) { |
||||||
|
checkbox.checked = !checkbox.checked |
||||||
|
} |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
Object.entries(this.options.HTMLAttributes).forEach(([key, value]) => { |
||||||
|
listItem.setAttribute(key, value) |
||||||
|
}) |
||||||
|
|
||||||
|
listItem.dataset.checked = node.attrs.checked |
||||||
|
if (node.attrs.checked) { |
||||||
|
checkbox.setAttribute('checked', 'checked') |
||||||
|
} |
||||||
|
|
||||||
|
checkboxWrapper.append(checkbox, checkboxStyler) |
||||||
|
listItem.append(checkboxWrapper, content) |
||||||
|
|
||||||
|
Object.entries(HTMLAttributes).forEach(([key, value]) => { |
||||||
|
listItem.setAttribute(key, value) |
||||||
|
}) |
||||||
|
|
||||||
|
return { |
||||||
|
dom: listItem, |
||||||
|
contentDOM: content, |
||||||
|
update: (updatedNode) => { |
||||||
|
if (updatedNode.type !== this.type) { |
||||||
|
return false |
||||||
|
} |
||||||
|
|
||||||
|
listItem.dataset.checked = updatedNode.attrs.checked |
||||||
|
if (updatedNode.attrs.checked) { |
||||||
|
checkbox.setAttribute('checked', 'checked') |
||||||
|
} else { |
||||||
|
checkbox.removeAttribute('checked') |
||||||
|
} |
||||||
|
|
||||||
|
return true |
||||||
|
}, |
||||||
|
} |
||||||
|
} |
||||||
|
}, |
||||||
|
|
||||||
|
addInputRules() { |
||||||
|
return [ |
||||||
|
wrappingInputRule({ |
||||||
|
find: inputRegex, |
||||||
|
type: this.type, |
||||||
|
getAttributes: (match) => ({ |
||||||
|
checked: match[match.length - 1].toLowerCase() === 'x', |
||||||
|
}), |
||||||
|
}), |
||||||
|
] |
||||||
|
}, |
||||||
|
}) |
Loading…
Reference in new issue