mirror of https://github.com/nocodb/nocodb
աɨռɢӄաօռɢ
1 year ago
241 changed files with 34436 additions and 9449 deletions
Before Width: | Height: | Size: 557 B After Width: | Height: | Size: 567 B |
Before Width: | Height: | Size: 712 B After Width: | Height: | Size: 727 B |
@ -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,136 @@
|
||||
<script setup lang="ts"> |
||||
import type { ColumnType } from 'nocodb-sdk' |
||||
import { message } from 'ant-design-vue' |
||||
import { useVModel } from '#imports' |
||||
|
||||
const props = defineProps<{ |
||||
modelValue: boolean |
||||
column: ColumnType |
||||
extra: any |
||||
}>() |
||||
|
||||
const emit = defineEmits(['update:modelValue']) |
||||
|
||||
const { api } = useApi() |
||||
|
||||
const dialogShow = useVModel(props, 'modelValue', emit) |
||||
|
||||
const { $e, $poller } = useNuxtApp() |
||||
|
||||
const { activeTable: _activeTable } = storeToRefs(useTablesStore()) |
||||
|
||||
const reloadDataHook = inject(ReloadViewDataHookInj) |
||||
|
||||
const { eventBus } = useSmartsheetStoreOrThrow() |
||||
|
||||
const { getMeta } = useMetas() |
||||
|
||||
const meta = inject(MetaInj, ref()) |
||||
|
||||
const options = ref({ |
||||
includeData: true, |
||||
}) |
||||
|
||||
const optionsToExclude = computed(() => { |
||||
const { includeData } = options.value |
||||
return { |
||||
excludeData: !includeData, |
||||
} |
||||
}) |
||||
|
||||
const isLoading = ref(false) |
||||
|
||||
const reloadTable = async () => { |
||||
await getMeta(meta!.value!.id!, true) |
||||
eventBus.emit(SmartsheetStoreEvents.FIELD_RELOAD) |
||||
reloadDataHook?.trigger() |
||||
} |
||||
|
||||
const _duplicate = async () => { |
||||
try { |
||||
isLoading.value = true |
||||
const jobData = await api.dbTable.duplicateColumn(props.column.base_id!, props.column.id!, { |
||||
options: optionsToExclude.value, |
||||
extra: props.extra, |
||||
}) |
||||
|
||||
$poller.subscribe( |
||||
{ id: jobData.id }, |
||||
async (data: { |
||||
id: string |
||||
status?: string |
||||
data?: { |
||||
error?: { |
||||
message: string |
||||
} |
||||
message?: string |
||||
result?: any |
||||
} |
||||
}) => { |
||||
if (data.status !== 'close') { |
||||
if (data.status === JobStatus.COMPLETED) { |
||||
reloadTable() |
||||
isLoading.value = false |
||||
dialogShow.value = false |
||||
} else if (data.status === JobStatus.FAILED) { |
||||
message.error(`There was an error duplicating the column.`) |
||||
reloadTable() |
||||
isLoading.value = false |
||||
dialogShow.value = false |
||||
} |
||||
} |
||||
}, |
||||
) |
||||
|
||||
$e('a:column:duplicate') |
||||
} catch (e: any) { |
||||
message.error(await extractSdkResponseErrorMsg(e)) |
||||
isLoading.value = false |
||||
dialogShow.value = false |
||||
} |
||||
} |
||||
|
||||
onKeyStroke('Enter', () => { |
||||
// should only trigger this when our modal is open |
||||
if (dialogShow.value) { |
||||
_duplicate() |
||||
} |
||||
}) |
||||
|
||||
defineExpose({ |
||||
duplicate: _duplicate, |
||||
}) |
||||
</script> |
||||
|
||||
<template> |
||||
<GeneralModal |
||||
v-model:visible="dialogShow" |
||||
:class="{ active: dialogShow }" |
||||
:closable="!isLoading" |
||||
:mask-closable="!isLoading" |
||||
:keyboard="!isLoading" |
||||
centered |
||||
wrap-class-name="nc-modal-column-duplicate" |
||||
:footer="null" |
||||
class="!w-[30rem]" |
||||
@keydown.esc="dialogShow = false" |
||||
> |
||||
<div> |
||||
<div class="prose-xl font-bold self-center">{{ $t('general.duplicate') }} {{ $t('objects.column') }}</div> |
||||
|
||||
<div class="mt-4">Are you sure you want to duplicate the field?</div> |
||||
|
||||
<div class="prose-md self-center text-gray-500 mt-4">{{ $t('title.advancedSettings') }}</div> |
||||
|
||||
<a-divider class="!m-0 !p-0 !my-2" /> |
||||
|
||||
<div class="text-xs p-2"> |
||||
<a-checkbox v-model:checked="options.includeData" :disabled="isLoading">{{ $t('labels.includeData') }}</a-checkbox> |
||||
</div> |
||||
</div> |
||||
<div class="flex flex-row gap-x-2 mt-2.5 pt-2.5 justify-end"> |
||||
<NcButton v-if="!isLoading" key="back" type="secondary" @click="dialogShow = false">{{ $t('general.cancel') }}</NcButton> |
||||
<NcButton key="submit" type="primary" :loading="isLoading" @click="_duplicate">{{ $t('general.confirm') }} </NcButton> |
||||
</div> |
||||
</GeneralModal> |
||||
</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,28 @@
|
||||
<script setup lang="ts"> |
||||
import type { ColumnType } from 'nocodb-sdk' |
||||
import { isVirtualCol } from 'nocodb-sdk' |
||||
|
||||
defineProps<{ |
||||
column: ColumnType |
||||
modelValue: any |
||||
}>() |
||||
|
||||
provide(ReadonlyInj, true) |
||||
</script> |
||||
|
||||
<template> |
||||
<div class="pointer-events-none"> |
||||
<LazySmartsheetRow :row="{ row: { [column.title]: modelValue }, rowMeta: {} }"> |
||||
<LazySmartsheetVirtualCell v-if="isVirtualCol(column)" :model-value="modelValue" class="!text-gray-600" :column="column" /> |
||||
|
||||
<LazySmartsheetCell |
||||
v-else |
||||
:model-value="modelValue" |
||||
class="!text-gray-600" |
||||
:column="column" |
||||
:edit-enabled="false" |
||||
:read-only="true" |
||||
/> |
||||
</LazySmartsheetRow> |
||||
</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', |
||||
}), |
||||
}), |
||||
] |
||||
}, |
||||
}) |
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue