UNPKG

rhino-editor

Version:

A custom element wrapped rich text editor

845 lines (842 loc) 26 kB
import { RhinoChangeEvent } from "./chunk-ZIALNMQA.js"; import { RhinoBlurEvent } from "./chunk-JXW4QBHT.js"; import { InitializeEvent } from "./chunk-VKKS3DJ7.js"; import { FileAcceptEvent } from "./chunk-EVXZQBVO.js"; import { BeforeInitializeEvent } from "./chunk-V3GVM5MU.js"; import { AddAttachmentEvent } from "./chunk-4GVZRMD6.js"; import { SelectionChangeEvent } from "./chunk-4KYHEDOE.js"; import { RhinoFocusEvent } from "./chunk-SKV74KKH.js"; import { editor_default } from "./chunk-Y7ECDMDC.js"; import { RhinoStarterKit } from "./chunk-Q63JC3JC.js"; import { AttachmentRemoveEvent } from "./chunk-YXKZT3DD.js"; import { AttachmentManager } from "./chunk-KSVK26OY.js"; import { AttachmentUpload, AttachmentUploadCompleteEvent, AttachmentUploadStartEvent } from "./chunk-SIPZ6BV2.js"; import { AttachmentEditor, BaseElement } from "./chunk-KCKEIVYI.js"; import { normalize } from "./chunk-PA75CBW2.js"; import { tipTapCoreStyles } from "./chunk-4HL6XB2M.js"; // src/exports/elements/tip-tap-editor-base.ts import { Editor } from "@tiptap/core"; import StarterKit from "@tiptap/starter-kit"; import { html } from "lit"; import { DOMSerializer } from "@tiptap/pm/model"; var NON_BREAKING_SPACE = "\xA0"; var TipTapEditorBase = class extends BaseElement { constructor() { super(); // Instance /** * Whether or not the editor should be editable. * * NOTE: a user can change this in the browser dev tools, don't rely on this to prevent * users from editing and attempting to save the document. */ this.readonly = false; /** * Prevents premature rebuilds. */ this.hasInitialized = false; /** * An array of "AttachmentUploads" added via direct upload. Check this for any attachment uploads that have not completed. */ this.pendingAttachments = []; /** * JSON or HTML serializer used for determining the string to write to the hidden input. */ this.serializer = "html"; /** Comma separated string passed to the attach-files input. */ this.accept = "*"; this.starterKitOptions = { // We don't use the native strike since it requires configuring ActionText. strike: false, link: false, rhinoLink: { openOnClick: false } }; /** * This will be concatenated onto RhinoStarterKit and StarterKit extensions. */ this.extensions = []; /** * When the `defer-initialize` attribute is present, it will wait to start the TipTap editor until the attribute has been removed. */ this.deferInitialize = false; /** * @internal */ this.__initialAttributes = {}; /** * @internal */ this.__hasRendered = false; this.__initializationPromise__ = null; this.__initializationResolver__ = null; /** * Used for determining how to handle uploads. * Override this for substituting your own * direct upload functionality. */ this.handleAttachment = (event) => { setTimeout(() => { if (event.defaultPrevented) { return; } const { attachment, target } = event; if (target instanceof HTMLElement && attachment.file) { const upload = new AttachmentUpload(attachment, target); upload.start(); } }); }; /** Override this to prevent specific file types from being uploaded. */ this.handleFileAccept = (_event) => { }; this.handleDropFile = (_view, event, _slice, moved) => { if (!(event instanceof DragEvent)) return false; if (moved) return false; return this.handleNativeDrop(event); }; this.handlePaste = async (event) => { if (this.editor == null) return; if (event == null) return; const { clipboardData } = event; if (clipboardData == null) return; let string = false; const hasFiles = clipboardData.files?.length > 0; let attachments = []; if (hasFiles) { attachments = await this.handleFiles(clipboardData.files); } else { let dataType = "text/plain"; if (clipboardData.types.includes("text/html")) { dataType = "text/html"; } string = clipboardData.getData(dataType); } setTimeout(async () => { if (event.defaultPrevented || event.originalPasteEvent.defaultPrevented) { return; } if (string !== false) { this.editor?.chain().focus().insertContent(string).run(); return; } if (attachments.length > 0) { this.editor?.chain().focus().setAttachment(attachments).run(); return; } }); }; this.__handleCreate = () => { this.requestUpdate(); }; this.__handleUpdate = () => { this.requestUpdate(); if (!this.hasInitialized) { return; } this.updateInputElementValue(); this.dispatchEvent(new RhinoChangeEvent()); }; this.__handleFocus = () => { this.dispatchEvent(new RhinoFocusEvent()); this.requestUpdate(); }; this.__handleBlur = () => { this.updateInputElementValue(); this.requestUpdate(); this.dispatchEvent(new RhinoBlurEvent()); }; this.__handleSelectionUpdate = ({ transaction }) => { this.requestUpdate(); this.dispatchEvent(new SelectionChangeEvent({ transaction })); }; this.__handleTransaction = () => { this.requestUpdate(); }; this.__addPendingAttachment = this.__addPendingAttachment.bind(this); this.__removePendingAttachment = this.__removePendingAttachment.bind(this); this.registerDependencies(); this.addEventListener(AddAttachmentEvent.eventName, this.handleAttachment); this.addEventListener( AttachmentUploadStartEvent.eventName, this.__addPendingAttachment ); this.addEventListener( AttachmentUploadCompleteEvent.eventName, this.__removePendingAttachment ); this.addEventListener( AttachmentRemoveEvent.eventName, this.__removePendingAttachment ); this.addEventListener("drop", this.handleNativeDrop); this.addEventListener("rhino-paste", this.handlePaste); this.addEventListener("rhino-file-accept", this.handleFileAccept); } static get styles() { return [normalize, tipTapCoreStyles, editor_default]; } static get properties() { return { // Attributes readonly: { type: Boolean, reflect: true }, input: { reflect: true }, class: { reflect: true }, accept: { reflect: true }, serializer: { reflect: true }, deferInitialize: { type: Boolean, attribute: "defer-initialize", reflect: true }, // Properties editor: { state: true }, editorElement: { state: true }, starterKitOptions: { state: true }, extensions: { state: true } }; } __getInitialAttributes() { if (this.__hasRendered) return; const slottedEditor = this.slottedEditor; if (slottedEditor) { this.__initialAttributes = {}; [...slottedEditor.attributes].forEach((attr) => { const { nodeName, nodeValue } = attr; if (nodeName && nodeValue != null) { this.__initialAttributes[nodeName] = nodeValue; } }); } this.__hasRendered = true; } /** * Reset mechanism. This is called on first connect, and called anytime extensions, * or editor options get modified to make sure we have a fresh instance. */ rebuildEditor() { if (!this.hasInitialized) return; const editors = this.querySelectorAll("[slot='editor']"); this.__getInitialAttributes(); if (this.editor) { this.editor.destroy(); } editors.forEach((el) => { el.editor?.destroy(); el.remove(); }); this.editor = this.__setupEditor(this); this.__bindEditorListeners(); this.editorElement = this.querySelector(".ProseMirror"); Object.entries(this.__initialAttributes)?.forEach( ([attrName, attrValue]) => { if (attrName === "class") { this.editorElement?.classList.add(...attrValue.split(" ")); return; } this.editorElement?.setAttribute(attrName, attrValue); } ); this.editorElement?.setAttribute("slot", "editor"); this.editorElement?.classList.add("trix-content"); this.editorElement?.setAttribute("tabindex", "0"); this.editorElement?.setAttribute("role", "textbox"); this.requestUpdate(); } /** * Grabs HTML content based on a given range. If no range is given, it will return the contents * of the current editor selection. If the current selection is empty, it will return an empty string. * @param from - The start of the selection * @param to - The end of the selection * @example Getting the HTML content of the current selection * const rhinoEditor = document.querySelector("rhino-editor") * rhinoEditor.getHTMLContentFromRange() * * @example Getting the HTML content of node range * const rhinoEditor = document.querySelector("rhino-editor") * rhinoEditor.getHTMLContentFromRange(0, 50) * * @example Getting the HTML content and falling back to entire editor HTML * const rhinoEditor = document.querySelector("rhino-editor") * let html = rhinoEditor.getHTMLContentFromRange() * if (!html) { * html = rhinoEditor.getHTML() * } */ getHTMLContentFromRange(from, to) { const editor = this.editor; if (!editor) return ""; let empty; if (!from && !to) { const currentSelection = editor.state.selection; from = currentSelection.from; to = currentSelection.to; } if (empty) { return ""; } if (from == null) { return ""; } if (to == null) { return ""; } const tempScript = document.createElement("div"); const contentSlice = editor.view.state.doc.slice(from, to); const fragment = contentSlice.content; const domFragment = DOMSerializer.fromSchema( editor.schema ).serializeFragment(fragment); tempScript.append(domFragment); tempScript.querySelectorAll(":scope > p").forEach((p) => { preserveSignificantWhiteSpaceForElement(p); }); return tempScript.innerHTML; } /** * Grabs plain text representation based on a given range. If no parameters are given, it will return the contents * of the current selection. If the current selection is empty, it will return an empty string. * @param from - The start of the selection * @param to - The end of the selection * @example Getting the Text content of the current selection * const rhinoEditor = document.querySelector("rhino-editor") * rhinoEditor.getTextContentFromRange() * * @example Getting the Text content of node range * const rhinoEditor = document.querySelector("rhino-editor") * rhinoEditor.getTextContentFromRange(0, 50) * * @example Getting the Text content and falling back to entire editor Text * const rhinoEditor = document.querySelector("rhino-editor") * let text = rhinoEditor.getTextContentFromRange() * if (!text) { * text = rhinoEditor.editor.getText() * } */ getTextContentFromRange(from, to) { const editor = this.editor; if (!editor) { return ""; } let empty; if (!from && !to) { const selection = editor.state.selection; from = selection.from; to = selection.to; empty = selection.empty; } if (empty) { return ""; } if (from == null) { return ""; } if (to == null) { return ""; } return editor.state.doc.textBetween(from, to, " "); } willUpdate(changedProperties) { if (changedProperties.has("deferInitialize") && !this.deferInitialize) { this.startEditor(); } if (changedProperties.has("class")) { this.classList.add("rhino-editor"); } super.willUpdate(changedProperties); } updated(changedProperties) { if (!this.hasInitialized) { return super.updated(changedProperties); } if (changedProperties.has("readonly")) { this.editor?.setEditable(!this.readonly); } if (changedProperties.has("extensions") || changedProperties.has("serializer") || changedProperties.has("starterKitOptions") || changedProperties.has("translations")) { this.rebuildEditor(); } if (changedProperties.has("serializer")) { this.updateInputElementValue(); } super.updated(changedProperties); this.dispatchEvent( new Event("rhino-update", { bubbles: true, composed: true, cancelable: false }) ); } /** Used for registering things like <role-toolbar>, <role-tooltip>, <rhino-attachment-editor> */ registerDependencies() { [AttachmentEditor].forEach((el) => el.define()); } get slottedEditor() { return this.querySelector("[slot='editor']"); } /** * @private */ __addPendingAttachment(e) { this.pendingAttachments.push(e.attachmentUpload); } /** * @private */ __removePendingAttachment(e) { const index = this.pendingAttachments.findIndex((attachment) => { if ("attachmentUpload" in e) { return attachment === e.attachmentUpload; } if ("attachment" in e) { return attachment.attachment.attachmentId === e.attachment.attachmentId; } return false; }); if (index > -1) { this.pendingAttachments.splice(index, 1); } } async connectedCallback() { super.connectedCallback(); this.__setupInitialization__(); if (this.editor) { this.__unBindEditorListeners(); } this.classList.add("rhino-editor"); if (!this.deferInitialize) { this.startEditor(); } } async startEditor() { await this.updateComplete; setTimeout(() => { this.dispatchEvent(new BeforeInitializeEvent()); setTimeout(async () => { await this.updateComplete; this.hasInitialized = true; this.rebuildEditor(); this.dispatchEvent(new InitializeEvent()); this.__initializationResolver__?.(); await this.__initializationPromise__; this.updateInputElementValue(); }); }); } disconnectedCallback() { super.disconnectedCallback(); this.editor?.destroy(); this.hasInitialized = false; this.__initializationPromise__ = null; this.__initializationResolver__ = null; } __setupInitialization__() { if (!this.__initializationPromise__) { this.__initializationPromise__ = new Promise((resolve) => { this.__initializationResolver__ = resolve; }); } } get initializationComplete() { this.__setupInitialization__(); return this.__initializationPromise__; } addExtensions(...extensions) { let ary = []; extensions.forEach((ext) => { if (Array.isArray(ext)) { ary = ary.concat(ext.flat(1)); return; } ary.push(ext); }); const existingExtensions = this.extensions.map((ext) => ext.name); ary = ary.filter((ext) => { return !existingExtensions.includes(ext.name); }); this.extensions = this.extensions.concat(ary); } disableStarterKitOptions(...options) { const disabledStarterKitOptions = {}; options.forEach((ext) => { if (Array.isArray(ext)) { ext.flat(1).forEach((str) => disabledStarterKitOptions[str] = false); return; } disabledStarterKitOptions[ext] = false; }); this.starterKitOptions = { ...this.starterKitOptions, ...disabledStarterKitOptions }; } /** * Extend this to provide your own options, or override existing options. * The "element" is where the editor will be initialized. * This will be merged * @example * class ExtendedRhinoEditor extends TipTapEditor { * editorOptions (_element: Element) { * return { * autofocus: true * } * } * } * */ editorOptions(_element) { return {}; } /** * Finds the <input> element in the light dom and updates it with the value of `#serialize()` */ updateInputElementValue() { if (this.inputElement != null && this.editor != null && !this.readonly) { this.inputElement.value = this.serialize(); } } /** * Function called when grabbing the content of the editor. Currently supports JSON or HTML. */ serialize() { const editor = this.editor; if (editor == null) return ""; if (this.serializer?.toLowerCase() === "json") { return JSON.stringify(editor.getJSON()); } return this.getHTMLAndPreserveSignificantWhiteSpace(); } // compareAttributes (el1: Element, el2: Element) { // function toObject (el: Element) { // const obj = {} as Record<string, any>; // ;[...el.attributes].forEach((attr) => { // let val = attr.value // let name = attr.name // if (val.startsWith("{")) { // try { // val = JSON.parse(val) // } catch (_e) {} // } // obj[name] = val // }) // return obj // } // const obj1 = toObject(el1) // const obj2 = toObject(el2) // console.log({ obj1, obj2 }) // } /** * @override * Apparently this is a native dom method? */ getHTML() { const editor = this.editor; if (!editor) { return ""; } return this.getHTMLAndPreserveSignificantWhiteSpace(); } /** * Searches for the <input> element in the light dom to write the HTML or JSON to. */ get inputElement() { if (!this.input) return void 0; const rootNode = this.getRootNode() || document; const el = rootNode.querySelector( `#${this.input}` ); return el; } async handleFiles(files) { if (this.editor == null) return []; if (files == null) return []; return new Promise((resolve, _reject) => { const fileAcceptEvents = [...files].map((file) => { const event = new FileAcceptEvent(file); this.dispatchEvent(event); return event; }); const allowedFiles = []; for (let i = 0; i < fileAcceptEvents.length; i++) { const event = fileAcceptEvents[i]; if (event.defaultPrevented) { continue; } allowedFiles.push(event.file); } const attachments = this.transformFilesToAttachments(allowedFiles); if (attachments == null || attachments.length <= 0) return; attachments.forEach((attachment) => { this.dispatchEvent(new AddAttachmentEvent(attachment)); }); resolve(attachments); }); } /** * Handles dropped files on the component, but not on the prosemirror instance. */ handleNativeDrop(event) { if (this.editor == null) return false; if (event == null) return false; const { dataTransfer } = event; if (dataTransfer == null) return false; if (dataTransfer.files.length <= 0) return false; if (event.defaultPrevented) return false; event.preventDefault(); this.handleFiles(dataTransfer.files).then((attachments) => { this.editor?.chain().focus().setAttachmentAtCoords(attachments, { top: event.clientY, left: event.clientX }).run(); }); return true; } transformFilesToAttachments(files) { if (this.editor == null) return; if (files == null || files.length === 0) return; const attachments = []; for (let i = 0; i < files.length; i++) { const file = files[i]; if (file == null) return; const src = URL.createObjectURL(file); const attachment = new AttachmentManager( { src, file }, this.editor.view ); attachments.push(attachment); } return attachments; } renderToolbar() { return html``; } renderDialog() { return html``; } render() { return html` ${this.renderToolbar()} <div class="editor-wrapper" part="editor-wrapper"> ${this.renderDialog()} <div class="editor" part="editor"> <slot name="editor"> <div class="trix-content"></div> </slot> </div> </div> `; } allOptions(element) { return { ...this.__defaultOptions(element), ...this.editorOptions(element) }; } /** * Due to some inconsistencies in how Trix will render the inputElement based on if its * the HTML representation, or transfromed with `#to_trix_html` this gives * us a consistent DOM structure to parse for rich text comments. */ normalizeDOM(inputElement, parser = new DOMParser()) { if (inputElement == null || inputElement.value == null) return; const doc = parser.parseFromString(inputElement.value, "text/html"); const figures = [...doc.querySelectorAll("figure[data-trix-attachment]")]; const filtersWithoutChildren = figures.filter( (figure) => figure.querySelector("figcaption") == null ); doc.querySelectorAll("div > figure:first-child").forEach((el) => { el.parentElement?.classList.add("attachment-gallery"); }); filtersWithoutChildren.forEach((figure) => { const attrs = figure.getAttribute("data-trix-attributes"); if (!attrs) return; const { caption } = JSON.parse(attrs); if (caption) { figure.insertAdjacentHTML( "beforeend", `<figcaption class="attachment__caption">${caption}</figcaption>` ); return; } }); doc.querySelectorAll( "figure :not(.attachment__caption--edited) .attachment__name" ).forEach((el) => { if (el.textContent?.includes(" \xB7 ") === false) return; el.insertAdjacentText("beforeend", " \xB7 "); }); doc.querySelectorAll(":scope > p > br:not(.ProseMirror-trailingBreak)").forEach((el) => el.remove()); const body = doc.querySelector("body"); if (body) { inputElement.value = body.innerHTML; } } /** * @private * Use a getter here so when we rebuild the editor it pulls the latest starterKitOptions * This is intentionally not to be configured by a user. It makes updating extensions hard. * it also is a getter and not a variable so that it will rerun in case options change. */ get __starterKitExtensions__() { return [ StarterKit.configure(this.starterKitOptions), RhinoStarterKit.configure(this.starterKitOptions) ]; } /** * @param {Element} element - The element that the editor will be installed onto. */ __defaultOptions(element) { let content = this.inputElement?.value || ""; if (content) { try { content = JSON.parse(content); } catch (e) { } } const extensions = this.__starterKitExtensions__.concat(this.extensions); return { injectCSS: false, extensions, autofocus: false, element, content, editable: !this.readonly, editorProps: { handleDrop: this.handleDropFile } }; } __bindEditorListeners() { if (this.editor == null) return; this.editor.on("focus", this.__handleFocus); this.editor.on("create", this.__handleCreate); this.editor.on("update", this.__handleUpdate); this.editor.on("selectionUpdate", this.__handleSelectionUpdate); this.editor.on("transaction", this.__handleTransaction); this.editor.on("blur", this.__handleBlur); } __unBindEditorListeners() { if (this.editor == null) return; this.editor.off("focus", this.__handleFocus); this.editor.off("create", this.__handleCreate); this.editor.off("update", this.__handleUpdate); this.editor.off("selectionUpdate", this.__handleSelectionUpdate); this.editor.off("transaction", this.__handleTransaction); this.editor.off("blur", this.__handleBlur); } __setupEditor(element = this) { if (!this.serializer || this.serializer === "html") { this.normalizeDOM(this.inputElement); } const editor = new Editor(this.allOptions(element)); return editor; } getHTMLAndPreserveSignificantWhiteSpace() { const editor = this.editor; if (!editor) { return ""; } const tempScript = document.createElement("div"); const doc = editor.view.state.doc; const schema = editor.schema; const domFragment = DOMSerializer.fromSchema(schema).serializeFragment( doc.content ); tempScript.append(domFragment); tempScript.querySelectorAll(":scope > p").forEach((p) => { preserveSignificantWhiteSpaceForElement(p); }); return tempScript.innerHTML; } }; // Static /** * Default registration name */ TipTapEditorBase.baseName = "rhino-editor"; function replaceSignificantWhitespace(text, isFirst, isLast) { if (isLast) { text = text.replace(/\ $/, NON_BREAKING_SPACE); } text = text.replace(/(\S)\ {3}(\S)/g, "$1 " + NON_BREAKING_SPACE + " $2").replace(/\ {2}/g, NON_BREAKING_SPACE + " ").replace(/\ {2}/g, " " + NON_BREAKING_SPACE); if (isFirst) { text = text.replace(/^\ /, NON_BREAKING_SPACE); } return text; } function preserveSignificantWhiteSpaceForElement(node) { if (node.textContent?.trim() === "" && !node.querySelector("br")) { node.innerHTML = "<br>" + node.innerHTML; return; } const textNodes = getAllTextNodes(node); textNodes.forEach((textNode, index) => { const isFirst = index === 0; const isLast = index === textNodes.length - 1; if (textNode.textContent) { const text = replaceSignificantWhitespace( textNode.textContent, isFirst, isLast ); textNode.textContent = text; } }); } function getAllTextNodes(element) { const textNodes = []; const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null); let node; while (node = walker.nextNode()) { textNodes.push(node); } return textNodes; } export { TipTapEditorBase }; //# sourceMappingURL=chunk-SCU63L2Z.js.map