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,67 @@
/** @module prosemirror */
import {EditorState, AllSelection, TextSelection, Plugin, PluginKey} from "prosemirror-state";
import {EditorView} from "prosemirror-view";
import {Schema, DOMSerializer} from "prosemirror-model";
import ProseMirrorInputRules from "./input-rules.mjs";
import {keymap} from "prosemirror-keymap";
import {baseKeymap} from "prosemirror-commands";
import {dropCursor} from "prosemirror-dropcursor";
import {gapCursor} from "prosemirror-gapcursor";
import {history} from "prosemirror-history";
import ProseMirrorKeyMaps from "./keymaps.mjs";
import ProseMirrorMenu from "./menu.mjs";
import "./extensions.mjs";
import * as collab from "prosemirror-collab";
import {Step} from "prosemirror-transform";
import {parseHTMLString, serializeHTMLString} from "./util.mjs";
import {schema as defaultSchema} from "./schema.mjs";
import ProseMirrorPlugin from "./plugin.mjs";
import ProseMirrorImagePlugin from "./image-plugin.mjs";
import ProseMirrorDirtyPlugin from "./dirty-plugin.mjs";
import ProseMirrorContentLinkPlugin from "./content-link-plugin.mjs";
import ProseMirrorHighlightMatchesPlugin from "./highlight-matches-plugin.mjs";
import ProseMirrorClickHandler from "./click-handler.mjs";
import {columnResizing, tableEditing} from "prosemirror-tables";
import DOMParser from "./dom-parser.mjs";
import ProseMirrorPasteTransformer from "./paste-transformer.mjs";
const dom = {
parser: DOMParser.fromSchema(defaultSchema),
serializer: DOMSerializer.fromSchema(defaultSchema),
parseString: parseHTMLString,
serializeString: serializeHTMLString
};
const defaultPlugins = {
inputRules: ProseMirrorInputRules.build(defaultSchema),
keyMaps: ProseMirrorKeyMaps.build(defaultSchema),
menu: ProseMirrorMenu.build(defaultSchema),
isDirty: ProseMirrorDirtyPlugin.build(defaultSchema),
clickHandler: ProseMirrorClickHandler.build(defaultSchema),
pasteTransformer: ProseMirrorPasteTransformer.build(defaultSchema),
baseKeyMap: keymap(baseKeymap),
dropCursor: dropCursor(),
gapCursor: gapCursor(),
history: history(),
columnResizing: columnResizing(),
tables: tableEditing()
};
export * as commands from "prosemirror-commands";
export * as transform from "prosemirror-transform";
export * as list from "prosemirror-schema-list";
export * as tables from "prosemirror-tables";
export * as input from "prosemirror-inputrules";
export * as state from "prosemirror-state";
export {
AllSelection, TextSelection,
DOMParser, DOMSerializer,
EditorState, EditorView,
Schema, Step,
Plugin, PluginKey, ProseMirrorPlugin, ProseMirrorContentLinkPlugin, ProseMirrorHighlightMatchesPlugin,
ProseMirrorDirtyPlugin, ProseMirrorImagePlugin, ProseMirrorClickHandler,
ProseMirrorInputRules, ProseMirrorKeyMaps, ProseMirrorMenu,
collab, defaultPlugins, defaultSchema, dom, keymap
}

View File

@@ -0,0 +1,45 @@
import ProseMirrorPlugin from "./plugin.mjs";
import {Plugin} from "prosemirror-state";
/**
* A class responsible for managing click events inside a ProseMirror editor.
* @extends {ProseMirrorPlugin}
*/
export default class ProseMirrorClickHandler extends ProseMirrorPlugin {
/** @override */
static build(schema, options={}) {
const plugin = new ProseMirrorClickHandler(schema);
return new Plugin({
props: {
handleClickOn: plugin._onClick.bind(plugin)
}
});
}
/* -------------------------------------------- */
/**
* Handle a click on the editor.
* @param {EditorView} view The ProseMirror editor view.
* @param {number} pos The position in the ProseMirror document that the click occurred at.
* @param {Node} node The current ProseMirror Node that the click has bubbled to.
* @param {number} nodePos The position of the click within this Node.
* @param {PointerEvent} event The click event.
* @param {boolean} direct Whether this Node is the one that was directly clicked on.
* @returns {boolean|void} A return value of true indicates the event has been handled, it will not propagate to
* other plugins, and ProseMirror will call preventDefault on it.
* @protected
*/
_onClick(view, pos, node, nodePos, event, direct) {
// If this is the inner-most click bubble, check marks for onClick handlers.
if ( direct ) {
const $pos = view.state.doc.resolve(pos);
for ( const mark of $pos.marks() ) {
if ( mark.type.onClick?.(view, pos, event, mark) === true ) return true;
}
}
// Check the current Node for onClick handlers.
return node.type.onClick?.(view, pos, event, node);
}
}

View File

@@ -0,0 +1,86 @@
import ProseMirrorPlugin from "./plugin.mjs";
import {Plugin} from "prosemirror-state";
/**
* A class responsible for handling the dropping of Documents onto the editor and creating content links for them.
* @extends {ProseMirrorPlugin}
*/
export default class ProseMirrorContentLinkPlugin extends ProseMirrorPlugin {
/**
* @typedef {object} ProseMirrorContentLinkOptions
* @property {ClientDocument} [document] The parent document housing this editor.
* @property {boolean} [relativeLinks=false] Whether to generate links relative to the parent document.
*/
/**
* @param {Schema} schema The ProseMirror schema.
* @param {ProseMirrorContentLinkOptions} options Additional options to configure the plugin's behaviour.
*/
constructor(schema, {document, relativeLinks=false}={}) {
super(schema);
if ( relativeLinks && !document ) {
throw new Error("A document must be provided in order to generate relative links.");
}
/**
* The parent document housing this editor.
* @type {ClientDocument}
*/
Object.defineProperty(this, "document", {value: document, writable: false});
/**
* Whether to generate links relative to the parent document.
* @type {boolean}
*/
Object.defineProperty(this, "relativeLinks", {value: relativeLinks, writable: false});
}
/* -------------------------------------------- */
/** @inheritdoc */
static build(schema, options={}) {
const plugin = new ProseMirrorContentLinkPlugin(schema, options);
return new Plugin({
props: {
handleDrop: plugin._onDrop.bind(plugin)
}
});
}
/* -------------------------------------------- */
/**
* Handle a drop onto the editor.
* @param {EditorView} view The ProseMirror editor view.
* @param {DragEvent} event The drop event.
* @param {Slice} slice A slice of editor content.
* @param {boolean} moved Whether the slice has been moved from a different part of the editor.
* @protected
*/
_onDrop(view, event, slice, moved) {
if ( moved ) return;
const pos = view.posAtCoords({left: event.clientX, top: event.clientY});
const data = TextEditor.getDragEventData(event);
if ( !data.type ) return;
const options = {};
if ( this.relativeLinks ) options.relativeTo = this.document;
const selection = view.state.selection;
if ( !selection.empty ) {
const content = selection.content().content;
options.label = content.textBetween(0, content.size);
}
TextEditor.getContentLink(data, options).then(link => {
if ( !link ) return;
const tr = view.state.tr;
if ( selection.empty ) tr.insertText(link, pos.pos);
else tr.replaceSelectionWith(this.schema.text(link));
view.dispatch(tr);
// Focusing immediately only seems to work in Chrome. In Firefox we must yield execution before attempting to
// focus, otherwise the cursor becomes invisible until the user manually unfocuses and refocuses.
setTimeout(view.focus.bind(view), 0);
});
event.stopPropagation();
return true;
}
}

