140 lines
5.6 KiB
JavaScript
140 lines
5.6 KiB
JavaScript
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;
|
|
}
|
|
}
|