mirror of https://github.com/nocodb/nocodb
Muhammed Mustafa
1 year ago
2 changed files with 310 additions and 0 deletions
@ -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> |
@ -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…
Reference in new issue