Browse Source

fix: Added basic rich text editor support, with bold, italic, underline, strike through, 3 list types and saved as markdown

pull/7046/head
Muhammed Mustafa 1 year ago
parent
commit
c51acc18ef
  1. 77
      packages/nc-gui/components/cell/RichText.vue
  2. 233
      packages/nc-gui/components/cell/RichText/SelectedBubbleMenu.vue

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

@ -0,0 +1,77 @@
<script lang="ts" setup>
import StarterKit from '@tiptap/starter-kit'
import TaskItem from '@tiptap/extension-task-item'
import TaskList from '@tiptap/extension-task-list'
import { EditorContent, useEditor } from '@tiptap/vue-3'
import TurndownService from 'turndown'
import { parse } from 'marked'
import { generateJSON } from '@tiptap/html'
import Underline from '@tiptap/extension-underline'
const props = defineProps<{
value?: string | null
readonly?: boolean
}>()
const emits = defineEmits(['update:value'])
const turndownService = new TurndownService()
const vModel = useVModel(props, 'value', emits, { defaultValue: '' })
const tiptapExtensions = [
StarterKit,
TaskList,
TaskItem.configure({
nested: true,
}),
Underline,
]
const editor = useEditor({
extensions: tiptapExtensions,
onUpdate: ({ editor }) => {
const markdown = turndownService.turndown(editor.getHTML())
vModel.value = markdown
},
editable: !props.readonly,
})
const setEditorContent = (contentMd: any) => {
if (!editor.value) return
;(editor.value.state as any).history$.prevRanges = null
;(editor.value.state as any).history$.done.eventCount = 0
const selection = editor.value.view.state.selection
const contentHtml = parse(contentMd)
const content = generateJSON(contentHtml, tiptapExtensions)
editor.value.chain().setContent(content).setTextSelection(selection.to).run()
}
onMounted(() => {
setTimeout(() => {
setEditorContent(vModel.value)
}, 300)
})
</script>
<template>
<EditorContent :editor="editor" class="nc-textarea-rich w-full h-full" />
<CellRichTextSelectedBubbleMenu v-if="editor" :editor="editor" />
</template>
<style lang="scss">
.nc-textarea-rich {
.ProseMirror {
@apply min-h-full;
}
.ProseMirror-focused {
// remove all border
outline: none;
}
}
</style>

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

@ -0,0 +1,233 @@
<script lang="ts" setup>
import type { Editor } from '@tiptap/vue-3'
import { BubbleMenu } 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'
const { editor } = defineProps<Props>()
interface Props {
editor: Editor
}
// Debounce show menu to prevent flickering
const showMenu = computed(() => {
if (!editor) return false
return !editor.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-docs-page-wrapper')
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-docs-page-wrapper')
pageContent?.classList.remove('bubble-menu-hidden')
}, 100)
}
const onToggleLink = () => {
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) {
editor!.chain().focus().unsetLink().run()
} else {
editor!
.chain()
.focus()
.toggleLink({
href: '',
})
.selectTextblockEnd()
.run()
setTimeout(() => {
const linkInput = document.querySelector('.docs-link-option-input')
if (linkInput) {
;(linkInput as any).focus()
}
}, 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 }">
<div v-if="showMenuDebounced" class="bubble-menu flex flex-row gap-x-1 bg-gray-100 py-1 rounded-lg px-1">
<a-button
type="text"
:class="{ 'is-active': editor.isActive('bold') }"
:aria-active="editor.isActive('bold')"
class="menu-button"
data-testid="nc-docs-editor-bold-button"
@click="editor!.chain().focus().toggleBold().run()"
>
<MdiFormatBold />
</a-button>
<a-button
type="text"
:class="{ 'is-active': editor.isActive('italic') }"
:aria-active="editor.isActive('italic')"
class="menu-button"
data-testid="nc-docs-editor-italic-button"
@click=";(editor!.chain().focus() as any).toggleItalic().run()"
>
<MdiFormatItalic />
</a-button>
<a-button
type="text"
:class="{ 'is-active': editor.isActive('underline') }"
:aria-active="editor.isActive('underline')"
class="menu-button"
data-testid="nc-docs-editor-underline-button"
@click="editor!.chain().focus().toggleUnderline().run()"
>
<MdiFormatUnderline />
</a-button>
<a-button
type="text"
:class="{ 'is-active': editor.isActive('strike') }"
:aria-active="editor.isActive('strike')"
class="menu-button"
data-testid="nc-docs-editor-strike-button"
@click="editor!.chain().focus().toggleStrike().run()"
>
<MdiFormatStrikeThrough />
</a-button>
<div class="divider"></div>
<a-button
type="text"
:class="{ 'is-active': editor.isActive('taskList') }"
:aria-active="editor.isActive('taskList')"
class="menu-button"
data-testid="nc-docs-editor-task-button"
@click="editor!.chain().focus().toggleTaskList().run()"
>
<MdiFormatListCheckbox />
</a-button>
<a-button
type="text"
:class="{ 'is-active': editor.isActive('bulletList') }"
:aria-active="editor.isActive('bulletList')"
class="menu-button"
data-testid="nc-docs-editor-bullet-button"
@click="editor!.chain().focus().toggleBulletList().run()"
>
<MdiFormatBulletList />
</a-button>
<a-button
type="text"
:class="{ 'is-active': editor.isActive('orderedList') }"
:aria-active="editor.isActive('orderedList')"
class="menu-button"
data-testid="nc-docs-editor-ordered-button"
@click="editor!.chain().focus().toggleOrderedList().run()"
>
<MdiFormatListNumber />
</a-button>
<div class="divider"></div>
<a-button
type="text"
:class="{ 'is-active': editor.isActive('link') }"
:aria-active="editor.isActive('link')"
class="menu-button"
data-testid="nc-docs-editor-link-button"
@click="onToggleLink"
>
<div class="flex flex-row items-center px-0.5">
<MdiLink />
<div class="!text-xs !ml-1">Link</div>
</div>
</a-button>
</div>
</BubbleMenu>
</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 {
// shadow
@apply border-gray-100 bg-white;
border-width: 1px;
box-shadow: 0px 0px 1.2rem 0 rgb(230, 230, 230) !important;
.is-active {
@apply border-1 !hover:bg-gray-200 border-1 border-gray-200 bg-gray-100;
}
.menu-button {
@apply rounded-md !py-0 !my-0 !px-1.5 !h-8 hover:bg-gray-100;
}
.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>
Loading…
Cancel
Save