UNPKG

rhino-editor

Version:

A custom element wrapped rich text editor

844 lines (837 loc) 25.9 kB
import { AttachmentRemoveEvent } from "./chunk-YXKZT3DD.js"; import { findAttribute } from "./chunk-4PTK2Z3Z.js"; import { AttachmentManager, toDefaultCaption } from "./chunk-KSVK26OY.js"; import { LOADING_STATES } from "./chunk-KCKEIVYI.js"; import { captionPlaceholder, fileUploadErrorMessage } from "./chunk-MPMUJGV5.js"; // src/exports/extensions/attachment.ts import { mergeAttributes, Node } from "@tiptap/core"; // src/internal/selection-to-insertion-end.ts import { Selection } from "@tiptap/pm/state"; import { ReplaceAroundStep, ReplaceStep } from "@tiptap/pm/transform"; function selectionToInsertionEnd(tr, startLen, bias) { const last = tr.steps.length - 1; if (last < startLen) { return; } const step = tr.steps[last]; if (!(step instanceof ReplaceStep || step instanceof ReplaceAroundStep)) { return; } const map = tr.mapping.maps[last]; let end = 0; map.forEach((_from, _to, _newFrom, newTo) => { if (end === 0) { end = newTo; } }); tr.setSelection(Selection.near(tr.doc.resolve(end), bias)); } // src/exports/extensions/attachment.ts import { findChildrenByType, findParentNodeOfTypeClosestToPos } from "prosemirror-utils"; import { render, html } from "lit/html.js"; import { unsafeHTML } from "lit/directives/unsafe-html.js"; import { ifDefined } from "lit/directives/if-defined.js"; import { when } from "lit/directives/when.js"; import { NodeSelection, Plugin, PluginKey, TextSelection } from "@tiptap/pm/state"; import { DOMSerializer } from "@tiptap/pm/model"; var figureTypes = [ "previewable-attachment-figure", "attachment-figure" ]; function parseContentTypeFromElement(element) { return findAttribute(element, "content-type") || JSON.parse(element.getAttribute("data-trix-attachment") || "{}").contentType || "application/octet-stream"; } var canParseAttachment = (node, shouldPreview) => { if (node instanceof HTMLElement) { const contentType = parseContentTypeFromElement(node); if (contentType === "application/octet-stream") { return false; } const actionTextAttachment = node.closest("action-text-attachment"); if (actionTextAttachment) { const previewable2 = actionTextAttachment.getAttribute("previewable") === "true"; if (!actionTextAttachment.getAttribute("sgid")) { return false; } if (previewable2 === shouldPreview) { return true; } return false; } const previewable = canPreview( findAttribute(node, "previewable"), findAttribute(node, "contentType") ); if (previewable === shouldPreview) { return true; } } return false; }; function handleCaptions(node, tr, newState, pos) { let modified = false; if (figureTypes.includes(node.type.name) === false) return modified; let scratch = document.createElement("div"); scratch.appendChild( DOMSerializer.fromSchema(newState.schema).serializeNode(node) ); const figcaption = scratch.querySelector("figcaption"); if (figcaption == null) return modified; const caption = figcaption.innerHTML; if (node.attrs.caption !== caption) { tr.setNodeMarkup(pos, void 0, { ...node.attrs, caption }); modified = true; } return modified; } function canPreview(previewable, contentType) { return Boolean( previewable || AttachmentManager.isPreviewable(contentType || "") ); } function toExtension(fileName) { if (!fileName) return ""; return "attachment--" + fileName.match(/\.(\w+)$/)?.[1].toLowerCase(); } function toType(content, previewable) { if (previewable) { return "attachment--preview"; } if (content) { return "attachment--content"; } return "attachment--file"; } var Attachment = Node.create({ name: "attachment-figure", group: "block attachmentFigure", content: "inline*", selectable: true, draggable: true, isolating: true, defining: true, addProseMirrorPlugins() { return [ new Plugin({ key: new PluginKey("rhino-attachment-fixer"), appendTransaction(_transactions, _oldState, newState) { const tr = newState.tr; let modified = false; newState.doc.descendants((node, pos, _parent) => { const mutations = [handleCaptions(node, tr, newState, pos)]; const shouldModify = mutations.some((bool) => bool === true); if (shouldModify) { modified = true; } }); if (modified) return tr; return void 0; } }), new Plugin({ key: new PluginKey("rhino-prevent-unintended-figcaption-behavior"), props: { handlePaste: (view, event) => { const name = view.state.selection.$anchor.parent.type.name; const { clipboardData } = event; if (!clipboardData) return false; if (figureTypes.includes(name)) { event.preventDefault(); const tr = view.state.tr; const text = clipboardData.getData("text/plain"); tr.insertText(text); view.dispatch(tr); return true; } return false; }, handleKeyDown: (view, event) => { if (["Backspace", "Enter"].includes(event.key)) { const name = view.state.selection.$head.parent.type.name; const content = view.state.selection.$head.parent.textContent; if (view.state.selection.to !== view.state.selection.from) { return false; } if (figureTypes.includes(name) && content === "") { event.preventDefault(); return true; } } return false; } } }), new Plugin({ key: new PluginKey("rhino-attachment-remove-event"), view() { const afterSgidsAndAttachmentIds = /* @__PURE__ */ new Map(); return { update(view, prevState) { const nodeTypes = [ view.state.schema.nodes["previewable-attachment-figure"], view.state.schema.nodes["attachment-figure"] ]; nodeTypes.forEach((nodeType) => { const attachmentNodesBefore = findChildrenByType( prevState.doc, nodeType ); findChildrenByType(view.state.doc, nodeType).forEach((node) => { const nodeAttrs = node.node.attrs; const sgid = nodeAttrs.sgid; const attachmentId = nodeAttrs.attachmentId; if (sgid) { afterSgidsAndAttachmentIds.set(sgid, node); } if (attachmentId) { afterSgidsAndAttachmentIds.set(attachmentId, node); } }); attachmentNodesBefore.forEach((node) => { const nodeAttrs = node.node.attrs; const { sgid, attachmentId } = nodeAttrs; if (sgid && afterSgidsAndAttachmentIds.has(sgid)) return; if (attachmentId && afterSgidsAndAttachmentIds.has(attachmentId)) return; const attachmentManager = new AttachmentManager( nodeAttrs, view ); view.dom.dispatchEvent( new AttachmentRemoveEvent(attachmentManager) ); }); afterSgidsAndAttachmentIds.clear(); }); } }; } }) ]; }, addOptions() { return { HTMLAttributes: { class: "attachment" }, fileUploadErrorMessage, captionPlaceholder, previewable: false, altTextEditor: false, shouldStopEvent: (event) => { const composedPath = event.composedPath(); const isInAttachmentEditor = composedPath.find( (el) => el?.tagName?.toLowerCase() === "rhino-attachment-editor" ); if (isInAttachmentEditor) { return true; } return false; } }; }, parseHTML() { return [ // When it's <figure data-trix-attachment> its coming from `to_trix_html` { tag: "figure[data-trix-attachment]", getAttrs: (node) => { const isValid = canParseAttachment(node, this.options.previewable); if (!isValid) { return false; } return null; } }, // When it's .attachment, its coming from <action-text-attachment><figure></figure></action-text-attachment> its the raw HTML. { tag: "action-text-attachment:not([content]) > figure.attachment", contentElement: "figcaption", getAttrs: (node) => { const isValid = canParseAttachment(node, this.options.previewable); if (!isValid) { return false; } return null; } }, { tag: "action-text-attachment[content]", getAttrs: (node) => { const isValid = canParseAttachment(node, this.options.previewable); if (!isValid) { return false; } return null; } } ]; }, renderHTML({ node }) { const { // Figure content, contentType, sgid, fileName, fileSize, caption, url, previewable, // Image src, width, height, alt } = node.attrs; const attachmentAttrs = { caption, contentType, content, filename: fileName, filesize: fileSize, height, width, sgid, url, src, alt }; const figure = [ "figure", mergeAttributes(this.options.HTMLAttributes, { class: this.options.HTMLAttributes.class + " " + toType(content, canPreview(previewable, contentType)) + " " + toExtension(fileName), "data-trix-content-type": contentType, "data-trix-attachment": JSON.stringify(attachmentAttrs), "data-trix-attributes": JSON.stringify({ caption, ...canPreview(previewable, contentType) ? { presentation: "gallery" } : {} }) }) ]; const figcaption = [ "figcaption", mergeAttributes( {}, { class: "attachment__caption attachment__caption--edited" } ), 0 ]; const image = [ "img", mergeAttributes( {}, { src: url || src, contenteditable: false, width, height, alt } ) ]; if (!content && canPreview(previewable, contentType)) { return [...figure, image, figcaption]; } return [...figure, figcaption]; }, addAttributes() { return { attachmentId: { default: null }, altTextDialogOpen: { default: false }, caption: { default: null, parseHTML: (element) => { return element.querySelector("figcaption")?.innerHTML || findAttribute(element, "caption"); } }, progress: { default: 0, parseHTML: (element) => { return findAttribute(element, "sgid") || findAttribute(element, "content") || element.closest("action-text-attachment")?.innerHTML ? 100 : 0; } }, loadingState: { default: LOADING_STATES.notStarted, parseHTML: (element) => findAttribute(element, "sgid") ? LOADING_STATES.success : LOADING_STATES.notStarted }, alt: { default: "", parseHTML: (element) => { return findAttribute(element, "alt") || element.querySelector("img")?.getAttribute("alt") || ""; } }, sgid: { default: null, parseHTML: (element) => findAttribute(element, "sgid") }, src: { default: null, parseHTML: (element) => findAttribute(element, "src") }, height: { default: null, parseHTML: (element) => findAttribute(element, "height") }, width: { default: null, parseHTML: (element) => { return findAttribute(element, "width"); } }, contentType: { default: null, parseHTML: (element) => { return parseContentTypeFromElement(element); } }, fileName: { default: null, parseHTML: (element) => findAttribute(element, "filename") }, fileSize: { default: null, parseHTML: (element) => findAttribute(element, "filesize") }, content: { default: null, parseHTML: (element) => { const attachment = element.closest("action-text-attachment"); let content = ""; if (attachment) { const domParser = new DOMParser(); const parsedDom = domParser.parseFromString( attachment.innerHTML, "text/html" ); const firstChild = parsedDom.body.firstElementChild; if (firstChild) { if (firstChild.tagName.toLowerCase() !== "figure" || !firstChild.classList.contains("attachment")) { content = attachment.innerHTML; } } } return content || findAttribute(element, "content"); } }, url: { default: null, parseHTML: (element) => { return findAttribute(element, "url"); } }, previewable: { default: false, parseHTML: (element) => { let { previewable } = JSON.parse( element.getAttribute("data-trix-attachment") || "{}" ); if (previewable == null) { previewable = findAttribute(element, "previewable"); } return previewable; } } }; }, addNodeView() { return ({ node, getPos, editor }) => { const { content, contentType, sgid, fileName, progress, fileSize, url, src, width, height, caption, previewable, loadingState, alt, altTextDialogOpen } = node.attrs; const trixAttachment = JSON.stringify({ contentType, content, filename: fileName, filesize: fileSize, alt, height, width, sgid, url, caption }); const isPreviewable = canPreview(previewable, contentType); const trixAttributes = JSON.stringify({ ...isPreviewable ? { presentation: "gallery" } : {}, caption }); const figureClasses = ` ${this.options.HTMLAttributes.class} ${toType(content, canPreview(previewable, contentType))} ${toExtension(fileName)} `; function handleFigureClick(e) { const target = e.currentTarget; const figcaption = target.querySelector("figcaption"); const attachmentEditor = target.querySelector( "rhino-attachment-editor" ); const composedPath = e.composedPath(); if (figcaption && composedPath.includes(figcaption)) { return; } if (attachmentEditor && composedPath.includes(attachmentEditor)) { return; } if (typeof getPos === "function") { const { view } = editor; const { tr } = view.state; const pos = getPos(); if (pos == null) { return; } const captionNode = view.state.doc.nodeAt(pos + 1); captionNode?.nodeSize; tr.setSelection( TextSelection.create( view.state.doc, pos + 1 + (captionNode?.nodeSize || 0) ) ); view.dispatch(tr); } } let imgSrc = void 0; if (isPreviewable && (url || src)) { imgSrc = url || src; } let mouseIsDown = false; let mouseTimeout = null; const handleMouseDown = (_e) => { mouseTimeout = setTimeout(() => { mouseIsDown = true; }, 20); }; const handleMouseUp = (_e) => { mouseIsDown = false; if (mouseTimeout) { clearTimeout(mouseTimeout); } }; const handleMouseMove = (_e) => { if (mouseIsDown && typeof getPos === "function") { const pos = getPos(); if (pos == null) { return; } const { view } = editor; view.dispatch( view.state.tr.setSelection( NodeSelection.create(view.state.doc, pos) ) ); } }; function setNodeAttributes(attrs) { if (typeof getPos === "function") { const { view } = editor; const { tr } = view.state; const pos = getPos(); if (pos == null) { return; } tr.setNodeMarkup(pos, null, { ...node.attrs, ...attrs }); view.dispatch(tr); } } function removeFigure() { if (typeof getPos === "function") { const { view } = editor; const { tr } = view.state; const pos = getPos(); if (pos == null) { return; } tr.delete(pos, pos + 1); view.dispatch(tr); } const closestAttachment = this.closest(".attachment"); if (closestAttachment) { closestAttachment.remove(); } } const template = html` <figure class=${figureClasses} attachment-type=${this.name} sgid=${ifDefined(sgid ? sgid : void 0)} data-trix-content-type=${contentType} data-trix-attachment=${trixAttachment} data-trix-attributes=${trixAttributes} @click=${handleFigureClick} @mousedown=${handleMouseDown} @mouseup=${handleMouseUp} @mousemove=${handleMouseMove} > <rhino-attachment-editor file-name=${fileName || ""} file-size=${String(fileSize || 0)} loading-state=${loadingState || LOADING_STATES.notStarted} img-src=${ifDefined(imgSrc)} progress=${String( progress ? progress : sgid || content || !fileSize ? 100 : progress )} contenteditable="false" ?previewable=${isPreviewable} ?show-metadata=${isPreviewable} .fileUploadErrorMessage=${this.options.fileUploadErrorMessage} .removeFigure=${removeFigure} .setNodeAttributes=${setNodeAttributes} .altTextDialogOpen=${altTextDialogOpen} .altTextEditor=${Boolean(this.options.altTextEditor)} alt-text=${alt} > </rhino-attachment-editor> ${when( content, /* This is really not great. This is how Trix does it, but it feels very unsafe. https://github.com/basecamp/trix/blob/fda14c5ae88a0821cf8999a53dcb3572b4172cf0/src/trix/views/attachment_view.js#L36 */ () => html`${unsafeHTML(content)}`, () => html`` )} ${when( isPreviewable && !content, () => html` <img class=${loadingState === LOADING_STATES.error ? "rhino-upload-error" : ""} width=${String(width)} height=${String(height)} src=${ifDefined(imgSrc)} alt=${alt} contenteditable="false" />`, () => html`` )} <figcaption style="${Boolean(content) ? "display: none;" : ""}" class=${`attachment__caption ${caption ? "attachment__caption--edited" : "is-empty"}`} data-placeholder=${this.options.captionPlaceholder} data-default-caption=${toDefaultCaption({ fileName, fileSize })} ></figcaption> </figure> `; const scratch = document.createElement("div"); render(template, scratch); const dom = scratch.firstElementChild; const contentDOM = dom?.querySelector("figcaption"); let srcRevoked = false; const shouldStopEvent = this.options.shouldStopEvent; return { dom, contentDOM, stopEvent(event) { return shouldStopEvent(event); }, update(node2) { if (node2.type.name !== "attachment") return false; if (!srcRevoked && node2.attrs.url) { srcRevoked = true; try { URL.revokeObjectURL(node2.attrs.src); } catch (_e) { } } return false; } }; }; }, addCommands() { return { setAttachmentAtCoords: (options, coordinates) => ({ view, state, tr, dispatch }) => { let posAtCoords = view.posAtCoords(coordinates); const currentSelection = state.doc.resolve(posAtCoords?.pos || 0); return handleAttachment(options, currentSelection, { state, tr, dispatch }); }, setAttachment: (options) => ({ state, tr, dispatch }) => { const currentSelection = state.doc.resolve(state.selection.anchor); return handleAttachment(options, currentSelection, { state, tr, dispatch }); } }; } }); var PreviewableAttachment = Attachment.extend({ name: "previewable-attachment-figure", group: "block previewableAttachmentFigure", addOptions() { return { ...Attachment.options, previewable: true }; }, // We purposely override this to nothing. Because all of the extensions registered by Attachment // are global, they run twice. We don't want that. for example, this causes `rhino-attachment-remove` // to fire twice. No bueno. addProseMirrorPlugins() { return []; } }); function handleAttachment(options, currentSelection, { state, tr, dispatch }) { const { schema } = state; const minSize = 0; const maxSize = tr.doc.content.size; function clamp(val, min = minSize, max = maxSize) { if (val < min) return min; if (val > max) return max; return val; } const hasGalleriesDisabled = schema.nodes["attachment-gallery"] == null; const currentNode = state.doc.resolve(currentSelection.pos); const paragraphTopNode = findParentNodeOfTypeClosestToPos( currentNode, schema.nodes["paragraph"] ); let currentGallery = findParentNodeOfTypeClosestToPos( state.doc.resolve(currentSelection.pos), schema.nodes["attachment-gallery"] ); let priorGalleryPos = null; if (paragraphTopNode) { const paragraphIsEmpty = currentSelection.parent.textContent === ""; const prevNode = state.doc.resolve(clamp(paragraphTopNode.pos - 1)); if (paragraphIsEmpty && prevNode.parent.type.name === "attachment-gallery") { priorGalleryPos = clamp(paragraphTopNode.pos - 1); } } const isInGallery = currentGallery || priorGalleryPos; const attachments = Array.isArray(options) ? options : [].concat(options); let allNodesPreviewable = true; let attachmentNodes = []; let previewableNodes = []; attachments.forEach((attachment) => { const nodeType = attachment.isPreviewable ? "previewable-attachment-figure" : "attachment-figure"; const figure = schema.nodes[nodeType].create( attachment, attachment.caption ? [schema.text(attachment.caption)] : [] ); if (hasGalleriesDisabled) { attachmentNodes.push(figure); return; } if (!attachment.isPreviewable) { allNodesPreviewable = false; if (previewableNodes.length >= 1) { attachmentNodes = attachmentNodes.concat( schema.nodes["attachment-gallery"].create({}, previewableNodes) ); previewableNodes = []; } attachmentNodes.push(figure); return; } previewableNodes.push(figure); }); let end = 0; if (currentGallery) { end = currentGallery.start + currentGallery.node.nodeSize - 2; } else if (priorGalleryPos != null) { end = priorGalleryPos; } end = clamp(end); if (hasGalleriesDisabled) { attachmentNodes = attachmentNodes.flatMap((node) => [node]); tr.insert(end, attachmentNodes.concat([schema.nodes.paragraph.create()])); if (dispatch) dispatch(tr); return true; } if (isInGallery) { if (allNodesPreviewable) { tr.insert(end, previewableNodes); } else { if (!hasGalleriesDisabled && previewableNodes.length >= 1) { attachmentNodes = attachmentNodes.concat( schema.nodes["attachment-gallery"].create({}, previewableNodes) ); } tr.insert(end + 1, attachmentNodes); } } else { const currSelection = state.selection; if (!hasGalleriesDisabled && previewableNodes.length >= 1) { attachmentNodes = attachmentNodes.concat( schema.nodes["attachment-gallery"].create({}, previewableNodes) ); } let from = currSelection.from; const prevNode = state.doc.resolve(from - 1); const parentNode = prevNode.parent; if (parentNode && parentNode.type.name === "doc") { from -= 1; } else { const closestParagraph = findParentNodeOfTypeClosestToPos( prevNode, schema.nodes["paragraph"] ); if (closestParagraph && closestParagraph.node.textContent === "") { from -= 1; } } tr.replaceWith(from, currSelection.to, [ ...attachmentNodes, schema.nodes.paragraph.create() ]); selectionToInsertionEnd(tr, tr.steps.length - 1, -1); } if (dispatch) dispatch(tr); return true; } export { figureTypes, Attachment, PreviewableAttachment }; //# sourceMappingURL=chunk-U7UPTND5.js.map