| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461 |
- <script setup lang="ts">
- import "remixicon/fonts/remixicon.css";
- import "../style/index.css";
- import "../style/table.scss";
- import { computed, onBeforeUnmount, onMounted, provide, ref, shallowRef, watch } from "vue";
- import { useThemeVars } from "naive-ui";
- import { EditorContent, BubbleMenu, EditorOptions } from "@tiptap/vue-3";
- import { useDebounceFn, useThrottleFn } from "@vueuse/core";
- import { NMessageProvider, NDialogProvider } from "naive-ui";
- import VividMenu from "./components/VividMenu.vue";
- import VividBubbleMenu from "./components/VividBubbleMenu.vue";
- import VividFooter from "./components/VividFooter.vue";
- import { Editor } from "@tiptap/vue-3";
- import { CellSelection } from "prosemirror-tables";
- import { TextSelection } from "@tiptap/pm/state";
- import { Extension, Mark, Node, Editor as TiptapEditor } from "@tiptap/core";
- const vars = useThemeVars();
- const props = defineProps({
- page: {
- type: Boolean,
- default: false,
- },
- dark: {
- type: Boolean,
- default: false,
- },
- bubbleMenu: {
- type: Boolean,
- default: true,
- },
- modelValue: {
- type: String,
- default: "",
- },
- to: {
- type: String,
- required: false,
- default: "",
- },
- readonly: {
- type: Boolean,
- default: false,
- },
- handleImageUpload: {
- type: Function,
- required: false,
- },
- handleVideoUpload: {
- type: Function,
- required: false,
- },
- handlePackageUpload: {
- type: Function,
- required: false,
- },
- });
- let internalExt: (Extension | Node | Mark)[] = [];
- const editor = shallowRef<Editor>();
- const words = ref(0);
- const characters = ref(0);
- const fullscreen = ref(false);
- const editorBox = ref<any>(null);
- const editorBoxParent = ref<any>(null);
- const isFocused = ref(false);
- const emit = defineEmits(["update:modelValue", "change"]);
- provide("useExtension", useExtension);
- provide("removeExtension", removeExtension);
- provide("editorInstance", editor);
- const updateEditorWordCount = useDebounceFn(() => {
- words.value = editor.value?.storage.characterCount.words() || 0;
- characters.value = editor.value?.storage.characterCount.characters() || 0;
- }, 300);
- function useExtension(ext: Extension | Node | Mark) {
- if (internalExt.filter((e) => e === ext).length) {
- return;
- }
- internalExt.push(ext);
- }
- function removeExtension(extName: string) {
- const index = internalExt.findIndex((e) => e.name === extName);
- if (index > -1) {
- internalExt.splice(index, 1);
- }
- }
- const onUpdate = useDebounceFn((editor: TiptapEditor) => {
- // HTML
- emit("update:modelValue", editor.getHTML());
- // JSON
- // this.$emit('update:modelValue', this.editor.getJSON())
- updateEditorWordCount();
- }, 300);
- function initEditor() {
- const opt: Partial<EditorOptions> = {
- content: props.modelValue,
- editable: !props.readonly,
- extensions: internalExt,
- onUpdate: ({ editor }) => {
- onUpdate(editor);
- },
- onFocus: () => {
- isFocused.value = true;
- },
- onBlur: () => {
- isFocused.value = false;
- },
- onSelectionUpdate: ({ editor }) => {
- },
- };
- editor.value = new Editor(opt);
- editor.value.storage.fullscreen = fullscreen;
- emit("update:modelValue", editor.value.getHTML());
- emit("change", editor.value.getHTML());
- updateEditorWordCount();
- }
- onMounted(() => {
- initEditor();
- });
- watch(
- () => props.readonly,
- (value) => {
- if (editor.value) {
- editor.value.setEditable(!value);
- }
- },
- );
- const onValueChange = useThrottleFn((value: string) => {
- if (!editor.value) {
- return;
- }
- // HTML
- const isSame = editor.value.getHTML() === value;
- // JSON
- // const isSame = JSON.stringify(this.editor.getJSON()) === JSON.stringify(value)
- if (isSame) {
- return;
- }
- editor.value.commands.setContent(value, false);
- }, 200);
- watch(
- () => props.modelValue,
- onValueChange,
- );
- watch(fullscreen, () => {
- if (fullscreen.value === true) {
- if (props.to) {
- editorBoxParent.value = editorBox.value.parentNode;
- const to = document.getElementById(props.to);
- if (to) {
- to.append(editorBox.value);
- }
- }
- } else {
- if (props.to) {
- editorBoxParent.value.append(editorBox.value);
- }
- }
- });
- onBeforeUnmount(() => {
- editor.value?.destroy();
- });
- function tab(e) {
- if (e.keyCode === 9) {
- e.preventDefault();
- }
- }
- const hideBubble = ref(false);
- const nodeType = computed<string | undefined>(() => {
- if (!editor.value) {
- return undefined;
- }
- const selection = editor.value.state.selection as any;
- const isImage = selection.node?.type.name === "image";
- const isVideo = selection.node?.type.name === "video";
- const isMagic = selection.node?.type.name === "magic";
- const isMath = selection.node?.type.name === "hb-math";
- const isCodeBlock = selection.$anchor.parent.type.name === "codeBlock";
- const isCell = selection instanceof CellSelection;
- const isTable = selection.node?.type.name === "table" || isCell; // 选中表格或者单元格
- const isText = selection instanceof TextSelection;
- if (isImage) return "image";
- if (isVideo) return "video";
- if (isTable) return "table";
- if (isMagic) return "magic";
- if (isMath) return "math";
- if (isCodeBlock) return "codeBlock";
- if (isText) return "text";
- return undefined;
- });
- watch(nodeType, () => {
- if (nodeType.value === "magic" || nodeType.value === "codeBlock") {
- hideBubble.value = true;
- } else {
- hideBubble.value = false;
- }
- });
- function getInstance() {
- return editor.value;
- }
- defineExpose({
- getInstance,
- });
- </script>
- <template>
- <div ref="editorBox" class="editor-background" :class="{ fullscreen: fullscreen }" @keydown="tab">
- <n-message-provider>
- <n-dialog-provider>
- <slot />
- <bubble-menu
- class="bubble-menu-bar"
- v-if="editor && editor.isEditable && bubbleMenu && nodeType"
- v-show="!hideBubble"
- :editor="editor"
- :tippy-options="{ duration: 0, maxWidth: 600, placement: 'top-start' }"
- >
- <slot name="bubble-menu" :nodeType="nodeType">
- <vivid-bubble-menu :node-type="nodeType" />
- </slot>
- </bubble-menu>
- <div
- class="editor"
- :class="{
- fullscreen: fullscreen,
- focus: isFocused && !fullscreen,
- online: page,
- }"
- spellcheck="false"
- >
- <div :class="{ 'editor-readonly': readonly }" style="width: 100%">
- <slot name="menu" :readonly="readonly">
- <vivid-menu class="editor-header" :editor="editor" :handleImageUpload="handleImageUpload" :handleVideoUpload="handleVideoUpload" :handlePackageUpload="handlePackageUpload"/>
- </slot>
- </div>
- <div class="editor-page" v-if="page">
- <editor-content
- class="editor-body editor-body-page markdown-body"
- :class="{ dark: props.dark, light: !props.dark }"
- :editor="editor"
- />
- </div>
- <editor-content
- v-else
- class="editor-body editor-body-flow markdown-body"
- :class="{ dark: props.dark, light: !props.dark }"
- :editor="editor"
- />
- <slot name="footer" :data="{ words, characters }">
- <vivid-footer :words="words" :characters="characters" />
- </slot>
- </div>
- </n-dialog-provider>
- </n-message-provider>
- </div>
- </template>
- <style scoped>
- .editor {
- border: 1px solid v-bind(vars.borderColor);
- border-radius: 3px;
- display: flex;
- flex-direction: column;
- box-sizing: border-box;
- height: 100%;
- transition-property: border-color, box-shadow;
- transition-duration: 0.2s;
- background: v-bind(vars.inputColor);
- position: relative;
- }
- .bubble-menu-bar {
- background: v-bind(vars.popoverColor);
- box-shadow: v-bind(vars.boxShadow2);
- border-radius: 10px;
- overflow: hidden;
- }
- .editor-background {
- width: 100%;
- height: 100%;
- }
- .editor-background.fullscreen {
- position: fixed;
- inset: 0;
- z-index: 100;
- margin: 0;
- padding: 0;
- background: v-bind(vars.cardColor);
- }
- .editor.fullscreen {
- position: fixed;
- inset: 0;
- z-index: 1;
- margin: 0;
- padding: 0;
- }
- .editor.fullscreen:hover {
- border-color: rgba(0, 0, 0, 0) !important;
- }
- .editor-header {
- border-bottom: 1px solid #cccccc;
- box-sizing: border-box;
- }
- .editor-page {
- box-sizing: border-box;
- flex: 1 1 auto;
- overflow: auto;
- -webkit-overflow-scrolling: touch;
- padding: 40px;
- width: 100%;
- display: flex;
- justify-content: center;
- background: v-bind(vars.baseColor);
- position: relative;
- }
- .editor-body {
- position: relative;
- }
- .editor-body-flow {
- box-sizing: border-box;
- -webkit-overflow-scrolling: touch;
- flex: 1 1 auto;
- overflow: auto;
- padding: 40px 0;
- position: relative;
- }
- .editor-body-page {
- box-sizing: border-box;
- -webkit-overflow-scrolling: touch;
- padding: 40px 0;
- height: fit-content;
- min-height: 297mm;
- border-radius: 10px;
- box-shadow: v-bind(vars.boxShadow3);
- overflow: hidden;
- max-width: 210mm;
- width: 100%;
- }
- .editor-body::v-deep(.is-editor-empty) {
- height: 100%;
- }
- .editor-body::v-deep(.tiptap) {
- height: 100%;
- }
- .editor-body-page::v-deep(.is-editor-empty) {
- height: 100%;
- }
- .editor-body-page::v-deep(.tiptap) {
- height: 100%;
- min-height: inherit;
- padding-top: 40px;
- padding-bottom: 40px;
- }
- ::v-deep(.tippy-box) {
- background-color: transparent;
- }
- ::v-deep(.tippy-arrow) {
- color: transparent;
- }
- .editor.online {
- align-items: center;
- overflow: hidden;
- }
- .editor-readonly {
- pointer-events: none;
- opacity: 0.6;
- }
- </style>
- <style>
- .tiptap > * {
- margin-left: 100px !important;
- margin-right: 100px !important;
- }
- .tiptap > section {
- }
- .tiptap > section:hover::after {
- content: " ";
- position: absolute;
- inset: 0;
- box-sizing: border-box;
- z-index: 1;
- border: 1px dashed;
- color: #00bd63;
- pointer-events: none;
- }
- .tiptap > section:hover {
- position: relative;
- }
- /* Give a remote user a caret */
- .collaboration-cursor__caret {
- position: relative;
- margin-left: -1px;
- margin-right: -1px;
- border-left: 1px solid #0d0d0d;
- border-right: 1px solid #0d0d0d;
- word-break: normal;
- pointer-events: none;
- }
- /* Render the username above the caret */
- .collaboration-cursor__label {
- position: absolute;
- top: -1.4em;
- left: -1px;
- font-size: 12px;
- font-style: normal;
- font-weight: 600;
- line-height: normal;
- user-select: none;
- color: #0d0d0d;
- padding: 0.1rem 0.3rem;
- border-radius: 3px 3px 3px 0;
- white-space: nowrap;
- }
- </style>
|