View File

@@ -0,0 +1,22 @@
import ProseMirrorPlugin from "./plugin.mjs";
import {Plugin} from "prosemirror-state";
/**
* A simple plugin that records the dirty state of the editor.
* @extends {ProseMirrorPlugin}
*/
export default class ProseMirrorDirtyPlugin extends ProseMirrorPlugin {
/** @inheritdoc */
static build(schema, options={}) {
return new Plugin({
state: {
init() {
return false;
},
apply() {
return true; // If any transaction is applied to the state, we mark the editor as dirty.
}
}
});
}
}

View File

@@ -0,0 +1,34 @@
import {DOMParser as BaseDOMParser} from "prosemirror-model";
export default class DOMParser extends BaseDOMParser {
/** @inheritdoc */
parse(dom, options) {
this.#unwrapImages(dom);
return super.parse(dom, options);
}
/* -------------------------------------------- */
/**
* Unwrap any image tags that may have been wrapped in <p></p> tags in earlier iterations of the schema.
* @param {HTMLElement} dom The root HTML element to parse.
*/
#unwrapImages(dom) {
dom.querySelectorAll("img").forEach(img => {
const paragraph = img.parentElement;
if ( paragraph?.tagName !== "P" ) return;
const parent = paragraph.parentElement || dom;
parent.insertBefore(img, paragraph);
// If the paragraph element was purely holding the image element and is now empty, we can remove it.
if ( !paragraph.childNodes.length ) paragraph.remove();
});
}
/* -------------------------------------------- */
/** @inheritdoc */
static fromSchema(schema) {
if ( schema.cached.domParser ) return schema.cached.domParser;
return schema.cached.domParser = new this(schema, this.schemaRules(schema));
}
}

View File

@@ -0,0 +1,199 @@
export default class ProseMirrorDropDown {
/**
* A class responsible for rendering a menu drop-down.
* @param {string} title The default title.
* @param {ProseMirrorDropDownEntry[]} items The configured menu items.
* @param {object} [options]
* @param {string} [options.cssClass] The menu CSS class name. Required if providing an action.
* @param {string} [options.icon] Use an icon for the dropdown rather than a text label.
* @param {function(MouseEvent)} [options.onAction] A callback to fire when a menu item is clicked.
*/
constructor(title, items, {cssClass, icon, onAction}={}) {
/**
* The default title for this drop-down.
* @type {string}
*/
Object.defineProperty(this, "title", {value: title, writable: false});
/**
* The items configured for this drop-down.
* @type {ProseMirrorDropDownEntry[]}
*/
Object.defineProperty(this, "items", {value: items, writable: false});
this.#icon = icon;
this.#cssClass = cssClass;
this.#onAction = onAction;
}
/* -------------------------------------------- */
/**
* The menu CSS class name.
* @type {string}
*/
#cssClass;
/* -------------------------------------------- */
/**
* The icon to use instead of a text label, if any.
* @type {string}
*/
#icon;
/* -------------------------------------------- */
/**
* The callback to fire when a menu item is clicked.
* @type {function(MouseEvent)}
*/
#onAction;
/* -------------------------------------------- */
/**
* Attach event listeners.
* @param {HTMLMenuElement} html The root menu element.
*/
activateListeners(html) {
if ( !this.#onAction ) return;
html.querySelector(`.pm-dropdown.${this.#cssClass}`).onclick = event => this.#onActivate(event);
}
/* -------------------------------------------- */
/**
* Construct the drop-down menu's HTML.
* @returns {string} HTML contents as a string.
*/
render() {
// Record which dropdown options are currently active
const activeItems = [];
this.forEachItem(item => {
if ( !item.active ) return;
activeItems.push(item);
});
activeItems.sort((a, b) => a.priority - b.priority);
const activeItem = activeItems.shift();
// Render the dropdown
const active = game.i18n.localize(activeItem ? activeItem.title : this.title);
const items = this.constructor._renderMenu(this.items);
return `
<button type="button" class="pm-dropdown ${this.#icon ? "icon" : ""} ${this.#cssClass}">
${this.#icon ? this.#icon : `<span>${active}</span>`}
<i class="fa-solid fa-chevron-down"></i>
${items}
</button>
`;
}
/* -------------------------------------------- */
/**
* Recurse through the menu structure and apply a function to each item in it.
* @param {function(ProseMirrorDropDownEntry):boolean} fn The function to call on each item. Return false to prevent
* iterating over any further items.
*/
forEachItem(fn) {
const forEach = items => {
for ( const item of items ) {
const result = fn(item);
if ( result === false ) break;
if ( item.children?.length ) forEach(item.children);
}
};
forEach(this.items);
}
/* -------------------------------------------- */
/**
* Handle spawning a drop-down menu.
* @param {PointerEvent} event The triggering event.
* @protected
*/
#onActivate(event) {
document.getElementById("prosemirror-dropdown")?.remove();
const menu = event.currentTarget.querySelector(":scope > ul");
if ( !menu ) return;
const { top, left, bottom } = event.currentTarget.getBoundingClientRect();
const dropdown = document.createElement("div");
dropdown.id = "prosemirror-dropdown";
// Apply theme if App V2.
if ( menu.closest(".application") ) {
dropdown.classList.add(document.body.classList.contains("theme-dark") ? "theme-dark" : "theme-light");
}
dropdown.append(menu.cloneNode(true));
Object.assign(dropdown.style, { left: `${left}px`, top: `${bottom}px` });
document.body.append(dropdown);
dropdown.querySelectorAll(`li`).forEach(item => {
item.onclick = event => this.#onAction(event);
item.onpointerover = event => this.#onHoverItem(event);
});
requestAnimationFrame(() => {
const { width, height } = dropdown.querySelector(":scope > ul").getBoundingClientRect();
const { clientWidth, clientHeight } = document.documentElement;
if ( left + width > clientWidth ) dropdown.style.left = `${left - width}px`;
if ( bottom + height > clientHeight ) dropdown.style.top = `${top - height}px`;
});
}
/* -------------------------------------------- */
/**
* Adjust menu position when hovering over items.
* @param {PointerEvent} event The triggering event.
*/
#onHoverItem(event) {
const menu = event.currentTarget.querySelector(":scope > ul");
if ( !menu ) return;
const { clientWidth, clientHeight } = document.documentElement;
const { top } = event.currentTarget.getBoundingClientRect();
const { x, width, height } = menu.getBoundingClientRect();
if ( top + height > clientHeight ) menu.style.top = `-${top + height - clientHeight}px`;
if ( x + width > clientWidth ) menu.style.left = `-${width}px`;
}
/* -------------------------------------------- */
/**
* Render a list of drop-down menu items.
* @param {ProseMirrorDropDownEntry[]} entries The menu items.
* @returns {string} HTML contents as a string.
* @protected
*/
static _renderMenu(entries) {
const groups = entries.reduce((arr, item) => {
const group = item.group ?? 0;
arr[group] ??= [];
arr[group].push(this._renderMenuItem(item));
return arr;
}, []);
const items = groups.reduce((arr, group) => {
if ( group?.length ) arr.push(group.join(""));
return arr;
}, []);
return `<ul>${items.join('<li class="divider"></li>')}</ul>`;
}
/* -------------------------------------------- */
/**
* Render an individual drop-down menu item.
* @param {ProseMirrorDropDownEntry} item The menu item.
* @returns {string} HTML contents as a string.
* @protected
*/
static _renderMenuItem(item) {
const parts = [`<li data-action="${item.action}" class="${item.class ?? ""}">`];
parts.push(`<span style="${item.style ?? ""}">${game.i18n.localize(item.title)}</span>`);
if ( item.active && !item.children?.length ) parts.push('<i class="fa-solid fa-check"></i>');
if ( item.children?.length ) {
parts.push('<i class="fa-solid fa-chevron-right"></i>', this._renderMenu(item.children));
}
parts.push("</li>");
return parts.join("");
}
}

