This commit is contained in:
2025-01-04 00:34:03 +01:00
parent 41829408dc
commit 0ca14bbc19
18111 changed files with 1871397 additions and 0 deletions

View 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;
}
}

View 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"]
};

View 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;
}
}

View 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
});
}
}

View 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;
}
}

View 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]
};

View 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('"', "'")}`}]
};

View 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];
}
};

View File

@@ -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)
};
}
}

View 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;
}
}

View 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]
};

View 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(" ");
}