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