View File

@@ -0,0 +1,21 @@
import {ResolvedPos} from "prosemirror-model";
/**
* Determine whether a given position has an ancestor node of the given type.
* @param {NodeType} other The other node type.
* @param {object} [attrs] An object of attributes that must also match, if provided.
* @returns {boolean}
*/
ResolvedPos.prototype.hasAncestor = function(other, attrs) {
if ( !this.depth ) return false;
for ( let i = this.depth; i > 0; i-- ) { // Depth 0 is the root document, so we don't need to test that.
const node = this.node(i);
if ( node.type === other ) {
const nodeAttrs = foundry.utils.deepClone(node.attrs);
delete nodeAttrs._preserve; // Do not include our internal attributes in the comparison.
if ( attrs ) return foundry.utils.objectsEqual(nodeAttrs, attrs);
return true;
}
}
return false;
};

View File

@@ -0,0 +1,159 @@
import ProseMirrorPlugin from "./plugin.mjs";
import {Plugin} from "prosemirror-state";
/**
* A class responsible for handling the display of automated link recommendations when a user highlights text in a
* ProseMirror editor.
* @param {EditorView} view The editor view.
*/
class PossibleMatchesTooltip {
/**
* @param {EditorView} view The editor view.
*/
constructor(view) {
this.update(view, null);
}
/* -------------------------------------------- */
/**
* A reference to any existing tooltip that has been generated as part of a highlight match.
* @type {HTMLElement}
*/
tooltip;
/* -------------------------------------------- */
/**
* Update the tooltip based on changes to the selected text.
* @param {EditorView} view The editor view.
* @param {State} lastState The previous state of the document.
*/
async update(view, lastState) {
if ( !game.settings.get("core", "pmHighlightDocumentMatches") ) return;
const state = view.state;
// Deactivate tooltip if the document/selection didn't change or is empty
const stateUnchanged = lastState && (lastState.doc.eq(state.doc) && lastState.selection.eq(state.selection));
if ( stateUnchanged || state.selection.empty ) return this._deactivateTooltip();
const selection = state.selection.content().content;
const highlighted = selection.textBetween(0, selection.size);
// If the user selected fewer than a certain amount of characters appropriate for the language, we bail out.
if ( highlighted.length < CONFIG.i18n.searchMinimumCharacterLength ) return this._deactivateTooltip();
// Look for any matches based on the contents of the selection
let html = this._findMatches(highlighted);
// If html is an empty string bail out and deactivate tooltip
if ( !html ) return this._deactivateTooltip();
// Enrich the matches HTML to get proper content links
html = await TextEditor.enrichHTML(html);
html = html.replace(/data-tooltip="[^"]+"/g, "");
const {from, to} = state.selection;
// In-screen coordinates
const start = view.coordsAtPos(from);
const end = view.coordsAtPos(to);
// Position the tooltip. This needs to be very close to the user's cursor, otherwise the locked tooltip will be
// immediately dismissed for being too far from the tooltip.
// TODO: We use the selection endpoints here which works fine for single-line selections, but not multi-line.
const left = (start.left + 3) + "px";
const bottom = window.innerHeight - start.bottom + 25 + "px";
const position = {bottom, left};
if ( this.tooltip ) this._updateTooltip(html);
else this._createTooltip(position, html, {cssClass: "link-matches"});
}
/* -------------------------------------------- */
/**
* Create a locked tooltip at the given position.
* @param {object} position A position object with coordinates for where the tooltip should be placed
* @param {string} position.top Explicit top position for the tooltip
* @param {string} position.right Explicit right position for the tooltip
* @param {string} position.bottom Explicit bottom position for the tooltip
* @param {string} position.left Explicit left position for the tooltip
* @param {string} text Explicit tooltip text or HTML to display.
* @param {object} [options={}] Additional options which can override tooltip behavior.
* @param {array} [options.cssClass] An optional, space-separated list of CSS classes to apply to the activated
* tooltip.
*/
_createTooltip(position, text, options) {
this.tooltip = game.tooltip.createLockedTooltip(position, text, options);
}
/* -------------------------------------------- */
/**
* Update the tooltip with new HTML
* @param {string} html The HTML to be included in the tooltip
*/
_updateTooltip(html) {
this.tooltip.innerHTML = html;
}
/* -------------------------------------------- */
/**
* Dismiss all locked tooltips and set this tooltip to undefined.
*/
_deactivateTooltip() {
if ( !this.tooltip ) return;
game.tooltip.dismissLockedTooltip(this.tooltip);
this.tooltip = undefined;
}
/* -------------------------------------------- */
/**
* Find all Documents in the world/compendia with names that match the selection insensitive to case.
* @param {string} text A string which will be matched against document names
* @returns {string}
*/
_findMatches(text) {
let html = "";
const matches = game.documentIndex.lookup(text, { ownership: "OBSERVER" });
for ( const [type, collection] of Object.entries(matches) ) {
if ( collection.length === 0 ) continue;
html += `<section><h4>${type}</h4><p>`;
for ( const document of collection ) {
html += document.entry?.link ? document.entry.link : `@UUID[${document.uuid}]{${document.entry.name}}`;
}
html += "</p></section>";
}
return html;
}
}
/**
* A ProseMirrorPlugin wrapper around the {@link PossibleMatchesTooltip} class.
* @extends {ProseMirrorPlugin}
*/
export default class ProseMirrorHighlightMatchesPlugin extends ProseMirrorPlugin {
/**
* @param {Schema} schema The ProseMirror schema.
* @param {ProseMirrorMenuOptions} [options] Additional options to configure the plugin's behaviour.
*/
constructor(schema, options={}) {
super(schema);
this.options = options;
}
/* -------------------------------------------- */
/** @inheritdoc */
static build(schema, options={}) {
return new Plugin({
view(editorView) {
return new PossibleMatchesTooltip(editorView);
},
isHighlightMatchesPlugin: true
});
}
}

