Browse Source

Merge pull request #7046 from nocodb/nc-fix/rich-text-long-text

Rich text long text
pull/7071/head
Raju Udava 12 months ago committed by GitHub
parent
commit
449e6ba982
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 6
      packages/nc-gui/components.d.ts
  2. 347
      packages/nc-gui/components/cell/RichText.vue
  3. 244
      packages/nc-gui/components/cell/RichText/LinkOptions.vue
  4. 381
      packages/nc-gui/components/cell/RichText/SelectedBubbleMenu.vue
  5. 68
      packages/nc-gui/components/cell/RichText/SelectedBubbleMenuPopup.vue
  6. 173
      packages/nc-gui/components/cell/TextArea.vue
  7. 3
      packages/nc-gui/components/nc/Button.vue
  8. 5
      packages/nc-gui/components/nc/Switch.vue
  9. 8
      packages/nc-gui/components/smartsheet/column/EditOrAdd.vue
  10. 38
      packages/nc-gui/components/smartsheet/column/LongTextOptions.vue
  11. 26
      packages/nc-gui/components/smartsheet/column/RichLongTextDefaultValue.vue
  12. 17
      packages/nc-gui/components/smartsheet/grid/Table.vue
  13. 8
      packages/nc-gui/components/smartsheet/grid/usePaginationShortcuts.ts
  14. 4
      packages/nc-gui/composables/useMultiSelect/index.ts
  15. 131
      packages/nc-gui/helpers/dbTiptapExtensions/links.ts
  16. 186
      packages/nc-gui/helpers/dbTiptapExtensions/task-item.ts
  17. 14
      packages/nc-gui/lang/en.json
  18. 12
      packages/nc-gui/package.json
  19. 1099
      pnpm-lock.yaml
  20. 11
      tests/playwright/tests/utils/general.ts

6
packages/nc-gui/components.d.ts vendored

