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