View File

@@ -0,0 +1,171 @@
import {Plugin} from "prosemirror-state";
import ProseMirrorPlugin from "./plugin.mjs";
import {hasFileExtension, isBase64Data} from "../data/validators.mjs";
import {dom} from "./_module.mjs";
/**
* A class responsible for handle drag-and-drop and pasting of image content. Ensuring no base64 data is injected
* directly into the journal content and it is instead uploaded to the user's data directory.
* @extends {ProseMirrorPlugin}
*/
export default class ProseMirrorImagePlugin extends ProseMirrorPlugin {
/**
* @param {Schema} schema The ProseMirror schema.
* @param {object} options Additional options to configure the plugin's behaviour.
* @param {ClientDocument} options.document A related Document to store extract base64 images for.
*/
constructor(schema, {document}={}) {
super(schema);
if ( !document ) {
throw new Error("The image drop and pasting plugin requires a reference to a related Document to function.");
}
/**
* The related Document to store extracted base64 images for.
* @type {ClientDocument}
*/
Object.defineProperty(this, "document", {value: document, writable: false});
}
/* -------------------------------------------- */
/** @inheritdoc */
static build(schema, options={}) {
const plugin = new ProseMirrorImagePlugin(schema, options);
return new Plugin({
props: {
handleDrop: plugin._onDrop.bind(plugin),
handlePaste: plugin._onPaste.bind(plugin)
}
});
}
/* -------------------------------------------- */
/**
* Handle a drop onto the editor.
* @param {EditorView} view The ProseMirror editor view.
* @param {DragEvent} event The drop event.
* @param {Slice} slice A slice of editor content.
* @param {boolean} moved Whether the slice has been moved from a different part of the editor.
* @protected
*/
_onDrop(view, event, slice, moved) {
// This is a drag-drop of internal editor content which we do not need to handle specially.
if ( moved ) return;
const pos = view.posAtCoords({left: event.clientX, top: event.clientY});
if ( !pos ) return; // This was somehow dropped outside the editor content.
if ( event.dataTransfer.types.some(t => t === "text/uri-list") ) {
const uri = event.dataTransfer.getData("text/uri-list");
if ( !isBase64Data(uri) ) return; // This is a direct URL hotlink which we can just embed without issue.
}
// Handle image drops.
if ( event.dataTransfer.files.length ) {
this._uploadImages(view, event.dataTransfer.files, pos.pos);
return true;
}
}
/* -------------------------------------------- */
/**
* Handle a paste into the editor.
* @param {EditorView} view The ProseMirror editor view.
* @param {ClipboardEvent} event The paste event.
* @protected
*/
_onPaste(view, event) {
if ( event.clipboardData.files.length ) {
this._uploadImages(view, event.clipboardData.files);
return true;
}
const html = event.clipboardData.getData("text/html");
if ( !html ) return; // We only care about handling rich content.
const images = this._extractBase64Images(html);
if ( !images.length ) return; // If there were no base64 images, defer to the default paste handler.
this._replaceBase64Images(view, html, images);
return true;
}
/* -------------------------------------------- */
/**
* Upload any image files encountered in the drop.
* @param {EditorView} view The ProseMirror editor view.
* @param {FileList} files The files to upload.
* @param {number} [pos] The position in the document to insert at. If not provided, the current selection will be
* replaced instead.
* @protected
*/
async _uploadImages(view, files, pos) {
const image = this.schema.nodes.image;
const imageExtensions = Object.keys(CONST.IMAGE_FILE_EXTENSIONS);
for ( const file of files ) {
if ( !hasFileExtension(file.name, imageExtensions) ) continue;
const src = await TextEditor._uploadImage(this.document.uuid, file);
if ( !src ) continue;
const node = image.create({src});
if ( pos === undefined ) {
pos = view.state.selection.from;
view.dispatch(view.state.tr.replaceSelectionWith(node));
} else view.dispatch(view.state.tr.insert(pos, node));
pos += 2; // Advance the position past the just-inserted image so the next image is inserted below it.
}
}
/* -------------------------------------------- */
/**
* Capture any base64-encoded images embedded in the rich text paste and upload them.
* @param {EditorView} view The ProseMirror editor view.
* @param {string} html The HTML data as a string.
* @param {[full: string, mime: string, data: string][]} images An array of extracted base64 image data.
* @protected
*/
async _replaceBase64Images(view, html, images) {
const byMimetype = Object.fromEntries(Object.entries(CONST.IMAGE_FILE_EXTENSIONS).map(([k, v]) => [v, k]));
let cleaned = html;
for ( const [full, mime, data] of images ) {
const file = this.constructor.base64ToFile(data, `pasted-image.${byMimetype[mime]}`, mime);
const path = await TextEditor._uploadImage(this.document.uuid, file) ?? "";
cleaned = cleaned.replace(full, path);
}
const doc = dom.parseString(cleaned);
view.dispatch(view.state.tr.replaceSelectionWith(doc));
}
/* -------------------------------------------- */
/**
* Detect base64 image data embedded in an HTML string and extract it.
* @param {string} html The HTML data as a string.
* @returns {[full: string, mime: string, data: string][]}
* @protected
*/
_extractBase64Images(html) {
const images = Object.values(CONST.IMAGE_FILE_EXTENSIONS);
const rgx = new RegExp(`data:(${images.join("|")});base64,([^"']+)`, "g");
return [...html.matchAll(rgx)];
}
/* -------------------------------------------- */
/**
* Convert a base64 string into a File object.
* @param {string} data Base64 encoded data.
* @param {string} filename The filename.
* @param {string} mimetype The file's mimetype.
* @returns {File}
*/
static base64ToFile(data, filename, mimetype) {
const bin = atob(data);
let n = bin.length;
const buf = new ArrayBuffer(n);
const bytes = new Uint8Array(buf);
while ( n-- ) bytes[n] = bin.charCodeAt(n);
return new File([bytes], filename, {type: mimetype});
}
}

View File