@ -76,6 +76,7 @@ declare module '@vue/runtime-core' {
CilFullscreen: typeof import('~icons/cil/fullscreen')['default']
CilFullscreenExit: typeof import('~icons/cil/fullscreen-exit')['default']
ClaritySuccessLine: typeof import('~icons/clarity/success-line')['default']
IcBaselineArrowOutward: typeof import('~icons/ic/baseline-arrow-outward')['default']
IcBaselineMoreVert: typeof import('~icons/ic/baseline-more-vert')['default']
IcOutlineInsertDriveFile: typeof import('~icons/ic/outline-insert-drive-file')['default']
IcRoundEdit: typeof import('~icons/ic/round-edit')['default']
@ -123,6 +124,7 @@ declare module '@vue/runtime-core' {
MdiCodeTags: typeof import('~icons/mdi/code-tags')['default']
MdiContentCopy: typeof import('~icons/mdi/content-copy')['default']
MdiCurrencyUsd: typeof import('~icons/mdi/currency-usd')['default']
MdiDeleteOutline: typeof import('~icons/mdi/delete-outline')['default']
MdiDiscord: typeof import('~icons/mdi/discord')['default']
MdiDotsHorizontal: typeof import('~icons/mdi/dots-horizontal')['default']
MdiDotsVertical: typeof import('~icons/mdi/dots-vertical')['default']
@ -130,9 +132,13 @@ declare module '@vue/runtime-core' {
MdiFileDocumentMultipleOutline: typeof import('~icons/mdi/file-document-multiple-outline')['default']
MdiFileDocumentOutline: typeof import('~icons/mdi/file-document-outline')['default']
MdiFlag: typeof import('~icons/mdi/flag')['default']
MdiFormatBold: typeof import('~icons/mdi/format-bold')['default']
MdiFormatItalic: typeof import('~icons/mdi/format-italic')['default']
MdiFormatUnderline: typeof import('~icons/mdi/format-underline')['default']
MdiHeart: typeof import('~icons/mdi/heart')['default']
MdiHistory: typeof import('~icons/mdi/history')['default']
MdiKeyStar: typeof import('~icons/mdi/key-star')['default']
MdiLink: typeof import('~icons/mdi/link')['default']
MdiLinkVariant: typeof import('~icons/mdi/link-variant')['default']
MdiLoading: typeof import('~icons/mdi/loading')['default']
MdiLogin: typeof import('~icons/mdi/login')['default']

347
packages/nc-gui/components/cell/RichText.vue

@ -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>

244
packages/nc-gui/components/cell/RichText/LinkOptions.vue

@ -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>

381
packages/nc-gui/components/cell/RichText/SelectedBubbleMenu.vue

@ -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>

68
packages/nc-gui/components/cell/RichText/SelectedBubbleMenuPopup.vue

@ -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>

173
packages/nc-gui/components/cell/TextArea.vue

@ -33,19 +33,33 @@ const isForm = inject(IsFormInj, ref(false))
const { showNull } = useGlobal()
const vModel = useVModel(props, 'modelValue', emits, { defaultValue: '' })
const vModel = useVModel(props, 'modelValue', emits, { defaultValue: column?.value.cdf ? String(column?.value.cdf) : '' })
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))!
const position = ref<
| {
top: number
left: number
}
| undefined
>({
top: 200,
left: 600,
})
const isDragging = ref(false)
const focus: VNodeRef = (el) => !isExpandedFormOpen.value && !isEditColumn.value && (el as HTMLTextAreaElement)?.focus()
const height = computed(() => {
if (!rowHeight.value) return 60
if (!rowHeight.value || rowHeight.value === 1) return 36
return rowHeight.value * 60
return rowHeight.value * 36
})
const isVisible = ref(false)
const inputWrapperRef = ref<HTMLElement | null>(null)
const inputRef = ref<HTMLTextAreaElement | null>(null)
@ -67,16 +81,95 @@ onClickOutside(inputWrapperRef, (e) => {
isVisible.value = false
})
const onDblClick = () => {
const onTextClick = () => {
if (!props.virtual) return
isVisible.value = true
editEnabled.value = true
}
const isRichMode = computed(() => {
let meta: any = {}
if (typeof column?.value?.meta === 'string') {
meta = JSON.parse(column?.value?.meta)
} else {
meta = column?.value?.meta ?? {}
}
return meta?.richMode
})
const onExpand = () => {
isVisible.value = true
const { top, left } = inputWrapperRef.value?.getBoundingClientRect() ?? { top: 0, left: 0 }
position.value = {
top: top + 42,
left,
}
}
const onMouseMove = (e: MouseEvent) => {
if (!isDragging.value) return
e.stopPropagation()
position.value = {
top: e.clientY - 22,
left: e.clientX - 46,
}
}
const onMouseUp = (e: MouseEvent) => {
if (!isDragging.value) return
e.stopPropagation()
isDragging.value = false
position.value = undefined
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
}
watch(position, () => {
const dom = document.querySelector('.nc-textarea-dropdown-active') as HTMLElement
if (!dom) return
if (!position.value) return
// Set left and top of dom
setTimeout(() => {
if (!position.value) return
dom.style.left = `${position.value.left}px`
dom.style.top = `${position.value.top}px`
}, 100)
})
const dragStart = () => {
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
isDragging.value = true
}
watch(editEnabled, () => {
if (editEnabled.value) {
isVisible.value = true
}
})
</script>
<template>
<NcDropdown v-model:visible="isVisible" class="overflow-visible" :trigger="[]" placement="bottomLeft">
<NcDropdown
v-model:visible="isVisible"
class="overflow-visible"
:trigger="[]"
placement="bottomLeft"
:overlay-class-name="isVisible ? 'nc-textarea-dropdown-active' : undefined"
>
<div
class="flex flex-row pt-0.5 w-full"
:class="{
@ -85,8 +178,19 @@ const onDblClick = () => {
'h-full': isForm,
}"
>
<div
v-if="isRichMode"
class="w-full cursor-pointer"
:style="{
maxHeight: `${height}px !important`,
minHeight: `${height}px !important`,
}"
@dblclick="onExpand"
>
<LazyCellRichText v-model:value="vModel" sync-value-change readonly class="!pointer-events-none" />
</div>
<textarea
v-if="editEnabled && !isVisible"
v-else-if="editEnabled && !isVisible"
:ref="focus"
v-model="vModel"
rows="4"
@ -123,39 +227,50 @@ const onDblClick = () => {
'word-break': 'break-word',
'white-space': 'pre-line',
}"
@click="onDblClick"
@click="onTextClick"
/>
<span v-else>{{ vModel }}</span>
<div
v-if="active && !isExpandedFormOpen"
class="!absolute right-0 bottom-0 h-6 w-5 group cursor-pointer flex justify-end gap-1 items-center active:(ring ring-accent ring-opacity-100) rounded border-none p-1 hover:(bg-primary bg-opacity-10) dark:(!bg-slate-500)"
:class="{ 'right-2 bottom-2': editEnabled }"
data-testid="attachment-cell-file-picker-button"
@click.stop="isVisible = !isVisible"
<NcTooltip
v-if="!isVisible"
placement="bottom"
class="!absolute right-0 bottom-1 !hidden nc-text-area-expand-btn"
:class="{ 'right-0 bottom-1': editEnabled, '!bottom-0': !isRichMode }"
>
<NcTooltip placement="bottom">
<template #title>{{ $t('title.expand') }}</template>
<component
:is="iconMap.expand"
class="transform dark:(!text-white) group-hover:(!text-grey-800 scale-120) text-gray-500 text-xs"
/>
</NcTooltip>
</div>
<template #title>{{ $t('title.expand') }}</template>
<NcButton type="secondary" size="xsmall" data-testid="attachment-cell-file-picker-button" @click.stop="onExpand">
<component :is="iconMap.expand" class="transform group-hover:(!text-grey-800 ) scale-120 text-gray-700 text-xs" />
</NcButton>
</NcTooltip>
</div>
<template #overlay>
<div ref="inputWrapperRef" class="flex flex-col min-w-120 min-h-70 py-3 pl-3 pr-1 expanded-cell-input">
<div
v-if="isVisible"
ref="inputWrapperRef"
class="flex flex-col min-w-200 min-h-70 py-3 expanded-cell-input relative"
:class="{
'cursor-move': isDragging,
}"
>
<div
v-if="column"
class="flex flex-row gap-x-1 items-center font-medium pb-2.5 mb-1 py-1 mr-3 ml-1 border-b-1 border-gray-100"
class="flex flex-row gap-x-1 items-center font-medium pl-3 pb-2.5 border-b-1 border-gray-100 cursor-move"
:class="{
'select-none': isDragging,
}"
@mousedown="dragStart"
>
<SmartsheetHeaderCellIcon class="flex" />
<div class="flex">
{{ column.title }}
<div class="flex max-w-38">
<span class="truncate">
{{ column.title }}
</span>
</div>
</div>
<a-textarea
v-if="!isRichMode"
ref="inputRef"
v-model:value="vModel"
class="p-1 !pt-1 !pr-3 !border-0 !border-r-0 !focus:outline-transparent nc-scrollbar-md !text-black !cursor-text"
@ -166,13 +281,19 @@ const onDblClick = () => {
@keydown.stop
@keydown.escape="isVisible = false"
/>
<LazyCellRichText v-else-if="isVisible" v-model:value="vModel" show-menu full-mode :read-only="readOnly" />
</div>
</template>
</NcDropdown>
</template>
<style>
<style lang="scss" scoped>
textarea:focus {
box-shadow: none;
}
:deep(.nc-text-area-expand-btn) {
@apply !block;
}
</style>

3
packages/nc-gui/components/nc/Button.vue

@ -172,7 +172,8 @@ useEventListener(NcButton, 'mousedown', () => {
@apply p-0 h-5.75 min-w-5.75 rounded-md;
}
.nc-button.ant-btn[disabled] {
.nc-button.ant-btn[disabled],
.ant-btn-text.nc-button.ant-btn[disabled] {
box-shadow: none !important;
@apply bg-gray-50 border-0 text-gray-300 cursor-not-allowed md:(hover:bg-gray-50);
}

5
packages/nc-gui/components/nc/Switch.vue

@ -11,7 +11,8 @@ const onChange = (e: boolean) => {
</script>
<template>
<a-switch v-model:checked="checked" :disabled="props.disabled" class="nc-switch" size="small" @change="onChange">
<a-switch v-model:checked="checked" :disabled="props.disabled" class="nc-switch" size="small" @change="onChange"> </a-switch>
<span class="cursor-pointer pl-2" @click="checked = !checked">
<slot />
</a-switch>
</span>
</template>

8
packages/nc-gui/components/smartsheet/column/EditOrAdd.vue

@ -220,6 +220,7 @@ if (props.fromTableExplorer) {
:class="{
'bg-white': !props.fromTableExplorer,
'w-[400px]': !props.embedMode,
'!w-146': isTextArea(formState) && formState.meta.richMode,
'!w-[600px]': formState.uidt === UITypes.Formula && !props.embedMode,
'!w-[500px]': formState.uidt === UITypes.Attachment && !props.embedMode && !appInfo.ee,
'shadow-lg border-1 border-gray-50 shadow-gray-100 rounded-md p-6': !embedMode,
@ -296,6 +297,7 @@ if (props.fromTableExplorer) {
<LazySmartsheetColumnQrCodeOptions v-if="formState.uidt === UITypes.QrCode" v-model="formState" />
<LazySmartsheetColumnBarcodeOptions v-if="formState.uidt === UITypes.Barcode" v-model="formState" />
<LazySmartsheetColumnCurrencyOptions v-if="formState.uidt === UITypes.Currency" v-model:value="formState" />
<LazySmartsheetColumnLongTextOptions v-if="formState.uidt === UITypes.LongText" v-model:value="formState" />
<LazySmartsheetColumnDurationOptions v-if="formState.uidt === UITypes.Duration" v-model:value="formState" />
<LazySmartsheetColumnRatingOptions v-if="formState.uidt === UITypes.Rating" v-model:value="formState" />
<LazySmartsheetColumnCheckboxOptions v-if="formState.uidt === UITypes.Checkbox" v-model:value="formState" />
@ -331,8 +333,12 @@ if (props.fromTableExplorer) {
<!--
Default Value for JSON & LongText is not supported in MySQL
Default Value is Disabled for MSSQL -->
<LazySmartsheetColumnRichLongTextDefaultValue
v-if="isTextArea(formState) && formState.meta.richMode"
v-model:value="formState"
/>
<LazySmartsheetColumnDefaultValue
v-if="
v-else-if="
!isVirtualCol(formState) &&
!isAttachment(formState) &&
!isMssql(meta!.source_id) &&

38
packages/nc-gui/components/smartsheet/column/LongTextOptions.vue

@ -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>

26
packages/nc-gui/components/smartsheet/column/RichLongTextDefaultValue.vue

@ -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>

17
packages/nc-gui/components/smartsheet/grid/Table.vue

@ -557,6 +557,8 @@ const {
return true
}
if (isExpandedCellInputExist()) return
// skip keyboard event handling if there is a drawer / modal
if (isDrawerOrModalExist()) {
return true
@ -565,7 +567,9 @@ const {
const cmdOrCtrl = isMac() ? e.metaKey : e.ctrlKey
const altOrOptionKey = e.altKey
if (e.key === ' ') {
if (isCellActive.value && !editEnabled.value && hasEditPermission.value && activeCell.row !== null) {
const isRichModalOpen = isExpandedCellInputExist()
if (isCellActive.value && !editEnabled.value && hasEditPermission.value && activeCell.row !== null && !isRichModalOpen) {
e.preventDefault()
const row = dataRef.value[activeCell.row]
expandForm?.(row)
@ -761,6 +765,9 @@ onClickOutside(tableBodyEl, (e) => {
if (activeCell.row === null || activeCell.col === null) return
const isRichModalOpen = isExpandedCellInputExist()
if (isRichModalOpen) return
const activeCol = fields.value[activeCell.col]
if (editEnabled.value && (isVirtualCol(activeCol) || activeCol.uidt === UITypes.JSON)) return
@ -1061,14 +1068,18 @@ useEventListener(document, 'mouseup', () => {
/** handle keypress events */
useEventListener(document, 'keydown', async (e: KeyboardEvent) => {
if (e.key === 'Alt') {
const isRichModalOpen = isExpandedCellInputExist()
if (e.key === 'Alt' && !isRichModalOpen) {
altModifier.value = true
}
})
/** handle keypress events */
useEventListener(document, 'keyup', async (e: KeyboardEvent) => {
if (e.key === 'Alt') {
const isRichModalOpen = isExpandedCellInputExist()
if (e.key === 'Alt' && !isRichModalOpen) {
altModifier.value = false
disableUrlOverlay.value = false
}

8
packages/nc-gui/components/smartsheet/grid/usePaginationShortcuts.ts

@ -26,6 +26,8 @@ const usePaginationShortcuts = ({
}
const onLeft = async (e: KeyboardEvent) => {
if (isExpandedCellInputExist()) return
if (!e.altKey) return
e.preventDefault()
@ -36,6 +38,8 @@ const usePaginationShortcuts = ({
}
const onRight = async (e: KeyboardEvent) => {
if (isExpandedCellInputExist()) return
if (!e.altKey) return
e.preventDefault()
@ -47,6 +51,8 @@ const usePaginationShortcuts = ({
}
const onDown = async (e: KeyboardEvent) => {
if (isExpandedCellInputExist()) return
if (!e.altKey) return
e.preventDefault()
@ -56,6 +62,8 @@ const usePaginationShortcuts = ({
}
const onUp = async (e: KeyboardEvent) => {
if (isExpandedCellInputExist()) return
if (!e.altKey) return
e.preventDefault()

4
packages/nc-gui/composables/useMultiSelect/index.ts

@ -486,6 +486,10 @@ export function useMultiSelect(
return true
}
if (isExpandedCellInputExist()) {
return
}
if (!isCellActive.value || activeCell.row === null || activeCell.col === null) {
return
}

131
packages/nc-gui/helpers/dbTiptapExtensions/links.ts

@ -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,
})

186
packages/nc-gui/helpers/dbTiptapExtensions/task-item.ts

@ -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',
}),
}),
]
},
})

14
packages/nc-gui/lang/en.json

@ -75,6 +75,7 @@
"matterMost": "Mattermost",
"twilio": "Twilio",
"whatsappTwilio": "WhatsApp Twilio",
"quote": "Quote",
"submit": "Submit",
"create": "Create",
"createEntity": "Create {entity}",
@ -82,6 +83,7 @@
"creatingEntity": "Creating {entity}",
"details": "Details",
"skip": "Skip",
"code": "Code",
"duplicate": "Duplicate",
"duplicating": "Duplicating",
"activate": "Activate",
@ -411,7 +413,18 @@
}
},
"labels": {
"heading1": "Heading 1",
"heading2": "Heading 2",
"heading3": "Heading 3",
"bold": "Bold",
"italic": "Italic",
"underline": "Underline",
"strike": "Strike",
"taskList": "Task List",
"bulletList": "Bullet List",
"numberedList": "Numbered List",
"downloadData": "Download Data",
"blockQuote": "Block Quote",
"noToken": "No Token",
"tokenLimit": "Only one token per user is allowed",
"duplicateAttachment": "File with name {filename} already attached",
@ -423,6 +436,7 @@
"headerName": "Header Name",
"icon": "Icon",
"max": "Max",
"enableRichText": "Enable Rich Text",
"idColon": "Id:",
"copiedRecordURL": "Copied Record URL",
"copyRecordURL": "Copy Record URL",

12
packages/nc-gui/package.json

@ -90,7 +90,16 @@
"vue3-grid-layout-next": "^1.0.6",
"vue3-text-clamp": "^0.1.2",
"vuedraggable": "^4.1.0",
"xlsx": "^0.18.5"
"xlsx": "^0.18.5",
"@tiptap/extension-link": "2.0.3",
"@tiptap/extension-task-list": "2.0.3",
"@tiptap/extension-underline": "^2.1.8",
"@tiptap/html": "2.0.3",
"@tiptap/pm": "^2.1.12",
"@tiptap/starter-kit": "^2.1.12",
"marked": "^4.3.0",
"turndown": "^7.1.2",
"@tiptap/vue-3": "2.0.3"
},
"devDependencies": {
"@antfu/eslint-config": "^0.26.3",
@ -131,6 +140,7 @@
"@types/tinycolor2": "^1.4.6",
"@types/validator": "^13.11.7",
"@types/vue-barcode-reader": "^0.0.3",
"@types/turndown": "^5.0.4",
"@unocss/nuxt": "^0.51.13",
"@vitest/ui": "^0.18.1",
"@vue/compiler-sfc": "^3.3.8",

1099
pnpm-lock.yaml

File diff suppressed because it is too large Load Diff

11
tests/playwright/tests/utils/general.ts

@ -1,16 +1,21 @@
// Selector objects include the text of any icons in the textContent property.
import { Locator } from '@playwright/test';
// This function removes the text of any icons from the textContent property.
async function getTextExcludeIconText(selector) {
async function getTextExcludeIconText(selector: Locator) {
// Get the text of the selector
let text = await selector.textContent();
// List of icons
const icons = await selector.locator('.material-symbols');
const icons = selector.locator('.material-symbols');
const iconCount = await icons.count();
// Remove the text of each icon from the text
for (let i = 0; i < iconCount; i++) {
await icons.nth(i).waitFor();
await icons.nth(i).waitFor({
state: 'attached',
});
const iconText = await icons.nth(i).textContent();
text = text.replace(iconText, '');
}

Loading…
Cancel
Save