UNPKG

@dialpad/dialtone

Version:

Dialpad's Dialtone design system monorepo

794 lines (793 loc) 26.2 kB
import { EditorContent, BubbleMenu, Editor } from "@tiptap/vue-3"; import { Extension } from "@tiptap/core"; import Blockquote from "@tiptap/extension-blockquote"; import CodeBlock from "@tiptap/extension-code-block"; import Code from "@tiptap/extension-code"; import Document from "@tiptap/extension-document"; import HardBreak from "@tiptap/extension-hard-break"; import Paragraph from "@tiptap/extension-paragraph"; import Placeholder from "@tiptap/extension-placeholder"; import Bold from "@tiptap/extension-bold"; import BulletList from "@tiptap/extension-bullet-list"; import Italic from "@tiptap/extension-italic"; import TipTapLink from "@tiptap/extension-link"; import ListItem from "@tiptap/extension-list-item"; import OrderedList from "@tiptap/extension-ordered-list"; import Strike from "@tiptap/extension-strike"; import Underline from "@tiptap/extension-underline"; import Text from "@tiptap/extension-text"; import TextAlign from "@tiptap/extension-text-align"; import History from "@tiptap/extension-history"; import { Emoji } from "./extensions/emoji/emoji.js"; import { CustomLink } from "./extensions/custom_link/custom_link.js"; import { ConfigurableImage } from "./extensions/image/image.js"; import { DivParagraph } from "./extensions/div/div.js"; import { MentionPlugin } from "./extensions/mentions/mention.js"; import { ChannelPlugin } from "./extensions/channels/channel.js"; import { SlashCommandPlugin } from "./extensions/slash_command/slash_command.js"; import { RICH_TEXT_EDITOR_AUTOFOCUS_TYPES, RICH_TEXT_EDITOR_OUTPUT_FORMATS, RICH_TEXT_EDITOR_SUPPORTED_LINK_PROTOCOLS } from "./rich_text_editor_constants.js"; import { emojiPattern } from "regex-combined-emojis"; import mentionSuggestion from "./extensions/mentions/suggestion.js"; import channelSuggestion from "./extensions/channels/suggestion.js"; import slashCommandSuggestion from "./extensions/slash_command/suggestion.js"; import { returnFirstEl, warnIfUnmounted } from "../../common/utils.js"; import deepEqual from "deep-equal"; import { resolveComponent, openBlock, createElementBlock, createBlock, withCtx, createElementVNode, createVNode, createTextVNode, createCommentVNode, mergeProps } from "vue"; import _export_sfc from "../../_virtual/_plugin-vue_export-helper.js"; import DtButton from "../button/button.vue.js"; import DtStack from "../stack/stack.vue.js"; const _sfc_main = { compatConfig: { MODE: 3 }, name: "DtRichTextEditor", components: { EditorContent, BubbleMenu, DtButton, DtStack }, props: { /** * Value of the input. The object format should match TipTap's JSON * document structure: https://tiptap.dev/guide/output#option-1-json */ modelValue: { type: [Object, String], default: "" }, /** * Whether the input is editable */ editable: { type: Boolean, default: true }, /** * Prevents the user from typing any further. Deleting text will still work. */ preventTyping: { type: Boolean, default: false }, /** * Whether the input allows for line breaks to be introduced in the text by pressing enter. If this is disabled, * line breaks can still be entered by pressing shift+enter. */ allowLineBreaks: { type: Boolean, default: false }, /** * Descriptive label for the input element */ inputAriaLabel: { type: String, required: true }, /** * Additional class name for the input element. Only accepts a String value * because this is passed to the editor via options. For multiple classes, * join them into one string, e.g. "d-p8 d-hmx96" */ inputClass: { type: String, default: "" }, /** * Whether the input should receive focus after the component has been * mounted. Either one of `start`, `end`, `all` or a Boolean or a Number. * - `start` Sets the focus to the beginning of the input * - `end` Sets the focus to the end of the input * - `all` Selects the whole contents of the input * - `Number` Sets the focus to a specific position in the input * - `true` Defaults to `start` * - `false` Disables autofocus * @values true, false, start, end, all, number */ autoFocus: { type: [Boolean, String, Number], default: false, validator(autoFocus) { if (typeof autoFocus === "string") { return RICH_TEXT_EDITOR_AUTOFOCUS_TYPES.includes(autoFocus); } return true; } }, /** * The output format that the editor uses when emitting the "@input" event. * One of `text`, `json`, `html`. See https://tiptap.dev/guide/output for * examples. * @values text, json, html */ outputFormat: { type: String, default: "html", validator(outputFormat) { return RICH_TEXT_EDITOR_OUTPUT_FORMATS.includes(outputFormat); } }, /** * Placeholder text */ placeholder: { type: String, default: "" }, /** * Enables the TipTap Link extension and optionally passes configurations to it * * It is not recommended to use this and the custom link extension at the same time. */ link: { type: [Boolean, Object], default: false }, /** * Enables the Custom Link extension and optionally passes configurations to it * * It is not recommended to use this and the built in TipTap link extension at the same time. * * The custom link does some additional things on top of the built in TipTap link * extension such as styling phone numbers and IP adresses as links, and allows you * to linkify text without having to type a space after the link. Currently it is missing some * functionality such as editing links and will likely require more work to be fully usable, * so it is recommended to use the built in TipTap link for now. */ customLink: { type: [Boolean, Object], default: false }, /** * suggestion object containing the items query function. * The valid keys passed into this object can be found here: https://tiptap.dev/api/utilities/suggestion * * The only required key is the items function which is used to query the contacts for suggestion. * items({ query }) => { return [ContactObject]; } * ContactObject format: * { name: string, avatarSrc: string, id: string } * * When null, it does not add the plugin. */ mentionSuggestion: { type: Object, default: null }, /** * suggestion object containing the items query function. * The valid keys passed into this object can be found here: https://tiptap.dev/api/utilities/suggestion * * The only required key is the items function which is used to query the channels for suggestion. * items({ query }) => { return [ChannelObject]; } * ChannelObject format: * { name: string, id: string, locked: boolean } * * When null, it does not add the plugin. Setting locked to true will display a lock rather than hash. */ channelSuggestion: { type: Object, default: null }, /** * suggestion object containing the items query function. * The valid keys passed into this object can be found here: https://tiptap.dev/api/utilities/suggestion * * The only required key is the items function which is used to query the slash commands for suggestion. * items({ query }) => { return [SlashCommandObject]; } * SlashCommandObject format: * { command: string, description: string, parametersExample?: string } * The "parametersExample" parameter is optional, and describes an example * of the parameters that command can take. * * When null, it does not add the plugin. * Note that slash commands only work when they are the first word in the input. */ slashCommandSuggestion: { type: Object, default: null }, /** * Whether the input allows for block quote. */ allowBlockquote: { type: Boolean, default: true }, /** * Whether the input allows for bold to be introduced in the text. */ allowBold: { type: Boolean, default: true }, /** * Whether the input allows for bullet list to be introduced in the text. */ allowBulletList: { type: Boolean, default: true }, /** * Whether the input allows for italic to be introduced in the text. */ allowItalic: { type: Boolean, default: true }, /** * Whether the input allows for strike to be introduced in the text. */ allowStrike: { type: Boolean, default: true }, /** * Whether the input allows for underline to be introduced in the text. */ allowUnderline: { type: Boolean, default: true }, /** * Whether the input allows inline code (wrapped in backticks). */ allowCode: { type: Boolean, default: true }, /** * Whether the input allows codeblock to be introduced in the text. */ allowCodeblock: { type: Boolean, default: true }, /** * Whether the input allows inline images to be rendered. */ allowInlineImages: { type: Boolean, default: false }, /** * Additional TipTap extensions to be added to the editor. */ additionalExtensions: { type: Array, default: () => [] }, /** * Manually hide the link bubble menu. The link bubble menu is shown when a link is selected via the cursor. * There are some cases when you may want the link to remain selected but hide the bubble menu such as when You * are showing a custom link editor popup. */ hideLinkBubbleMenu: { type: Boolean, default: false }, /** * Show text in HTML div tags instead of paragraph tags */ useDivTags: { type: Boolean, default: false } }, emits: [ /** * Editor input event * @event input * @type {String|JSON} */ "input", /** * Input event always in JSON format. * @event input * @type {JSON} */ "json-input", /** * Input event always in HTML format. * @event input * @type {HTML} */ "html-input", /** * Input event always in text format. * @event input * @type {String} */ "text-input", /** * Event to sync the value with the parent * @event update:value * @type {String|JSON} */ "update:modelValue", /** * Editor blur event * @event blur * @type {FocusEvent} */ "blur", /** * Editor focus event * @event focus * @type {FocusEvent} */ "focus", /** * Enter was pressed. Note that shift enter must be pressed to line break the input. * @event enter * @type {String} */ "enter", /** * "Edit link" button was clicked. Fires an event for the consuming component to handle the editing of the link. * event contains the link object with two properties href and text. * @event edit-link * @type {Object} */ "edit-link", /** * "Selected" event is fired when the user selects text in the editor. returns the currently selected text. * If the selected text is partially a link, the full link text is returned. * @event selected * @type {String} */ "selected" ], data() { return { editor: null, tippyOptions: { appendTo: () => { var _a; return (_a = returnFirstEl(this.$refs.editor.$el).getRootNode()) == null ? void 0 : _a.querySelector("body"); }, placement: "top-start" } }; }, computed: { attrs() { return { ...this.$attrs, onInput: () => { }, onFocus: () => { }, onBlur: () => { } }; }, // eslint-disable-next-line complexity extensions() { const extensions = [Document, Text, History, HardBreak]; extensions.push(this.useDivTags ? DivParagraph : Paragraph); if (this.allowBlockquote) { extensions.push(Blockquote); } if (this.allowBold) { extensions.push(Bold); } if (this.allowBulletList) { extensions.push(BulletList); extensions.push(ListItem.extend({ renderText({ node }) { return node.textContent; } })); extensions.push(OrderedList); } if (this.allowItalic) { extensions.push(Italic); } if (this.allowStrike) { extensions.push(Strike); } if (this.allowUnderline) { extensions.push(Underline); } if (this.placeholder) { extensions.push( Placeholder.configure({ placeholder: this.placeholder }) ); } const self = this; const ShiftEnter = Extension.create({ addKeyboardShortcuts() { return { "Shift-Enter": ({ editor }) => { if (self.allowLineBreaks) { return false; } editor.commands.first(({ commands }) => [ () => commands.newlineInCode(), () => self.allowBulletList && commands.splitListItem("listItem"), () => commands.createParagraphNear(), () => commands.liftEmptyBlock(), () => commands.splitBlock() ]); return true; }, Enter: () => { if (self.allowLineBreaks) { return false; } self.$emit("enter"); return true; } }; } }); extensions.push(ShiftEnter); if (this.link) { extensions.push(TipTapLink.extend({ inclusive: false }).configure({ HTMLAttributes: { class: "d-link d-wb-break-all" }, openOnClick: false, autolink: true, protocols: RICH_TEXT_EDITOR_SUPPORTED_LINK_PROTOCOLS })); } if (this.customLink) { extensions.push(this.getExtension(CustomLink, this.customLink)); } if (this.mentionSuggestion) { const suggestionObject = { ...this.mentionSuggestion, ...mentionSuggestion }; extensions.push(MentionPlugin.configure({ suggestion: suggestionObject })); } if (this.channelSuggestion) { const suggestionObject = { ...this.channelSuggestion, ...channelSuggestion }; extensions.push(ChannelPlugin.configure({ suggestion: suggestionObject })); } if (this.slashCommandSuggestion) { const suggestionObject = { ...this.slashCommandSuggestion, ...slashCommandSuggestion }; extensions.push(SlashCommandPlugin.configure({ suggestion: suggestionObject })); } extensions.push(Emoji); extensions.push(TextAlign.configure({ types: ["paragraph"], defaultAlignment: "left" })); if (this.allowCode) { extensions.push(Code); } if (this.allowCodeblock) { extensions.push(CodeBlock.extend({ renderText({ node }) { return `\`\`\` ${node.textContent} \`\`\``; } }).configure({ HTMLAttributes: { class: "d-rich-text-editor__code-block" } })); } if (this.allowInlineImages) { extensions.push(ConfigurableImage); } if (this.additionalExtensions.length) { extensions.push(...this.additionalExtensions); } return extensions; }, inputAttrs() { const attrs = { "aria-label": this.inputAriaLabel, "aria-multiline": true, role: "textbox" }; if (!this.editable) { attrs["aria-readonly"] = true; } return attrs; } }, /** * Because the Editor instance is initialized when mounted it does not get * updated props automatically, so the ones that can change after mount have * to be hooked up to the Editor's own API. */ watch: { editable(isEditable) { this.editor.setEditable(isEditable); this.updateEditorAttributes({ "aria-readonly": !isEditable }); }, inputClass(newClass) { this.updateEditorAttributes({ class: newClass }); }, inputAriaLabel(newLabel) { this.updateEditorAttributes({ "aria-label": newLabel }); }, extensions() { this.destroyEditor(); this.createEditor(); }, modelValue(newValue) { this.processValue(newValue); } }, created() { this.createEditor(); }, beforeUnmount() { this.destroyEditor(); }, mounted() { warnIfUnmounted(returnFirstEl(this.$el), this.$options.name); this.processValue(this.modelValue, false); }, methods: { createEditor() { this.editor = new Editor({ autofocus: this.autoFocus, content: this.modelValue, editable: this.editable, extensions: this.extensions, editorProps: { attributes: { ...this.inputAttrs, class: this.inputClass }, handlePaste: (_, event) => { if (!this.link && !this.customLink) { const regex = /^https?:\/\//; if (!(event == null ? void 0 : event.clipboardData)) { return false; } const pastedContent = event.clipboardData.getData("text"); if (!regex.test(pastedContent)) { return false; } if (!event.clipboardData.getData("text/html")) { return false; } this.editor.chain().focus().insertContent(pastedContent).run(); return true; } return false; }, // Moves the <br /> tags inside the previous closing tag to avoid // Prosemirror wrapping them within another </p> tag. transformPastedHTML(html) { return html.replace(/(<\/\w+>)((<br \/>)+)/g, "$2$3$1"); } } }); this.addEditorListeners(); }, bubbleMenuShouldShow({ editor, view, state, oldState, from, to }) { return editor.isActive("link"); }, /** * If the selection contains a link, return the existing link text. * Otherwise, use just the selected text. * @param editor the editor instance. */ getSelectedLinkText(editor) { var _a, _b, _c; const { view, state } = editor; const { from, to } = view.state.selection; const text = state.doc.textBetween(from, to, ""); const linkNode = this.editor.state.doc.nodeAt(from); if (linkNode && ((_c = (_b = (_a = linkNode.marks) == null ? void 0 : _a.at(0)) == null ? void 0 : _b.type) == null ? void 0 : _c.name) === "link") { return linkNode.textContent; } else { return text; } }, editLink() { const linkText = this.getSelectedLinkText(this.editor); const link = { href: this.editor.getAttributes("link").href, text: linkText }; this.$emit("edit-link", link); }, removeLink() { var _a, _b, _c, _d; (_d = (_c = (_b = (_a = this.editor) == null ? void 0 : _a.chain()) == null ? void 0 : _b.focus()) == null ? void 0 : _c.unsetLink()) == null ? void 0 : _d.run(); }, openLink() { var _a, _b; (_b = (_a = this.editor) == null ? void 0 : _a.chain()) == null ? void 0 : _b.focus(); const link = this.editor.getAttributes("link").href; window.open(link, "_blank"); }, // eslint-disable-next-line complexity setLink(linkInput, linkText, linkOptions, linkProtocols = RICH_TEXT_EDITOR_SUPPORTED_LINK_PROTOCOLS, defaultPrefix) { var _a, _b, _c; if (!linkInput) { this.removeLink(); return; } const prefix = linkProtocols.find((prefixRegex) => prefixRegex.test(linkInput)); if (!prefix) { linkInput = `${defaultPrefix}${linkInput}`; } this.editor.chain().focus().extendMarkRange("link").run(); const selection = (_c = (_b = (_a = this.editor) == null ? void 0 : _a.view) == null ? void 0 : _b.state) == null ? void 0 : _c.selection; this.editor.chain().focus().insertContent(linkText).setTextSelection({ from: selection.from, to: selection.from + linkText.length }).setLink({ href: linkInput, class: linkOptions.class }).run(); }, // eslint-disable-next-line complexity processValue(newValue, returnIfEqual = true) { const currentValue = this.getOutput(); if (returnIfEqual && deepEqual(newValue, currentValue)) { return; } if (typeof newValue === "string" && this.outputFormat === "text") { const inputUnicodeRegex = new RegExp(`(${emojiPattern})`, "g"); newValue = newValue == null ? void 0 : newValue.replace(inputUnicodeRegex, '<emoji-component code="$1"></emoji-component>'); } this.editor.commands.setContent(newValue, false); }, destroyEditor() { this.editor.destroy(); }, triggerInputChangeEvents() { const value = this.getOutput(); this.$emit("input", value); this.$emit("update:modelValue", value); const jsonValue = this.editor.getJSON(); this.$emit("json-input", jsonValue); const htmlValue = this.editor.getHTML(); this.$emit("html-input", htmlValue); const textValue = this.editor.getText({ blockSeparator: "\n" }); this.$emit("text-input", textValue); }, /** * The Editor exposes event hooks that we have to map our emits into. See * https://tiptap.dev/api/events for all events. */ addEditorListeners() { this.editor.on("create", () => { this.triggerInputChangeEvents(); }); this.editor.on("update", () => { var _a, _b; if (this.preventTyping && ((_b = (_a = this.editor.view) == null ? void 0 : _a.input) == null ? void 0 : _b.lastKeyCode) !== 8) { this.editor.commands.setContent(this.value, false); return; } this.triggerInputChangeEvents(); }); this.editor.on("selectionUpdate", ({ editor }) => { this.$emit("selected", this.getSelectedLinkText(editor)); }); this.editor.on("focus", ({ event }) => { this.$emit("focus", event); }); this.editor.on("blur", ({ event }) => { this.$emit("blur", event); }); }, getOutput() { switch (this.outputFormat) { case "json": return this.editor.getJSON(); case "html": return this.editor.getHTML(); case "text": default: return this.editor.getText({ blockSeparator: "\n" }); } }, getExtension(extension, options) { var _a; if (typeof options === "boolean") { return extension; } return (_a = extension.configure) == null ? void 0 : _a.call(extension, options); }, updateEditorAttributes(attributes) { this.editor.setOptions({ editorProps: { attributes: { ...this.inputAttrs, class: this.inputClass, ...attributes } } }); }, focusEditor() { this.editor.commands.focus(); } } }; const _hoisted_1 = { class: "d-popover__dialog" }; function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) { const _component_dt_button = resolveComponent("dt-button"); const _component_dt_stack = resolveComponent("dt-stack"); const _component_bubble_menu = resolveComponent("bubble-menu"); const _component_editor_content = resolveComponent("editor-content"); return openBlock(), createElementBlock("div", null, [ $data.editor && $props.link && !$props.hideLinkBubbleMenu ? (openBlock(), createBlock(_component_bubble_menu, { key: 0, editor: $data.editor, "should-show": $options.bubbleMenuShouldShow, "tippy-options": $data.tippyOptions, style: { "visibility": "visible" } }, { default: withCtx(() => [ createElementVNode("div", _hoisted_1, [ createVNode(_component_dt_stack, { direction: "row", class: "d-rich-text-editor-bubble-menu__button-stack", gap: "0" }, { default: withCtx(() => [ createVNode(_component_dt_button, { kind: "muted", importance: "clear", onClick: $options.editLink }, { default: withCtx(() => [ createTextVNode(" Edit ") ]), _: 1 }, 8, ["onClick"]), createVNode(_component_dt_button, { kind: "muted", importance: "clear", onClick: $options.openLink }, { default: withCtx(() => [ createTextVNode(" Open link ") ]), _: 1 }, 8, ["onClick"]), createVNode(_component_dt_button, { kind: "danger", importance: "clear", onClick: $options.removeLink }, { default: withCtx(() => [ createTextVNode(" Remove ") ]), _: 1 }, 8, ["onClick"]) ]), _: 1 }) ]) ]), _: 1 }, 8, ["editor", "should-show", "tippy-options"])) : createCommentVNode("", true), createVNode(_component_editor_content, mergeProps({ ref: "editor", editor: $data.editor, class: "d-rich-text-editor", "data-qa": "dt-rich-text-editor" }, $options.attrs), null, 16, ["editor"]) ]); } const DtRichTextEditor = /* @__PURE__ */ _export_sfc(_sfc_main, [["render", _sfc_render]]); export { DtRichTextEditor as default }; //# sourceMappingURL=rich_text_editor.vue.js.map