@@ -0,0 +1,133 @@
import {ellipsis, InputRule, inputRules, textblockTypeInputRule, wrappingInputRule} from "prosemirror-inputrules";
import ProseMirrorPlugin from "./plugin.mjs";
/**
* A class responsible for building the input rules for the ProseMirror editor.
* @extends {ProseMirrorPlugin}
*/
export default class ProseMirrorInputRules extends ProseMirrorPlugin {
/**
* Build the plugin.
* @param {Schema} schema The ProseMirror schema to build the plugin against.
* @param {object} [options] Additional options to pass to the plugin.
* @param {number} [options.minHeadingLevel=0] The minimum heading level to start from when generating heading input
* rules. The resulting heading level for a heading rule is equal to the
* number of leading hashes minus this number.
* */
static build(schema, {minHeadingLevel=0}={}) {
const rules = new this(schema, {minHeadingLevel});
return inputRules({rules: rules.buildRules()});
}
/* -------------------------------------------- */
/**
* Build input rules for node types present in the schema.
* @returns {InputRule[]}
*/
buildRules() {
const rules = [ellipsis, ProseMirrorInputRules.#emDashRule()];
if ( "blockquote" in this.schema.nodes ) rules.push(this.#blockQuoteRule());
if ( "ordered_list" in this.schema.nodes ) rules.push(this.#orderedListRule());
if ( "bullet_list" in this.schema.nodes ) rules.push(this.#bulletListRule());
if ( "code_block" in this.schema.nodes ) rules.push(this.#codeBlockRule());
if ( "heading" in this.schema.nodes ) rules.push(this.#headingRule(1, 6));
if ( "horizontal_rule" in this.schema.nodes ) rules.push(this.#hrRule());
return rules;
}
/* -------------------------------------------- */
/**
* Turn a "&gt;" at the start of a textblock into a blockquote.
* @returns {InputRule}
* @private
*/
#blockQuoteRule() {
return wrappingInputRule(/^\s*>\s$/, this.schema.nodes.blockquote);
}
/* -------------------------------------------- */
/**
* Turn a number followed by a dot at the start of a textblock into an ordered list.
* @returns {InputRule}
* @private
*/
#orderedListRule() {
return wrappingInputRule(
/^(\d+)\.\s$/, this.schema.nodes.ordered_list,
match => ({order: Number(match[1])}),
(match, node) => (node.childCount + node.attrs.order) === Number(match[1])
);
}
/* -------------------------------------------- */
/**
* Turn a -, +, or * at the start of a textblock into a bulleted list.
* @returns {InputRule}
* @private
*/
#bulletListRule() {
return wrappingInputRule(/^\s*[-+*]\s$/, this.schema.nodes.bullet_list);
}
/* -------------------------------------------- */
/**
* Turn three backticks at the start of a textblock into a code block.
* @returns {InputRule}
* @private
*/
#codeBlockRule() {
return textblockTypeInputRule(/^```$/, this.schema.nodes.code_block);
}
/* -------------------------------------------- */
/**
* Turns a double dash anywhere into an em-dash. Does not match at the start of the line to avoid conflict with the
* HR rule.
* @returns {InputRule}
* @private
*/
static #emDashRule() {
return new InputRule(/[^-]+(--)/, "—");
}
/* -------------------------------------------- */
/**
* Turns a number of # characters followed by a space at the start of a textblock into a heading up to a maximum
* level.
* @param {number} minLevel The minimum heading level to start generating input rules for.
* @param {number} maxLevel The maximum number of heading levels.
* @returns {InputRule}
* @private
*/
#headingRule(minLevel, maxLevel) {
const range = maxLevel - minLevel + 1;
return textblockTypeInputRule(
new RegExp(`^(#{1,${range}})\\s$`), this.schema.nodes.heading,
match => {
const level = match[1].length;
return {level: level + minLevel - 1};
}
);
}
/* -------------------------------------------- */
/**
* Turns three hyphens at the start of a line into a horizontal rule.
* @returns {InputRule}
* @private
*/
#hrRule() {
const hr = this.schema.nodes.horizontal_rule;
return new InputRule(/^---$/, (state, match, start, end) => {
return state.tr.replaceRangeWith(start, end, hr.create()).scrollIntoView();
});
}
}

View File

@@ -0,0 +1,207 @@
import {keymap} from "prosemirror-keymap";
import {redo, undo} from "prosemirror-history";
import {undoInputRule} from "prosemirror-inputrules";
import {
chainCommands,
exitCode,
joinDown,
joinUp,
lift,
selectParentNode,
setBlockType,
toggleMark
} from "prosemirror-commands";
import {liftListItem, sinkListItem, wrapInList} from "prosemirror-schema-list";
import ProseMirrorPlugin from "./plugin.mjs";
/**
* A class responsible for building the keyboard commands for the ProseMirror editor.
* @extends {ProseMirrorPlugin}
*/
export default class ProseMirrorKeyMaps extends ProseMirrorPlugin {
/**
* @param {Schema} schema The ProseMirror schema to build keymaps for.
* @param {object} [options] Additional options to configure the plugin's behaviour.
* @param {Function} [options.onSave] A function to call when Ctrl+S is pressed.
*/
constructor(schema, {onSave}={}) {
super(schema);
/**
* A function to call when Ctrl+S is pressed.
* @type {Function}
*/
Object.defineProperty(this, "onSave", {value: onSave, writable: false});
}
/* -------------------------------------------- */
/** @inheritdoc */
static build(schema, options={}) {
const keymaps = new this(schema, options);
return keymap(keymaps.buildMapping());
}
/* -------------------------------------------- */
/**
* @callback ProseMirrorCommand
* @param {EditorState} state The current editor state.
* @param {function(Transaction)} dispatch A function to dispatch a transaction.
* @param {EditorView} view Escape-hatch for when the command needs to interact directly with the UI.
* @returns {boolean} Whether the command has performed any action and consumed the event.
*/
/**
* Build keyboard commands for nodes and marks present in the schema.
* @returns {Record<string, ProseMirrorCommand>} An object of keyboard shortcuts to editor functions.
*/
buildMapping() {
// TODO: Figure out how to integrate this with our keybindings system.
const mapping = {};
// Undo, Redo, Backspace.
mapping["Mod-z"] = undo;
mapping["Shift-Mod-z"] = redo;
mapping["Backspace"] = undoInputRule;
// ProseMirror-specific block operations.
mapping["Alt-ArrowUp"] = joinUp;
mapping["Alt-ArrowDown"] = joinDown;
mapping["Mod-BracketLeft"] = lift;
mapping["Escape"] = selectParentNode;
// Bold.
if ( "strong" in this.schema.marks ) {
mapping["Mod-b"] = toggleMark(this.schema.marks.strong);
mapping["Mod-B"] = toggleMark(this.schema.marks.strong);
}
// Italic.
if ( "em" in this.schema.marks ) {
mapping["Mod-i"] = toggleMark(this.schema.marks.em);
mapping["Mod-I"] = toggleMark(this.schema.marks.em);
}
// Underline.
if ( "underline" in this.schema.marks ) {
mapping["Mod-u"] = toggleMark(this.schema.marks.underline);
mapping["Mod-U"] = toggleMark(this.schema.marks.underline);
}
// Inline code.
if ( "code" in this.schema.marks ) mapping["Mod-`"] = toggleMark(this.schema.marks.code);
// Bulleted list.
if ( "bullet_list" in this.schema.nodes ) mapping["Shift-Mod-8"] = wrapInList(this.schema.nodes.bullet_list);
// Numbered list.
if ( "ordered_list" in this.schema.nodes ) mapping["Shift-Mod-9"] = wrapInList(this.schema.nodes.ordered_list);
// Blockquotes.
if ( "blockquote" in this.schema.nodes ) mapping["Mod->"] = wrapInList(this.schema.nodes.blockquote);
// Line breaks.
if ( "hard_break" in this.schema.nodes ) this.#lineBreakMapping(mapping);
// Block splitting.
this.#newLineMapping(mapping);
// List items.
if ( "list_item" in this.schema.nodes ) {
const li = this.schema.nodes.list_item;
mapping["Shift-Tab"] = liftListItem(li);
mapping["Tab"] = sinkListItem(li);
}
// Paragraphs.
if ( "paragraph" in this.schema.nodes ) mapping["Shift-Mod-0"] = setBlockType(this.schema.nodes.paragraph);
// Code blocks.
if ( "code_block" in this.schema.nodes ) mapping["Shift-Mod-\\"] = setBlockType(this.schema.nodes.code_block);
// Headings.
if ( "heading" in this.schema.nodes ) this.#headingsMapping(mapping, 6);
// Horizontal rules.
if ( "horizontal_rule" in this.schema.nodes ) this.#horizontalRuleMapping(mapping);
// Saving.
if ( this.onSave ) this.#addSaveMapping(mapping);
return mapping;
}
/* -------------------------------------------- */
/**
* Implement keyboard commands for heading levels.
* @param {Record<string, ProseMirrorCommand>} mapping The keyboard mapping.
* @param {number} maxLevel The maximum level of headings.
*/
#headingsMapping(mapping, maxLevel) {
const h = this.schema.nodes.heading;
Array.fromRange(maxLevel, 1).forEach(level => mapping[`Shift-Mod-${level}`] = setBlockType(h, {level}));
}
/* -------------------------------------------- */
/**
* Implement keyboard commands for horizontal rules.
* @param {Record<string, ProseMirrorCommand>} mapping The keyboard mapping.
*/
#horizontalRuleMapping(mapping) {
const hr = this.schema.nodes.horizontal_rule;
mapping["Mod-_"] = (state, dispatch) => {
dispatch(state.tr.replaceSelectionWith(hr.create()).scrollIntoView());
return true;
};
}
/* -------------------------------------------- */
/**
* Implement line-break keyboard commands.
* @param {Record<string, ProseMirrorCommand>} mapping The keyboard mapping.
*/
#lineBreakMapping(mapping) {
const br = this.schema.nodes.hard_break;
// Exit a code block if we're in one, then create a line-break.
const cmd = chainCommands(exitCode, (state, dispatch) => {
dispatch(state.tr.replaceSelectionWith(br.create()).scrollIntoView());
return true;
});
mapping["Mod-Enter"] = cmd;
mapping["Shift-Enter"] = cmd;
}
/* -------------------------------------------- */
/**
* Implement some custom logic for how to split special blocks.
* @param {Record<string, ProseMirrorCommand>} mapping The keyboard mapping.
*/
#newLineMapping(mapping) {
const cmds = Object.values(this.schema.nodes).reduce((arr, node) => {
if ( node.split instanceof Function ) arr.push(node.split);
return arr;
}, []);
if ( !cmds.length ) return;
mapping["Enter"] = cmds.length < 2 ? cmds[0] : chainCommands(...cmds);
}
/* -------------------------------------------- */
/**
* Implement save shortcut.
* @param {Record<string, ProseMirrorCommand>} mapping The keyboard mapping.
*/
#addSaveMapping(mapping) {
mapping["Mod-s"] = () => {
this.onSave();
return true;
};
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,37 @@
import ProseMirrorPlugin from "./plugin.mjs";
import { Plugin } from "prosemirror-state";
import { randomID } from "../utils/helpers.mjs";
import { transformSlice } from "./util.mjs";
/**
* A class responsible for applying transformations to content pasted inside the editor.
*/
export default class ProseMirrorPasteTransformer extends ProseMirrorPlugin {
/** @override */
static build(schema, options={}) {
const plugin = new ProseMirrorPasteTransformer(schema);
return new Plugin({
props: {
transformPasted: plugin._onPaste.bind(plugin)
}
});
}
/* -------------------------------------------- */
/**
* Transform content before it is injected into the ProseMirror document.
* @param {Slice} slice The content slice.
* @param {EditorView} view The ProseMirror editor view.
* @returns {Slice} The transformed content.
*/
_onPaste(slice, view) {
// Give pasted secret blocks new IDs.
const secret = view.state.schema.nodes.secret;
return transformSlice(slice, node => {
if ( node.type === secret ) {
return secret.create({ ...node.attrs, id: `secret-${randomID()}` }, node.content, node.marks);
}
});
}
}

