Initial
This commit is contained in:
139
resources/app/common/prosemirror/schema/attribute-capture.mjs
Normal file
139
resources/app/common/prosemirror/schema/attribute-capture.mjs
Normal file
@@ -0,0 +1,139 @@
|
||||
import {ALLOWED_HTML_ATTRIBUTES} from "../../constants.mjs";
|
||||
import {getType, mergeObject} from "../../utils/helpers.mjs";
|
||||
import {classesFromString, mergeClass, mergeStyle, stylesFromString} from "./utils.mjs";
|
||||
|
||||
/**
|
||||
* @typedef {object} AllowedAttributeConfiguration
|
||||
* @property {Set<string>} attrs The set of exactly-matching attribute names.
|
||||
* @property {string[]} wildcards A list of wildcard allowed prefixes for attributes.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} ManagedAttributesSpec
|
||||
* @property {string[]} attributes A list of managed attributes.
|
||||
* @property {string[]} styles A list of CSS property names that are managed as inline styles.
|
||||
* @property {string[]} classes A list of managed class names.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A class responsible for injecting attribute capture logic into the ProseMirror schema.
|
||||
*/
|
||||
export default class AttributeCapture {
|
||||
constructor() {
|
||||
this.#parseAllowedAttributesConfig(ALLOWED_HTML_ATTRIBUTES ?? {});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The configuration of attributes that are allowed on HTML elements.
|
||||
* @type {Record<string, AllowedAttributeConfiguration>}
|
||||
*/
|
||||
#allowedAttrs = {};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Augments the schema definition to allow each node or mark to capture all the attributes on an element and preserve
|
||||
* them when re-serialized back into the DOM.
|
||||
* @param {NodeSpec|MarkSpec} spec The schema specification.
|
||||
*/
|
||||
attributeCapture(spec) {
|
||||
if ( !spec.parseDOM ) return;
|
||||
if ( !spec.attrs ) spec.attrs = {};
|
||||
spec.attrs._preserve = { default: {}, formatting: true };
|
||||
spec.parseDOM.forEach(rule => {
|
||||
if ( rule.style ) return; // This doesn't work for style rules. We need a different solution there.
|
||||
const getAttrs = rule.getAttrs;
|
||||
rule.getAttrs = el => {
|
||||
let attrs = getAttrs?.(el);
|
||||
if ( attrs === false ) return false;
|
||||
if ( typeof attrs !== "object" ) attrs = {};
|
||||
mergeObject(attrs, rule.attrs);
|
||||
mergeObject(attrs, { _preserve: this.#captureAttributes(el, spec.managed) });
|
||||
return attrs;
|
||||
};
|
||||
});
|
||||
const toDOM = spec.toDOM;
|
||||
spec.toDOM = node => {
|
||||
const domSpec = toDOM(node);
|
||||
const attrs = domSpec[1];
|
||||
const preserved = node.attrs._preserve ?? {};
|
||||
if ( preserved.style ) preserved.style = preserved.style.replaceAll('"', "'");
|
||||
if ( getType(attrs) === "Object" ) {
|
||||
domSpec[1] = mergeObject(preserved, attrs, { inplace: false });
|
||||
if ( ("style" in preserved) && ("style" in attrs) ) domSpec[1].style = mergeStyle(preserved.style, attrs.style);
|
||||
if ( ("class" in preserved) && ("class" in attrs) ) domSpec[1].class = mergeClass(preserved.class, attrs.class);
|
||||
}
|
||||
else domSpec.splice(1, 0, { ...preserved });
|
||||
return domSpec;
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Capture all allowable attributes present on an HTML element and store them in an object for preservation in the
|
||||
* schema.
|
||||
* @param {HTMLElement} el The element.
|
||||
* @param {ManagedAttributesSpec} managed An object containing the attributes, styles, and classes that are managed
|
||||
* by the ProseMirror node and should not be preserved.
|
||||
* @returns {Attrs}
|
||||
*/
|
||||
#captureAttributes(el, managed={}) {
|
||||
const allowed = this.#allowedAttrs[el.tagName.toLowerCase()] ?? this.#allowedAttrs["*"];
|
||||
return Array.from(el.attributes).reduce((obj, attr) => {
|
||||
if ( attr.name.startsWith("data-pm-") ) return obj; // Ignore attributes managed by the ProseMirror editor itself.
|
||||
if ( managed.attributes?.includes(attr.name) ) return obj; // Ignore attributes managed by the node.
|
||||
// Ignore attributes that are not allowed.
|
||||
if ( !allowed.wildcards.some(prefix => attr.name.startsWith(prefix)) && !allowed.attrs.has(attr.name) ) {
|
||||
return obj;
|
||||
}
|
||||
if ( (attr.name === "class") && managed.classes?.length ) {
|
||||
obj.class = classesFromString(attr.value).filter(cls => !managed.classes.includes(cls)).join(" ");
|
||||
return obj;
|
||||
}
|
||||
if ( (attr.name === "style") && managed.styles?.length ) {
|
||||
const styles = stylesFromString(attr.value);
|
||||
managed.styles.forEach(style => delete styles[style]);
|
||||
obj.style = Object.entries(styles).map(([k, v]) => v ? `${k}: ${v}` : null).filterJoin("; ");
|
||||
return obj;
|
||||
}
|
||||
obj[attr.name] = attr.value;
|
||||
return obj;
|
||||
}, {});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Parse the configuration of allowed attributes into a more performant structure.
|
||||
* @param {Record<string, string[]>} config The allowed attributes configuration.
|
||||
*/
|
||||
#parseAllowedAttributesConfig(config) {
|
||||
const all = this.#allowedAttrs["*"] = this.#parseAllowedAttributes(config["*"] ?? []);
|
||||
for ( const [tag, attrs] of Object.entries(config ?? {}) ) {
|
||||
if ( tag === "*" ) continue;
|
||||
const allowed = this.#allowedAttrs[tag] = this.#parseAllowedAttributes(attrs);
|
||||
all.attrs.forEach(allowed.attrs.add, allowed.attrs);
|
||||
allowed.wildcards.push(...all.wildcards);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Parse an allowed attributes configuration into a more efficient structure.
|
||||
* @param {string[]} attrs The list of allowed attributes.
|
||||
* @returns {AllowedAttributeConfiguration}
|
||||
*/
|
||||
#parseAllowedAttributes(attrs) {
|
||||
const allowed = { wildcards: [], attrs: new Set() };
|
||||
for ( const attr of attrs ) {
|
||||
const wildcard = attr.indexOf("*");
|
||||
if ( wildcard < 0 ) allowed.attrs.add(attr);
|
||||
else allowed.wildcards.push(attr.substring(0, wildcard));
|
||||
}
|
||||
return allowed;
|
||||
}
|
||||
}
|
||||
70
resources/app/common/prosemirror/schema/core.mjs
Normal file
70
resources/app/common/prosemirror/schema/core.mjs
Normal file
@@ -0,0 +1,70 @@
|
||||
export const paragraph = {
|
||||
attrs: {alignment: {default: "left", formatting: true}},
|
||||
managed: {styles: ["text-align"]},
|
||||
content: "inline*",
|
||||
group: "block",
|
||||
parseDOM: [{tag: "p", getAttrs: el => ({alignment: el.style.textAlign || "left"})}],
|
||||
toDOM: node => {
|
||||
const {alignment} = node.attrs;
|
||||
if ( alignment === "left" ) return ["p", 0];
|
||||
return ["p", {style: `text-align: ${alignment};`}, 0];
|
||||
}
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const blockquote = {
|
||||
content: "block+",
|
||||
group: "block",
|
||||
defining: true,
|
||||
parseDOM: [{tag: "blockquote"}],
|
||||
toDOM: () => ["blockquote", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const hr = {
|
||||
group: "block",
|
||||
parseDOM: [{tag: "hr"}],
|
||||
toDOM: () => ["hr"]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const heading = {
|
||||
attrs: {level: {default: 1}},
|
||||
content: "inline*",
|
||||
group: "block",
|
||||
defining: true,
|
||||
parseDOM: [
|
||||
{tag: "h1", attrs: {level: 1}},
|
||||
{tag: "h2", attrs: {level: 2}},
|
||||
{tag: "h3", attrs: {level: 3}},
|
||||
{tag: "h4", attrs: {level: 4}},
|
||||
{tag: "h5", attrs: {level: 5}},
|
||||
{tag: "h6", attrs: {level: 6}}
|
||||
],
|
||||
toDOM: node => [`h${node.attrs.level}`, 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const pre = {
|
||||
content: "text*",
|
||||
marks: "",
|
||||
group: "block",
|
||||
code: true,
|
||||
defining: true,
|
||||
parseDOM: [{tag: "pre", preserveWhitespace: "full"}],
|
||||
toDOM: () => ["pre", ["code", 0]]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const br = {
|
||||
inline: true,
|
||||
group: "inline",
|
||||
selectable: false,
|
||||
parseDOM: [{tag: "br"}],
|
||||
toDOM: () => ["br"]
|
||||
};
|
||||
70
resources/app/common/prosemirror/schema/image-link-node.mjs
Normal file
70
resources/app/common/prosemirror/schema/image-link-node.mjs
Normal file
@@ -0,0 +1,70 @@
|
||||
import {mergeObject} from "../../utils/helpers.mjs";
|
||||
import ImageNode from "./image-node.mjs";
|
||||
import LinkMark from "./link-mark.mjs";
|
||||
import SchemaDefinition from "./schema-definition.mjs";
|
||||
|
||||
/**
|
||||
* A class responsible for encapsulating logic around image-link nodes in the ProseMirror schema.
|
||||
* @extends {SchemaDefinition}
|
||||
*/
|
||||
export default class ImageLinkNode extends SchemaDefinition {
|
||||
/** @override */
|
||||
static tag = "a";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static get attrs() {
|
||||
return mergeObject(ImageNode.attrs, LinkMark.attrs);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static getAttrs(el) {
|
||||
if ( (el.children.length !== 1) || (el.children[0].tagName !== "IMG") ) return false;
|
||||
const attrs = ImageNode.getAttrs(el.children[0]);
|
||||
attrs.href = el.href;
|
||||
attrs.title = el.title;
|
||||
return attrs;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static toDOM(node) {
|
||||
const spec = LinkMark.toDOM(node);
|
||||
spec.push(ImageNode.toDOM(node));
|
||||
return spec;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static make() {
|
||||
return mergeObject(super.make(), {
|
||||
group: "block",
|
||||
draggable: true,
|
||||
managed: { styles: ["float"], classes: ["centered"] }
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle clicking on image links while editing.
|
||||
* @param {EditorView} view The ProseMirror editor view.
|
||||
* @param {number} pos The position in the ProseMirror document that the click occurred at.
|
||||
* @param {PointerEvent} event The click event.
|
||||
* @param {Node} node The Node instance.
|
||||
*/
|
||||
static onClick(view, pos, event, node) {
|
||||
if ( (event.ctrlKey || event.metaKey) && node.attrs.href ) window.open(node.attrs.href, "_blank");
|
||||
// For some reason, calling event.preventDefault in this (mouseup) handler is not enough to cancel the default click
|
||||
// behaviour. It seems to be related to the outer anchor being set to contenteditable="false" by ProseMirror.
|
||||
// This workaround seems to prevent the click.
|
||||
const parent = event.target.parentElement;
|
||||
if ( (parent.tagName === "A") && !parent.isContentEditable ) parent.contentEditable = "true";
|
||||
return true;
|
||||
}
|
||||
}
|
||||
67
resources/app/common/prosemirror/schema/image-node.mjs
Normal file
67
resources/app/common/prosemirror/schema/image-node.mjs
Normal file
@@ -0,0 +1,67 @@
|
||||
import SchemaDefinition from "./schema-definition.mjs";
|
||||
import {mergeObject} from "../../utils/helpers.mjs";
|
||||
|
||||
/**
|
||||
* A class responsible for encapsulating logic around image nodes in the ProseMirror schema.
|
||||
* @extends {SchemaDefinition}
|
||||
*/
|
||||
export default class ImageNode extends SchemaDefinition {
|
||||
/** @override */
|
||||
static tag = "img[src]";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static get attrs() {
|
||||
return {
|
||||
src: {},
|
||||
alt: {default: null},
|
||||
title: {default: null},
|
||||
width: {default: ""},
|
||||
height: {default: ""},
|
||||
alignment: {default: "", formatting: true}
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static getAttrs(el) {
|
||||
const attrs = {
|
||||
src: el.getAttribute("src"),
|
||||
title: el.title,
|
||||
alt: el.alt
|
||||
};
|
||||
if ( el.classList.contains("centered") ) attrs.alignment = "center";
|
||||
else if ( el.style.float ) attrs.alignment = el.style.float;
|
||||
if ( el.hasAttribute("width") ) attrs.width = el.width;
|
||||
if ( el.hasAttribute("height") ) attrs.height = el.height;
|
||||
return attrs;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static toDOM(node) {
|
||||
const {src, alt, title, width, height, alignment} = node.attrs;
|
||||
const attrs = {src};
|
||||
if ( alignment === "center" ) attrs.class = "centered";
|
||||
else if ( alignment ) attrs.style = `float: ${alignment};`;
|
||||
if ( alt ) attrs.alt = alt;
|
||||
if ( title ) attrs.title = title;
|
||||
if ( width ) attrs.width = width;
|
||||
if ( height ) attrs.height = height;
|
||||
return ["img", attrs];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static make() {
|
||||
return mergeObject(super.make(), {
|
||||
managed: {styles: ["float"], classes: ["centered"]},
|
||||
group: "block",
|
||||
draggable: true
|
||||
});
|
||||
}
|
||||
}
|
||||
65
resources/app/common/prosemirror/schema/link-mark.mjs
Normal file
65
resources/app/common/prosemirror/schema/link-mark.mjs
Normal file
@@ -0,0 +1,65 @@
|
||||
import SchemaDefinition from "./schema-definition.mjs";
|
||||
import {mergeObject} from "../../utils/helpers.mjs";
|
||||
|
||||
/**
|
||||
* A class responsible for encapsulating logic around link marks in the ProseMirror schema.
|
||||
* @extends {SchemaDefinition}
|
||||
*/
|
||||
export default class LinkMark extends SchemaDefinition {
|
||||
/** @override */
|
||||
static tag = "a";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static get attrs() {
|
||||
return {
|
||||
href: { default: null },
|
||||
title: { default: null }
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static getAttrs(el) {
|
||||
if ( (el.children.length === 1) && (el.children[0]?.tagName === "IMG") ) return false;
|
||||
return { href: el.href, title: el.title };
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static toDOM(node) {
|
||||
const { href, title } = node.attrs;
|
||||
const attrs = {};
|
||||
if ( href ) attrs.href = href;
|
||||
if ( title ) attrs.title = title;
|
||||
return ["a", attrs];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static make() {
|
||||
return mergeObject(super.make(), {
|
||||
inclusive: false
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle clicks on link marks while editing.
|
||||
* @param {EditorView} view The ProseMirror editor view.
|
||||
* @param {number} pos The position in the ProseMirror document that the click occurred at.
|
||||
* @param {PointerEvent} event The click event.
|
||||
* @param {Mark} mark The Mark instance.
|
||||
* @returns {boolean|void} Returns true to indicate the click was handled here and should not be propagated to
|
||||
* other plugins.
|
||||
*/
|
||||
static onClick(view, pos, event, mark) {
|
||||
if ( (event.ctrlKey || event.metaKey) && mark.attrs.href ) window.open(mark.attrs.href, "_blank");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
73
resources/app/common/prosemirror/schema/lists.mjs
Normal file
73
resources/app/common/prosemirror/schema/lists.mjs
Normal file
@@ -0,0 +1,73 @@
|
||||
import {isElementEmpty, onlyInlineContent} from "./utils.mjs";
|
||||
|
||||
export const ol = {
|
||||
content: "(list_item | list_item_text)+",
|
||||
managed: {attributes: ["start"]},
|
||||
group: "block",
|
||||
attrs: {order: {default: 1}},
|
||||
parseDOM: [{tag: "ol", getAttrs: el => ({order: el.hasAttribute("start") ? Number(el.start) : 1})}],
|
||||
toDOM: node => node.attrs.order === 1 ? ["ol", 0] : ["ol", {start: node.attrs.order}, 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const ul = {
|
||||
content: "(list_item | list_item_text)+",
|
||||
group: "block",
|
||||
parseDOM: [{tag: "ul"}],
|
||||
toDOM: () => ["ul", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* ProseMirror enforces a stricter subset of HTML where block and inline content cannot be mixed. For example, the
|
||||
* following is valid HTML:
|
||||
* <ul>
|
||||
* <li>
|
||||
* The first list item.
|
||||
* <ul>
|
||||
* <li>An embedded list.</li>
|
||||
* </ul>
|
||||
* </li>
|
||||
* </ul>
|
||||
*
|
||||
* But, since the contents of the <li> would mix inline content (the text), with block content (the inner <ul>), the
|
||||
* schema is defined to only allow block content, and would transform the items to look like this:
|
||||
* <ul>
|
||||
* <li>
|
||||
* <p>The first list item.</p>
|
||||
* <ul>
|
||||
* <li><p>An embedded list.</p></li>
|
||||
* </ul>
|
||||
* </li>
|
||||
* </ul>
|
||||
*
|
||||
* We can address this by hooking into the DOM parsing and 'tagging' the extra paragraph elements inserted this way so
|
||||
* that when the contents are serialized again, they can be removed. This is left as a TODO for now.
|
||||
*/
|
||||
|
||||
// In order to preserve existing HTML we define two types of list nodes. One that contains block content, and one that
|
||||
// contains text content. We default to block content if the element is empty, in order to make integration with the
|
||||
// wrapping and lifting helpers simpler.
|
||||
export const li = {
|
||||
content: "paragraph block*",
|
||||
defining: true,
|
||||
parseDOM: [{tag: "li", getAttrs: el => {
|
||||
// If this contains only inline content and no other elements, do not use this node type.
|
||||
if ( !isElementEmpty(el) && onlyInlineContent(el) ) return false;
|
||||
}}],
|
||||
toDOM: () => ["li", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const liText = {
|
||||
content: "text*",
|
||||
defining: true,
|
||||
parseDOM: [{tag: "li", getAttrs: el => {
|
||||
// If this contains any non-inline elements, do not use this node type.
|
||||
if ( isElementEmpty(el) || !onlyInlineContent(el) ) return false;
|
||||
}}],
|
||||
toDOM: () => ["li", 0]
|
||||
};
|
||||
70
resources/app/common/prosemirror/schema/marks.mjs
Normal file
70
resources/app/common/prosemirror/schema/marks.mjs
Normal file
@@ -0,0 +1,70 @@
|
||||
export const em = {
|
||||
parseDOM: [{tag: "i"}, {tag: "em"}, {style: "font-style=italic"}],
|
||||
toDOM: () => ["em", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const strong = {
|
||||
parseDOM: [
|
||||
{tag: "strong"},
|
||||
{tag: "b"},
|
||||
{style: "font-weight", getAttrs: weight => /^(bold(er)?|[5-9]\d{2})$/.test(weight) && null}
|
||||
],
|
||||
toDOM: () => ["strong", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const code = {
|
||||
parseDOM: [{tag: "code"}],
|
||||
toDOM: () => ["code", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const underline = {
|
||||
parseDOM: [{tag: "u"}, {style: "text-decoration=underline"}],
|
||||
toDOM: () => ["span", {style: "text-decoration: underline;"}, 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const strikethrough = {
|
||||
parseDOM: [{tag: "s"}, {tag: "del"}, {style: "text-decoration=line-through"}],
|
||||
toDOM: () => ["s", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const superscript = {
|
||||
parseDOM: [{tag: "sup"}, {style: "vertical-align=super"}],
|
||||
toDOM: () => ["sup", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const subscript = {
|
||||
parseDOM: [{tag: "sub"}, {style: "vertical-align=sub"}],
|
||||
toDOM: () => ["sub", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const span = {
|
||||
parseDOM: [{tag: "span", getAttrs: el => {
|
||||
if ( el.style.fontFamily ) return false;
|
||||
return {};
|
||||
}}],
|
||||
toDOM: () => ["span", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const font = {
|
||||
attrs: {
|
||||
family: {}
|
||||
},
|
||||
parseDOM: [{style: "font-family", getAttrs: family => ({family})}],
|
||||
toDOM: node => ["span", {style: `font-family: ${node.attrs.family.replaceAll('"', "'")}`}]
|
||||
};
|
||||
210
resources/app/common/prosemirror/schema/other.mjs
Normal file
210
resources/app/common/prosemirror/schema/other.mjs
Normal file
@@ -0,0 +1,210 @@
|
||||
import {isElementEmpty, onlyInlineContent} from "./utils.mjs";
|
||||
|
||||
// These nodes are supported for HTML preservation purposes, but do not have robust editing support for now.
|
||||
|
||||
export const details = {
|
||||
content: "(summary | summary_block) block*",
|
||||
group: "block",
|
||||
defining: true,
|
||||
parseDOM: [{tag: "details"}],
|
||||
toDOM: () => ["details", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const summary = {
|
||||
content: "text*",
|
||||
defining: true,
|
||||
parseDOM: [{tag: "summary", getAttrs: el => {
|
||||
// If this contains any non-inline elements, do not use this node type.
|
||||
if ( !isElementEmpty(el) && !onlyInlineContent(el) ) return false;
|
||||
}}],
|
||||
toDOM: () => ["summary", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const summaryBlock = {
|
||||
content: "block+",
|
||||
defining: true,
|
||||
parseDOM: [{tag: "summary", getAttrs: el => {
|
||||
// If this contains only text nodes and no elements, do not use this node type.
|
||||
if ( isElementEmpty(el) || onlyInlineContent(el) ) return false;
|
||||
}}],
|
||||
toDOM: () => ["summary", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const dl = {
|
||||
content: "(block|dt|dd)*",
|
||||
group: "block",
|
||||
defining: true,
|
||||
parseDOM: [{tag: "dl"}],
|
||||
toDOM: () => ["dl", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const dt = {
|
||||
content: "block+",
|
||||
defining: true,
|
||||
parseDOM: [{tag: "dt"}],
|
||||
toDOM: () => ["dt", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const dd = {
|
||||
content: "block+",
|
||||
defining: true,
|
||||
parseDOM: [{tag: "dd"}],
|
||||
toDOM: () => ["dd", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const fieldset = {
|
||||
content: "legend block*",
|
||||
group: "block",
|
||||
defining: true,
|
||||
parseDOM: [{tag: "fieldset"}],
|
||||
toDOM: () => ["fieldset", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const legend = {
|
||||
content: "inline+",
|
||||
defining: true,
|
||||
parseDOM: [{tag: "legend"}],
|
||||
toDOM: () => ["legend", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const picture = {
|
||||
content: "source* image",
|
||||
group: "block",
|
||||
defining: true,
|
||||
parseDOM: [{tag: "picture"}],
|
||||
toDOM: () => ["picture", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const audio = {
|
||||
content: "source* track*",
|
||||
group: "block",
|
||||
parseDOM: [{tag: "audio"}],
|
||||
toDOM: () => ["audio", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const video = {
|
||||
content: "source* track*",
|
||||
group: "block",
|
||||
parseDOM: [{tag: "video"}],
|
||||
toDOM: () => ["video", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const track = {
|
||||
parseDOM: [{tag: "track"}],
|
||||
toDOM: () => ["track"]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const source = {
|
||||
parseDOM: [{tag: "source"}],
|
||||
toDOM: () => ["source"]
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const object = {
|
||||
inline: true,
|
||||
group: "inline",
|
||||
parseDOM: [{tag: "object"}],
|
||||
toDOM: () => ["object"]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const figure = {
|
||||
content: "(figcaption|block)*",
|
||||
group: "block",
|
||||
defining: true,
|
||||
parseDOM: [{tag: "figure"}],
|
||||
toDOM: () => ["figure", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const figcaption = {
|
||||
content: "inline+",
|
||||
defining: true,
|
||||
parseDOM: [{tag: "figcaption"}],
|
||||
toDOM: () => ["figcaption", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const small = {
|
||||
content: "paragraph block*",
|
||||
group: "block",
|
||||
defining: true,
|
||||
parseDOM: [{tag: "small"}],
|
||||
toDOM: () => ["small", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const ruby = {
|
||||
content: "(rp|rt|block)+",
|
||||
group: "block",
|
||||
defining: true,
|
||||
parseDOM: [{tag: "ruby"}],
|
||||
toDOM: () => ["ruby", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const rp = {
|
||||
content: "inline+",
|
||||
parseDOM: [{tag: "rp"}],
|
||||
toDOM: () => ["rp", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const rt = {
|
||||
content: "inline+",
|
||||
parseDOM: [{tag: "rt"}],
|
||||
toDOM: () => ["rt", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const iframe = {
|
||||
attrs: { sandbox: { default: "allow-scripts allow-forms" } },
|
||||
managed: { attributes: ["sandbox"] },
|
||||
group: "block",
|
||||
defining: true,
|
||||
parseDOM: [{tag: "iframe", getAttrs: el => {
|
||||
let sandbox = "allow-scripts allow-forms";
|
||||
const url = URL.parseSafe(el.src);
|
||||
const host = url?.hostname;
|
||||
const isTrusted = CONST.TRUSTED_IFRAME_DOMAINS.some(domain => (host === domain) || host?.endsWith(`.${domain}`));
|
||||
if ( isTrusted ) sandbox = null;
|
||||
return { sandbox };
|
||||
}}],
|
||||
toDOM: node => {
|
||||
const attrs = {};
|
||||
if ( node.attrs.sandbox ) attrs.sandbox = node.attrs.sandbox;
|
||||
return ["iframe", attrs];
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* An abstract interface for a ProseMirror schema definition.
|
||||
* @abstract
|
||||
*/
|
||||
export default class SchemaDefinition {
|
||||
/* -------------------------------------------- */
|
||||
/* Properties */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The HTML tag selector this node is associated with.
|
||||
* @type {string}
|
||||
*/
|
||||
static tag = "";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Schema attributes.
|
||||
* @returns {Record<string, AttributeSpec>}
|
||||
* @abstract
|
||||
*/
|
||||
static get attrs() {
|
||||
throw new Error("SchemaDefinition subclasses must implement the attrs getter.");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Check if an HTML element is appropriate to represent as this node, and if so, extract its schema attributes.
|
||||
* @param {HTMLElement} el The HTML element.
|
||||
* @returns {object|boolean} Returns false if the HTML element is not appropriate for this schema node, otherwise
|
||||
* returns its attributes.
|
||||
* @abstract
|
||||
*/
|
||||
static getAttrs(el) {
|
||||
throw new Error("SchemaDefinition subclasses must implement the getAttrs method.");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Convert a ProseMirror Node back into an HTML element.
|
||||
* @param {Node} node The ProseMirror node.
|
||||
* @returns {[string, any]}
|
||||
* @abstract
|
||||
*/
|
||||
static toDOM(node) {
|
||||
throw new Error("SchemaDefinition subclasses must implement the toDOM method.");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create the ProseMirror schema specification.
|
||||
* @returns {NodeSpec|MarkSpec}
|
||||
* @abstract
|
||||
*/
|
||||
static make() {
|
||||
return {
|
||||
attrs: this.attrs,
|
||||
parseDOM: [{tag: this.tag, getAttrs: this.getAttrs.bind(this)}],
|
||||
toDOM: this.toDOM.bind(this)
|
||||
};
|
||||
}
|
||||
}
|
||||
77
resources/app/common/prosemirror/schema/secret-node.mjs
Normal file
77
resources/app/common/prosemirror/schema/secret-node.mjs
Normal file
@@ -0,0 +1,77 @@
|
||||
import SchemaDefinition from "./schema-definition.mjs";
|
||||
import {mergeObject, randomID} from "../../utils/helpers.mjs";
|
||||
|
||||
/**
|
||||
* A class responsible for encapsulating logic around secret nodes in the ProseMirror schema.
|
||||
* @extends {SchemaDefinition}
|
||||
*/
|
||||
export default class SecretNode extends SchemaDefinition {
|
||||
/** @override */
|
||||
static tag = "section";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static get attrs() {
|
||||
return {
|
||||
revealed: { default: false },
|
||||
id: {}
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static getAttrs(el) {
|
||||
if ( !el.classList.contains("secret") ) return false;
|
||||
return {
|
||||
revealed: el.classList.contains("revealed"),
|
||||
id: el.id || `secret-${randomID()}`
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static toDOM(node) {
|
||||
const attrs = {
|
||||
id: node.attrs.id,
|
||||
class: `secret${node.attrs.revealed ? " revealed" : ""}`
|
||||
};
|
||||
return ["section", attrs, 0];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static make() {
|
||||
return mergeObject(super.make(), {
|
||||
content: "block+",
|
||||
group: "block",
|
||||
defining: true,
|
||||
managed: { attributes: ["id"], classes: ["revealed"] }
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle splitting a secret block in two, making sure the new block gets a unique ID.
|
||||
* @param {EditorState} state The ProseMirror editor state.
|
||||
* @param {(tr: Transaction) => void} dispatch The editor dispatch function.
|
||||
*/
|
||||
static split(state, dispatch) {
|
||||
const secret = state.schema.nodes.secret;
|
||||
const { $cursor } = state.selection;
|
||||
// Check we are actually on a blank line and not splitting text content.
|
||||
if ( !$cursor || $cursor.parent.content.size ) return false;
|
||||
// Check that we are actually in a secret block.
|
||||
if ( $cursor.node(-1).type !== secret ) return false;
|
||||
// Check that the block continues past the cursor.
|
||||
if ( $cursor.after() === $cursor.end(-1) ) return false;
|
||||
const before = $cursor.before(); // The previous line.
|
||||
// Ensure a new ID assigned to the new secret block.
|
||||
dispatch(state.tr.split(before, 1, [{type: secret, attrs: {id: `secret-${randomID()}`}}]));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
218
resources/app/common/prosemirror/schema/tables.mjs
Normal file
218
resources/app/common/prosemirror/schema/tables.mjs
Normal file
@@ -0,0 +1,218 @@
|
||||
import {tableNodes} from "prosemirror-tables";
|
||||
import {isElementEmpty, onlyInlineContent} from "./utils.mjs";
|
||||
|
||||
const CELL_ATTRS = {
|
||||
colspan: {default: 1},
|
||||
rowspan: {default: 1},
|
||||
colwidth: {default: null}
|
||||
};
|
||||
|
||||
const MANAGED_CELL_ATTRS = {
|
||||
attributes: ["colspan", "rowspan", "data-colwidth"]
|
||||
};
|
||||
|
||||
// If any of these elements are part of a table, consider it a 'complex' table and do not attempt to make it editable.
|
||||
const COMPLEX_TABLE_ELEMENTS = new Set(["CAPTION", "COLGROUP", "THEAD", "TFOOT"]);
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Utilities */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Determine node attributes for a table cell when parsing the DOM.
|
||||
* @param {HTMLTableCellElement} cell The table cell DOM node.
|
||||
* @returns {{colspan: number, rowspan: number}}
|
||||
*/
|
||||
function getTableCellAttrs(cell) {
|
||||
const colspan = cell.getAttribute("colspan") || 1;
|
||||
const rowspan = cell.getAttribute("rowspan") || 1;
|
||||
return {
|
||||
colspan: Number(colspan),
|
||||
rowspan: Number(rowspan)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the HTML attributes to be set on the table cell DOM node based on its ProseMirror node attributes.
|
||||
* @param {Node} node The table cell ProseMirror node.
|
||||
* @returns {object} An object of attribute name -> attribute value.
|
||||
*/
|
||||
function setTableCellAttrs(node) {
|
||||
const attrs = {};
|
||||
const {colspan, rowspan} = node.attrs;
|
||||
if ( colspan !== 1 ) attrs.colspan = colspan;
|
||||
if ( rowspan !== 1 ) attrs.rowspan = rowspan;
|
||||
return attrs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this element exists as part of a 'complex' table.
|
||||
* @param {HTMLElement} el The element to test.
|
||||
* @returns {boolean|void}
|
||||
*/
|
||||
function inComplexTable(el) {
|
||||
const table = el.closest("table");
|
||||
if ( !table ) return;
|
||||
return Array.from(table.children).some(child => COMPLEX_TABLE_ELEMENTS.has(child.tagName));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Built-in Tables */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const builtInTableNodes = tableNodes({
|
||||
tableGroup: "block",
|
||||
cellContent: "block+"
|
||||
});
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* 'Complex' Tables */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const tableComplex = {
|
||||
content: "(caption | caption_block)? colgroup? thead? tbody tfoot?",
|
||||
isolating: true,
|
||||
group: "block",
|
||||
parseDOM: [{tag: "table", getAttrs: el => {
|
||||
if ( inComplexTable(el) === false ) return false;
|
||||
}}],
|
||||
toDOM: () => ["table", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const colgroup = {
|
||||
content: "col*",
|
||||
isolating: true,
|
||||
parseDOM: [{tag: "colgroup"}],
|
||||
toDOM: () => ["colgroup", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const col = {
|
||||
tableRole: "col",
|
||||
parseDOM: [{tag: "col"}],
|
||||
toDOM: () => ["col"]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const thead = {
|
||||
content: "table_row_complex+",
|
||||
isolating: true,
|
||||
parseDOM: [{tag: "thead"}],
|
||||
toDOM: () => ["thead", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const tbody = {
|
||||
content: "table_row_complex+",
|
||||
isolating: true,
|
||||
parseDOM: [{tag: "tbody", getAttrs: el => {
|
||||
if ( inComplexTable(el) === false ) return false;
|
||||
}}],
|
||||
toDOM: () => ["tbody", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const tfoot = {
|
||||
content: "table_row_complex+",
|
||||
isolating: true,
|
||||
parseDOM: [{tag: "tfoot"}],
|
||||
toDOM: () => ["tfoot", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const caption = {
|
||||
content: "text*",
|
||||
isolating: true,
|
||||
parseDOM: [{tag: "caption", getAttrs: el => {
|
||||
if ( !isElementEmpty(el) && !onlyInlineContent(el) ) return false;
|
||||
}}],
|
||||
toDOM: () => ["caption", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const captionBlock = {
|
||||
content: "block*",
|
||||
isolating: true,
|
||||
parseDOM: [{tag: "caption", getAttrs: el => {
|
||||
if ( isElementEmpty(el) || onlyInlineContent(el) ) return false;
|
||||
}}],
|
||||
toDOM: () => ["caption", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const tableRowComplex = {
|
||||
content: "(table_cell_complex | table_header_complex | table_cell_complex_block | table_header_complex_block)*",
|
||||
parseDOM: [{tag: "tr", getAttrs: el => {
|
||||
if ( inComplexTable(el) === false ) return false;
|
||||
}}],
|
||||
toDOM: () => ["tr", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const tableCellComplex = {
|
||||
content: "text*",
|
||||
attrs: CELL_ATTRS,
|
||||
managed: MANAGED_CELL_ATTRS,
|
||||
isolating: true,
|
||||
parseDOM: [{tag: "td", getAttrs: el => {
|
||||
if ( inComplexTable(el) === false ) return false;
|
||||
if ( !isElementEmpty(el) && !onlyInlineContent(el) ) return false;
|
||||
return getTableCellAttrs(el);
|
||||
}}],
|
||||
toDOM: node => ["td", setTableCellAttrs(node), 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const tableCellComplexBlock = {
|
||||
content: "block*",
|
||||
attrs: CELL_ATTRS,
|
||||
managed: MANAGED_CELL_ATTRS,
|
||||
isolating: true,
|
||||
parseDOM: [{tag: "td", getAttrs: el => {
|
||||
if ( inComplexTable(el) === false ) return false;
|
||||
if ( isElementEmpty(el) || onlyInlineContent(el) ) return false;
|
||||
return getTableCellAttrs(el);
|
||||
}}],
|
||||
toDOM: node => ["td", setTableCellAttrs(node), 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const tableHeaderComplex = {
|
||||
content: "text*",
|
||||
attrs: CELL_ATTRS,
|
||||
managed: MANAGED_CELL_ATTRS,
|
||||
isolating: true,
|
||||
parseDOM: [{tag: "th", getAttrs: el => {
|
||||
if ( inComplexTable(el) === false ) return false;
|
||||
if ( !isElementEmpty(el) && !onlyInlineContent(el) ) return false;
|
||||
return getTableCellAttrs(el);
|
||||
}}],
|
||||
toDOM: node => ["th", setTableCellAttrs(node), 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const tableHeaderComplexBlock = {
|
||||
content: "block*",
|
||||
attrs: CELL_ATTRS,
|
||||
managed: MANAGED_CELL_ATTRS,
|
||||
isolating: true,
|
||||
parseDOM: [{tag: "th", getAttrs: el => {
|
||||
if ( inComplexTable(el) === false ) return false;
|
||||
if ( isElementEmpty(el) || onlyInlineContent(el) ) return false;
|
||||
return getTableCellAttrs(el);
|
||||
}}],
|
||||
toDOM: node => ["th", setTableCellAttrs(node), 0]
|
||||
};
|
||||
75
resources/app/common/prosemirror/schema/utils.mjs
Normal file
75
resources/app/common/prosemirror/schema/utils.mjs
Normal file
@@ -0,0 +1,75 @@
|
||||
import {mergeObject} from "../../utils/helpers.mjs";
|
||||
|
||||
// A list of tag names that are considered allowable inside a node that only supports inline content.
|
||||
const INLINE_TAGS = new Set(["A", "EM", "I", "STRONG", "B", "CODE", "U", "S", "DEL", "SUP", "SUB", "SPAN"]);
|
||||
|
||||
/**
|
||||
* Determine if an HTML element contains purely inline content, i.e. only text nodes and 'mark' elements.
|
||||
* @param {HTMLElement} element The element.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function onlyInlineContent(element) {
|
||||
for ( const child of element.children ) {
|
||||
if ( !INLINE_TAGS.has(child.tagName) ) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Determine if an HTML element is empty.
|
||||
* @param {HTMLElement} element The element.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isElementEmpty(element) {
|
||||
return !element.childNodes.length;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Convert an element's style attribute string into an object.
|
||||
* @param {string} str The style string.
|
||||
* @returns {object}
|
||||
*/
|
||||
export function stylesFromString(str) {
|
||||
return Object.fromEntries(str.split(/;\s*/g).map(prop => prop.split(/:\s*/)));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Merge two style attribute strings.
|
||||
* @param {string} a The first style string.
|
||||
* @param {string} b The second style string.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function mergeStyle(a, b) {
|
||||
const allStyles = mergeObject(stylesFromString(a), stylesFromString(b));
|
||||
return Object.entries(allStyles).map(([k, v]) => v ? `${k}: ${v}` : null).filterJoin("; ");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Convert an element's class attribute string into an array of class names.
|
||||
* @param {string} str The class string.
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function classesFromString(str) {
|
||||
return str.split(/\s+/g);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Merge two class attribute strings.
|
||||
* @param {string} a The first class string.
|
||||
* @param {string} b The second class string.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function mergeClass(a, b) {
|
||||
const allClasses = classesFromString(a).concat(classesFromString(b));
|
||||
return Array.from(new Set(allClasses)).join(" ");
|
||||
}
|
||||
Reference in New Issue
Block a user