@dialpad/dialtone
Version:
Dialpad's Dialtone design system monorepo
735 lines (734 loc) • 25 kB
JavaScript
"use strict";
Object.defineProperties(exports, { __esModule: { value: true }, [Symbol.toStringTag]: { value: "Module" } });
const vue2 = require("@tiptap/vue-2");
const core = require("@tiptap/core");
const Blockquote = require("@tiptap/extension-blockquote");
const CodeBlock = require("@tiptap/extension-code-block");
const Code = require("@tiptap/extension-code");
const Document = require("@tiptap/extension-document");
const Paragraph = require("@tiptap/extension-paragraph");
const Placeholder = require("@tiptap/extension-placeholder");
const HardBreak = require("@tiptap/extension-hard-break");
const Bold = require("@tiptap/extension-bold");
const BulletList = require("@tiptap/extension-bullet-list");
const Italic = require("@tiptap/extension-italic");
const TipTapLink = require("@tiptap/extension-link");
const ListItem = require("@tiptap/extension-list-item");
const OrderedList = require("@tiptap/extension-ordered-list");
const Strike = require("@tiptap/extension-strike");
const Underline = require("@tiptap/extension-underline");
const Text = require("@tiptap/extension-text");
const TextAlign = require("@tiptap/extension-text-align");
const History = require("@tiptap/extension-history");
const emoji = require("./extensions/emoji/emoji.cjs");
const custom_link = require("./extensions/custom_link/custom_link.cjs");
const image = require("./extensions/image/image.cjs");
const div = require("./extensions/div/div.cjs");
const mention = require("./extensions/mentions/mention.cjs");
const channel = require("./extensions/channels/channel.cjs");
const slash_command = require("./extensions/slash_command/slash_command.cjs");
const rich_text_editor_constants = require("./rich_text_editor_constants.cjs");
const regexCombinedEmojis = require("regex-combined-emojis");
const suggestion = require("./extensions/mentions/suggestion.cjs");
const suggestion$1 = require("./extensions/channels/suggestion.cjs");
const suggestion$2 = require("./extensions/slash_command/suggestion.cjs");
const common_utils = require("../../common/utils.cjs");
const deepEqual = require("deep-equal");
const _pluginVue2_normalizer = require("../../_virtual/_plugin-vue2_normalizer.cjs");
const button = require("../button/button.vue.cjs");
const stack = require("../stack/stack.vue.cjs");
const _sfc_main = {
name: "DtRichTextEditor",
components: {
EditorContent: vue2.EditorContent,
BubbleMenu: vue2.BubbleMenu,
DtButton: button.default,
DtStack: stack.default
},
props: {
/**
* Value of the input. The object format should match TipTap's JSON
* document structure: https://tiptap.dev/guide/output#option-1-json
*/
value: {
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_constants.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_constants.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:value",
/**
* 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 = this.$refs.editor.$el.getRootNode()) == null ? void 0 : _a.querySelector("body");
},
placement: "top-start"
}
};
},
computed: {
editorListeners() {
return {
...this.$listeners,
input: () => {
},
focus: () => {
},
blur: () => {
}
};
},
// eslint-disable-next-line complexity
extensions() {
const extensions = [Document, Text, History, HardBreak];
extensions.push(this.useDivTags ? div.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 = core.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_constants.RICH_TEXT_EDITOR_SUPPORTED_LINK_PROTOCOLS
}));
}
if (this.customLink) {
extensions.push(this.getExtension(custom_link.CustomLink, this.customLink));
}
if (this.mentionSuggestion) {
const suggestionObject = { ...this.mentionSuggestion, ...suggestion.default };
extensions.push(mention.MentionPlugin.configure({ suggestion: suggestionObject }));
}
if (this.channelSuggestion) {
const suggestionObject = { ...this.channelSuggestion, ...suggestion$1.default };
extensions.push(channel.ChannelPlugin.configure({ suggestion: suggestionObject }));
}
if (this.slashCommandSuggestion) {
const suggestionObject = { ...this.slashCommandSuggestion, ...suggestion$2.default };
extensions.push(slash_command.SlashCommandPlugin.configure({ suggestion: suggestionObject }));
}
extensions.push(emoji.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(image.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();
},
value(newValue) {
this.processValue(newValue);
}
},
created() {
this.createEditor();
},
beforeUnmount() {
this.destroyEditor();
},
mounted() {
common_utils.warnIfUnmounted(this.$el, this.$options.name);
this.processValue(this.value, false);
},
methods: {
createEditor() {
this.editor = new vue2.Editor({
autofocus: this.autoFocus,
content: this.value,
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_constants.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(`(${regexCombinedEmojis.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:value", 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();
}
}
};
var _sfc_render = function render() {
var _vm = this, _c = _vm._self._c;
return _c("div", [_vm.editor && _vm.link && !_vm.hideLinkBubbleMenu ? _c("bubble-menu", { staticStyle: { "visibility": "visible" }, attrs: { "editor": _vm.editor, "should-show": _vm.bubbleMenuShouldShow, "tippy-options": _vm.tippyOptions } }, [_c("div", { staticClass: "d-popover__dialog" }, [_c("dt-stack", { staticClass: "d-rich-text-editor-bubble-menu__button-stack", attrs: { "direction": "row", "gap": "0" } }, [_c("dt-button", { attrs: { "kind": "muted", "importance": "clear" }, on: { "click": _vm.editLink } }, [_vm._v(" Edit ")]), _c("dt-button", { attrs: { "kind": "muted", "importance": "clear" }, on: { "click": _vm.openLink } }, [_vm._v(" Open link ")]), _c("dt-button", { attrs: { "kind": "danger", "importance": "clear" }, on: { "click": _vm.removeLink } }, [_vm._v(" Remove ")])], 1)], 1)]) : _vm._e(), _c("editor-content", _vm._g({ ref: "editor", staticClass: "d-rich-text-editor", attrs: { "editor": _vm.editor, "data-qa": "dt-rich-text-editor" } }, _vm.editorListeners))], 1);
};
var _sfc_staticRenderFns = [];
var __component__ = /* @__PURE__ */ _pluginVue2_normalizer.default(
_sfc_main,
_sfc_render,
_sfc_staticRenderFns
);
const DtRichTextEditor = __component__.exports;
exports.default = DtRichTextEditor;
//# sourceMappingURL=rich_text_editor.vue.cjs.map