View File

@@ -0,0 +1,30 @@
/**
* @abstract
*/
export default class ProseMirrorPlugin {
/**
* An abstract class for building a ProseMirror Plugin.
* @see {Plugin}
* @param {Schema} schema The schema to build the plugin against.
*/
constructor(schema) {
/**
* The ProseMirror schema to build the plugin against.
* @type {Schema}
*/
Object.defineProperty(this, "schema", {value: schema});
}
/* -------------------------------------------- */
/**
* Build the plugin.
* @param {Schema} schema The ProseMirror schema to build the plugin against.
* @param {object} [options] Additional options to pass to the plugin.
* @returns {Plugin}
* @abstract
*/
static build(schema, options={}) {
throw new Error("Subclasses of ProseMirrorPlugin must implement a static build method.");
}
}

View File

@@ -0,0 +1,90 @@
import {Schema} from "prosemirror-model";
import {splitListItem} from "prosemirror-schema-list";
import {
paragraph, blockquote, hr as horizontal_rule, heading, pre as code_block, br as hard_break
} from "./schema/core.mjs";
import {ol as ordered_list, ul as bullet_list, li as list_item, liText as list_item_text} from "./schema/lists.mjs";
import{
builtInTableNodes, tableComplex as table_complex, colgroup, col, thead, tbody, tfoot, caption,
captionBlock as caption_block, tableRowComplex as table_row_complex, tableCellComplex as table_cell_complex,
tableCellComplexBlock as table_cell_complex_block, tableHeaderComplex as table_header_complex,
tableHeaderComplexBlock as table_header_complex_block
} from "./schema/tables.mjs";
import {
details, summary, summaryBlock as summary_block, dl, dt, dd, fieldset, legend, picture, audio, video, track, source,
object, figure, figcaption, small, ruby, rp, rt, iframe
} from "./schema/other.mjs"
import {
superscript, subscript, span, font, em, strong, underline, strikethrough, code
} from "./schema/marks.mjs";
import ImageNode from "./schema/image-node.mjs";
import LinkMark from "./schema/link-mark.mjs";
import ImageLinkNode from "./schema/image-link-node.mjs";
import SecretNode from "./schema/secret-node.mjs";
import AttributeCapture from "./schema/attribute-capture.mjs";
const doc = {
content: "block+"
};
const text = {
group: "inline"
};
const secret = SecretNode.make();
const link = LinkMark.make();
const image = ImageNode.make();
const imageLink = ImageLinkNode.make();
export const nodes = {
// Core Nodes.
doc, text, paragraph, blockquote, secret, horizontal_rule, heading, code_block, image_link: imageLink, image,
hard_break,
// Lists.
ordered_list, bullet_list, list_item, list_item_text,
// Tables
table_complex, tbody, thead, tfoot, caption, caption_block, colgroup, col, table_row_complex, table_cell_complex,
table_header_complex, table_cell_complex_block, table_header_complex_block,
...builtInTableNodes,
// Misc.
details, summary, summary_block, dl, dt, dd, fieldset, legend, picture, audio, video, track, source, object, figure,
figcaption, small, ruby, rp, rt, iframe
};
export const marks = {superscript, subscript, span, font, link, em, strong, underline, strikethrough, code};
// Auto-generated specifications for HTML preservation.
["header", "main", "section", "article", "aside", "nav", "footer", "div", "address"].forEach(tag => {
nodes[tag] = {
content: "block+",
group: "block",
defining: true,
parseDOM: [{tag}],
toDOM: () => [tag, 0]
};
});
["abbr", "cite", "mark", "q", "time", "ins"].forEach(tag => {
marks[tag] = {
parseDOM: [{tag}],
toDOM: () => [tag, 0]
};
});
const all = Object.values(nodes).concat(Object.values(marks));
const capture = new AttributeCapture();
all.forEach(capture.attributeCapture.bind(capture));
export const schema = new Schema({nodes, marks});
/* -------------------------------------------- */
/* Handlers */
/* -------------------------------------------- */
schema.nodes.list_item.split = splitListItem(schema.nodes.list_item);
schema.nodes.secret.split = SecretNode.split;
schema.marks.link.onClick = LinkMark.onClick;
schema.nodes.image_link.onClick = ImageLinkNode.onClick;

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

