rhino-editor
Version:
A custom element wrapped rich text editor
845 lines (842 loc) • 26 kB
JavaScript
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