View File

@@ -0,0 +1,310 @@
import {DOMSerializer} from "prosemirror-model";
import {getType, isEmpty} from "../utils/helpers.mjs";
/**
* @callback ProseMirrorNodeOutput
* @param {Node} node The ProseMirror node.
* @returns {DOMOutputSpec} The specification to build a DOM node for this ProseMirror node.
*/
/**
* @callback ProseMirrorMarkOutput
* @param {Mark} mark The ProseMirror mark.
* @param {boolean} inline Is the mark appearing in an inline context?
* @returns {DOMOutputSpec} The specification to build a DOM node for this ProseMirror mark.
*/
/**
* A class responsible for serializing a ProseMirror document into a string of HTML.
*/
export default class StringSerializer {
/**
* @param {Record<string, ProseMirrorNodeOutput>} nodes The node output specs.
* @param {Record<string, ProseMirrorMarkOutput>} marks The mark output specs.
*/
constructor(nodes, marks) {
this.#nodes = nodes;
this.#marks = marks;
}
/* -------------------------------------------- */
/**
* The node output specs.
* @type {Record<string, ProseMirrorNodeOutput>}
*/
#nodes;
/* -------------------------------------------- */
/**
* The mark output specs.
* @type {Record<string, ProseMirrorMarkOutput>}
*/
#marks;
/* -------------------------------------------- */
/**
* Build a serializer for the given schema.
* @param {Schema} schema The ProseMirror schema.
* @returns {StringSerializer}
*/
static fromSchema(schema) {
if ( schema.cached.stringSerializer ) return schema.cached.stringSerializer;
return schema.cached.stringSerializer =
new StringSerializer(DOMSerializer.nodesFromSchema(schema), DOMSerializer.marksFromSchema(schema));
}
/* -------------------------------------------- */
/**
* Create a StringNode from a ProseMirror DOMOutputSpec.
* @param {DOMOutputSpec} spec The specification.
* @param {boolean} inline Whether this is a block or inline node.
* @returns {{outer: StringNode, [content]: StringNode}} An object describing the outer node, and a reference to the
* child node where content should be appended, if applicable.
* @protected
*/
_specToStringNode(spec, inline) {
if ( typeof spec === "string" ) {
// This is raw text content.
const node = new StringNode();
node.appendChild(spec);
return {outer: node};
}
// Our schema only uses the array type of DOMOutputSpec so we don't need to support the other types here.
// Array specs take the form of [tagName, ...tail], where the tail elements may be an object of attributes, another
// array representing a child spec, or the value 0 (read 'hole').
let attrs = {};
let [tagName, ...tail] = spec;
if ( getType(tail[0]) === "Object" ) attrs = tail.shift();
const outer = new StringNode(tagName, attrs, inline);
let content;
for ( const innerSpec of tail ) {
if ( innerSpec === 0 ) {
if ( tail.length > 1 ) throw new RangeError("Content hole must be the only child of its parent node.");
// The outer node and the node to append content to are the same node. The vast majority of our output specs
// are like this.
return {outer, content: outer};
}
// Otherwise, recursively build any inner specifications and update our content reference to point to wherever the
// hole is found.
const {outer: inner, content: innerContent} = this._specToStringNode(innerSpec, true);
outer.appendChild(inner);
if ( innerContent ) {
if ( content ) throw new RangeError("Multiple content holes.");
content = innerContent;
}
}
return {outer, content};
}
/* -------------------------------------------- */
/**
* Serialize a ProseMirror fragment into an HTML string.
* @param {Fragment} fragment The ProseMirror fragment, a collection of ProseMirror nodes.
* @param {StringNode} [target] The target to append to. Not required for the top-level invocation.
* @returns {StringNode} A DOM tree representation as a StringNode.
*/
serializeFragment(fragment, target) {
target = target ?? new StringNode();
const stack = [];
let parent = target;
fragment.forEach(node => {
/**
* Handling marks is a little complicated as ProseMirror stores them in a 'flat' structure, rather than a
* nested structure that is more natural for HTML. For example, the following HTML:
* <em>Almost before <strong>we knew it</strong>, we had left the ground.</em>
* is represented in ProseMirror's internal structure as:
* {marks: [ITALIC], content: "Almost before "}, {marks: [ITALIC, BOLD], content: "we knew it"},
* {marks: [ITALIC], content: ", we had left the ground"}
* In order to translate from the latter back into the former, we maintain a stack. When we see a new mark, we
* push it onto the stack so that content is appended to that mark. When the mark stops appearing in subsequent
* nodes, we pop off the stack until we find a mark that does exist, and start appending to that one again.
*
* The order that marks appear in the node.marks array is guaranteed to be the order that they were declared in
* the schema.
*/
if ( stack.length || node.marks.length ) {
// Walk along the stack to find a mark that is not already pending (i.e. we haven't seen it yet).
let pos = 0;
while ( (pos < stack.length) && (pos < node.marks.length) ) {
const next = node.marks[pos];
// If the mark does not span multiple nodes, we can serialize it now rather than waiting.
if ( !next.eq(stack[pos].mark) || (next.type.spec.spanning === false) ) break;
pos++;
}
// Pop off the stack to reach the position of our mark.
while ( pos < stack.length ) parent = stack.pop().parent;
// Add the marks from this point.
for ( let i = pos; i < node.marks.length; i++ ) {
const mark = node.marks[i];
const {outer, content} = this._serializeMark(mark, node.isInline);
stack.push({mark, parent});
parent.appendChild(outer);
parent = content ?? outer;
}
}
// Finally append the content to whichever parent node we've arrived at.
parent.appendChild(this._toStringNode(node));
});
return target;
}
/* -------------------------------------------- */
/**
* Convert a ProseMirror node representation to a StringNode.
* @param {Node} node The ProseMirror node.
* @returns {StringNode}
* @protected
*/
_toStringNode(node) {
const {outer, content} = this._specToStringNode(this.#nodes[node.type.name](node), node.type.inlineContent);
if ( content ) {
if ( node.isLeaf ) throw new RangeError("Content hole not allowed in a leaf node spec.");
this.serializeFragment(node.content, content);
}
return outer;
}
/* -------------------------------------------- */
/**
* Convert a ProseMirror mark representation to a StringNode.
* @param {Mark} mark The ProseMirror mark.
* @param {boolean} inline Does the mark appear in an inline context?
* @returns {{outer: StringNode, [content]: StringNode}}
* @protected
*/
_serializeMark(mark, inline) {
return this._specToStringNode(this.#marks[mark.type.name](mark, inline), true);
}
}
/**
* A class that behaves like a lightweight DOM node, allowing children to be appended. Serializes to an HTML string.
*/
class StringNode {
/**
* @param {string} [tag] The tag name. If none is provided, this node's children will not be wrapped in an
* outer tag.
* @param {Record<string, string>} [attrs] The tag attributes.
* @param {boolean} [inline=false] Whether the node appears inline or as a block.
*/
constructor(tag, attrs={}, inline=true) {
/**
* The tag name.
* @type {string}
*/
Object.defineProperty(this, "tag", {value: tag, writable: false});
/**
* The tag attributes.
* @type {Record<string, string>}
*/
Object.defineProperty(this, "attrs", {value: attrs, writable: false});
this.#inline = inline;
}
/* -------------------------------------------- */
/**
* A list of HTML void elements that do not have a closing tag.
* @type {Set<string>}
*/
static #VOID = new Set([
"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"
]);
/* -------------------------------------------- */
/**
* A list of children. Either other StringNodes, or plain strings.
* @type {Array<StringNode|string>}
* @private
*/
#children = [];
/* -------------------------------------------- */
/**
* @ignore
*/
#inline;
/**
* Whether the node appears inline or as a block.
*/
get inline() {
if ( !this.tag || StringNode.#VOID.has(this.tag) || !this.#children.length ) return true;
return this.#inline;
}
/* -------------------------------------------- */
/**
* Append a child to this string node.
* @param {StringNode|string} child The child node or string.
* @throws If attempting to append a child to a void element.
*/
appendChild(child) {
if ( StringNode.#VOID.has(this.tag) ) throw new Error("Void elements cannot contain children.");
this.#children.push(child);
}
/* -------------------------------------------- */
/**
* Serialize the StringNode structure into a single string.
* @param {string|number} spaces The number of spaces to use for indentation (maximum 10). If this value is a string,
* that string is used as indentation instead (or the first 10 characters if it is
* longer).
*/
toString(spaces=0, {_depth=0, _inlineParent=false}={}) {
let indent = "";
const isRoot = _depth < 1;
if ( !_inlineParent ) {
if ( typeof spaces === "number" ) indent = " ".repeat(Math.min(10, spaces));
else if ( typeof spaces === "string" ) indent = spaces.substring(0, 10);
indent = indent.repeat(Math.max(0, _depth - 1));
}
const attrs = isEmpty(this.attrs) ? "" : " " + Object.entries(this.attrs).map(([k, v]) => `${k}="${v}"`).join(" ");
const open = this.tag ? `${indent}<${this.tag}${attrs}>` : "";
if ( StringNode.#VOID.has(this.tag) ) return open;
const close = this.tag ? `${this.inline && !isRoot ? "" : indent}</${this.tag}>` : "";
const children = this.#children.map(c => {
let content = c.toString(spaces, {_depth: _depth + 1, _inlineParent: this.inline});
if ( !isRoot && !this.tag ) content = StringNode.#escapeHTML(content);
return content;
});
const lineBreak = (this.inline && !isRoot) || !spaces ? "" : "\n";
return [open, ...children, close].filterJoin(lineBreak);
}
/* -------------------------------------------- */
/**
* Escape HTML tags within string content.
* @param {string} content The string content.
* @returns {string}
*/
static #escapeHTML(content) {
return content.replace(/[<>]/g, char => {
switch ( char ) {
case "<": return "&lt;";
case ">": return "&gt;";
}
return char;
});
}
}

View File

@@ -0,0 +1,73 @@
import {defaultSchema} from "./_module.mjs";
import DOMParser from "./dom-parser.mjs";
import StringSerializer from "./string-serializer.mjs";
import { Slice } from "prosemirror-model";
/**
* Use the DOM and ProseMirror's DOMParser to construct a ProseMirror document state from an HTML string. This cannot be
* used server-side.
* @param {string} htmlString A string of HTML.
* @param {Schema} [schema] The ProseMirror schema to use instead of the default one.
* @returns {Node} The document node.
*/
export function parseHTMLString(htmlString, schema) {
const target = document.createElement("template");
target.innerHTML = htmlString;
return DOMParser.fromSchema(schema ?? defaultSchema).parse(target.content);
}
/**
* Use the StringSerializer to convert a ProseMirror document into an HTML string. This can be used server-side.
* @param {Node} doc The ProseMirror document.
* @param {object} [options] Additional options to configure serialization behavior.
* @param {Schema} [options.schema] The ProseMirror schema to use instead of the default one.
* @param {string|number} [options.spaces] The number of spaces to use for indentation. See {@link StringNode#toString}
* for details.
* @returns {string}
*/
export function serializeHTMLString(doc, {schema, spaces}={}) {
schema = schema ?? defaultSchema;
// If the only content is an empty <p></p> tag, return an empty string.
if ( (doc.size < 3) && (doc.content[0].type === schema.nodes.paragraph) ) return "";
return StringSerializer.fromSchema(schema).serializeFragment(doc.content).toString(spaces);
}
/**
* @callback ProseMirrorSliceTransformer
* @param {Node} node The candidate node.
* @returns {Node|void} A new node to replace the candidate node, or nothing if a replacement should not be made.
*/
/**
* Apply a transformation to some nodes in a slice, and return the new slice.
* @param {Slice} slice The slice to transform.
* @param {function} transformer The transformation function.
* @returns {Slice} Either the original slice if no changes were made, or the newly-transformed slice.
*/
export function transformSlice(slice, transformer) {
const nodeTree = new Map();
slice.content.nodesBetween(0, slice.content.size, (node, start, parent, index) => {
nodeTree.set(node, { parent, index });
});
let newSlice;
const replaceNode = (node, { parent, index }) => {
// If there is a parent, make the replacement, then recurse up the tree to the root, creating new nodes as we go.
if ( parent ) {
const newContent = parent.content.replaceChild(index, node);
const newParent = parent.copy(newContent);
replaceNode(newParent, nodeTree.get(parent));
return;
}
// Otherwise, handle replacing the root slice's content.
const targetSlice = newSlice ?? slice;
const fragment = targetSlice.content;
const newFragment = fragment.replaceChild(index, node);
newSlice = new Slice(newFragment, targetSlice.openStart, targetSlice.openEnd);
}
for ( const [node, treeInfo] of nodeTree.entries() ) {
const newNode = transformer(node);
if ( newNode ) replaceNode(newNode, treeInfo);
}
return newSlice ?? slice;
}