Add coexistence checks to all enqueue methods to prevent loading both React and Grid.js assets simultaneously. Changes: - ReactAdmin.php: Only enqueue React assets when ?react=1 - Init.php: Skip Grid.js when React active on admin pages - Form.php, Coupon.php, Access.php: Restore classic assets when ?react=0 - Customer.php, Product.php, License.php: Add coexistence checks Now the toggle between Classic and React versions works correctly. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
594 lines
18 KiB
JavaScript
594 lines
18 KiB
JavaScript
"use strict";
|
|
|
|
Object.defineProperty(exports, "__esModule", {
|
|
value: true
|
|
});
|
|
exports.RichTextData = void 0;
|
|
exports.create = create;
|
|
exports.removeReservedCharacters = removeReservedCharacters;
|
|
var _data = require("@wordpress/data");
|
|
var _store = require("./store");
|
|
var _createElement = require("./create-element");
|
|
var _concat = require("./concat");
|
|
var _specialCharacters = require("./special-characters");
|
|
var _toHtmlString = require("./to-html-string");
|
|
var _getTextContent = require("./get-text-content");
|
|
/**
|
|
* WordPress dependencies
|
|
*/
|
|
|
|
/**
|
|
* Internal dependencies
|
|
*/
|
|
|
|
/** @typedef {import('./types').RichTextValue} RichTextValue */
|
|
|
|
function createEmptyValue() {
|
|
return {
|
|
formats: [],
|
|
replacements: [],
|
|
text: ''
|
|
};
|
|
}
|
|
function toFormat({
|
|
tagName,
|
|
attributes
|
|
}) {
|
|
let formatType;
|
|
if (attributes && attributes.class) {
|
|
formatType = (0, _data.select)(_store.store).getFormatTypeForClassName(attributes.class);
|
|
if (formatType) {
|
|
// Preserve any additional classes.
|
|
attributes.class = ` ${attributes.class} `.replace(` ${formatType.className} `, ' ').trim();
|
|
if (!attributes.class) {
|
|
delete attributes.class;
|
|
}
|
|
}
|
|
}
|
|
if (!formatType) {
|
|
formatType = (0, _data.select)(_store.store).getFormatTypeForBareElement(tagName);
|
|
}
|
|
if (!formatType) {
|
|
return attributes ? {
|
|
type: tagName,
|
|
attributes
|
|
} : {
|
|
type: tagName
|
|
};
|
|
}
|
|
if (formatType.__experimentalCreatePrepareEditableTree && !formatType.__experimentalCreateOnChangeEditableValue) {
|
|
return null;
|
|
}
|
|
if (!attributes) {
|
|
return {
|
|
formatType,
|
|
type: formatType.name,
|
|
tagName
|
|
};
|
|
}
|
|
const registeredAttributes = {};
|
|
const unregisteredAttributes = {};
|
|
const _attributes = {
|
|
...attributes
|
|
};
|
|
for (const key in formatType.attributes) {
|
|
const name = formatType.attributes[key];
|
|
registeredAttributes[key] = _attributes[name];
|
|
|
|
// delete the attribute and what's left is considered
|
|
// to be unregistered.
|
|
delete _attributes[name];
|
|
if (typeof registeredAttributes[key] === 'undefined') {
|
|
delete registeredAttributes[key];
|
|
}
|
|
}
|
|
for (const name in _attributes) {
|
|
unregisteredAttributes[name] = attributes[name];
|
|
}
|
|
if (formatType.contentEditable === false) {
|
|
delete unregisteredAttributes.contenteditable;
|
|
}
|
|
return {
|
|
formatType,
|
|
type: formatType.name,
|
|
tagName,
|
|
attributes: registeredAttributes,
|
|
unregisteredAttributes
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The RichTextData class is used to instantiate a wrapper around rich text
|
|
* values, with methods that can be used to transform or manipulate the data.
|
|
*
|
|
* - Create an empty instance: `new RichTextData()`.
|
|
* - Create one from an HTML string: `RichTextData.fromHTMLString(
|
|
* '<em>hello</em>' )`.
|
|
* - Create one from a wrapper HTMLElement: `RichTextData.fromHTMLElement(
|
|
* document.querySelector( 'p' ) )`.
|
|
* - Create one from plain text: `RichTextData.fromPlainText( '1\n2' )`.
|
|
* - Create one from a rich text value: `new RichTextData( { text: '...',
|
|
* formats: [ ... ] } )`.
|
|
*
|
|
* @todo Add methods to manipulate the data, such as applyFormat, slice etc.
|
|
*/
|
|
class RichTextData {
|
|
#value;
|
|
static empty() {
|
|
return new RichTextData();
|
|
}
|
|
static fromPlainText(text) {
|
|
return new RichTextData(create({
|
|
text
|
|
}));
|
|
}
|
|
static fromHTMLString(html) {
|
|
return new RichTextData(create({
|
|
html
|
|
}));
|
|
}
|
|
static fromHTMLElement(htmlElement, options = {}) {
|
|
const {
|
|
preserveWhiteSpace = false
|
|
} = options;
|
|
const element = preserveWhiteSpace ? htmlElement : collapseWhiteSpace(htmlElement);
|
|
const richTextData = new RichTextData(create({
|
|
element
|
|
}));
|
|
Object.defineProperty(richTextData, 'originalHTML', {
|
|
value: htmlElement.innerHTML
|
|
});
|
|
return richTextData;
|
|
}
|
|
constructor(init = createEmptyValue()) {
|
|
this.#value = init;
|
|
}
|
|
toPlainText() {
|
|
return (0, _getTextContent.getTextContent)(this.#value);
|
|
}
|
|
// We could expose `toHTMLElement` at some point as well, but we'd only use
|
|
// it internally.
|
|
toHTMLString({
|
|
preserveWhiteSpace
|
|
} = {}) {
|
|
return this.originalHTML || (0, _toHtmlString.toHTMLString)({
|
|
value: this.#value,
|
|
preserveWhiteSpace
|
|
});
|
|
}
|
|
valueOf() {
|
|
return this.toHTMLString();
|
|
}
|
|
toString() {
|
|
return this.toHTMLString();
|
|
}
|
|
toJSON() {
|
|
return this.toHTMLString();
|
|
}
|
|
get length() {
|
|
return this.text.length;
|
|
}
|
|
get formats() {
|
|
return this.#value.formats;
|
|
}
|
|
get replacements() {
|
|
return this.#value.replacements;
|
|
}
|
|
get text() {
|
|
return this.#value.text;
|
|
}
|
|
}
|
|
exports.RichTextData = RichTextData;
|
|
for (const name of Object.getOwnPropertyNames(String.prototype)) {
|
|
if (RichTextData.prototype.hasOwnProperty(name)) {
|
|
continue;
|
|
}
|
|
Object.defineProperty(RichTextData.prototype, name, {
|
|
value(...args) {
|
|
// Should we convert back to RichTextData?
|
|
return this.toHTMLString()[name](...args);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Create a RichText value from an `Element` tree (DOM), an HTML string or a
|
|
* plain text string, with optionally a `Range` object to set the selection. If
|
|
* called without any input, an empty value will be created. The optional
|
|
* functions can be used to filter out content.
|
|
*
|
|
* A value will have the following shape, which you are strongly encouraged not
|
|
* to modify without the use of helper functions:
|
|
*
|
|
* ```js
|
|
* {
|
|
* text: string,
|
|
* formats: Array,
|
|
* replacements: Array,
|
|
* ?start: number,
|
|
* ?end: number,
|
|
* }
|
|
* ```
|
|
*
|
|
* As you can see, text and formatting are separated. `text` holds the text,
|
|
* including any replacement characters for objects and lines. `formats`,
|
|
* `objects` and `lines` are all sparse arrays of the same length as `text`. It
|
|
* holds information about the formatting at the relevant text indices. Finally
|
|
* `start` and `end` state which text indices are selected. They are only
|
|
* provided if a `Range` was given.
|
|
*
|
|
* @param {Object} [$1] Optional named arguments.
|
|
* @param {Element} [$1.element] Element to create value from.
|
|
* @param {string} [$1.text] Text to create value from.
|
|
* @param {string} [$1.html] HTML to create value from.
|
|
* @param {Range} [$1.range] Range to create value from.
|
|
* @param {boolean} [$1.__unstableIsEditableTree]
|
|
* @return {RichTextValue} A rich text value.
|
|
*/
|
|
function create({
|
|
element,
|
|
text,
|
|
html,
|
|
range,
|
|
__unstableIsEditableTree: isEditableTree
|
|
} = {}) {
|
|
if (html instanceof RichTextData) {
|
|
return {
|
|
text: html.text,
|
|
formats: html.formats,
|
|
replacements: html.replacements
|
|
};
|
|
}
|
|
if (typeof text === 'string' && text.length > 0) {
|
|
return {
|
|
formats: Array(text.length),
|
|
replacements: Array(text.length),
|
|
text
|
|
};
|
|
}
|
|
if (typeof html === 'string' && html.length > 0) {
|
|
// It does not matter which document this is, we're just using it to
|
|
// parse.
|
|
element = (0, _createElement.createElement)(document, html);
|
|
}
|
|
if (typeof element !== 'object') {
|
|
return createEmptyValue();
|
|
}
|
|
return createFromElement({
|
|
element,
|
|
range,
|
|
isEditableTree
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Helper to accumulate the value's selection start and end from the current
|
|
* node and range.
|
|
*
|
|
* @param {Object} accumulator Object to accumulate into.
|
|
* @param {Node} node Node to create value with.
|
|
* @param {Range} range Range to create value with.
|
|
* @param {Object} value Value that is being accumulated.
|
|
*/
|
|
function accumulateSelection(accumulator, node, range, value) {
|
|
if (!range) {
|
|
return;
|
|
}
|
|
const {
|
|
parentNode
|
|
} = node;
|
|
const {
|
|
startContainer,
|
|
startOffset,
|
|
endContainer,
|
|
endOffset
|
|
} = range;
|
|
const currentLength = accumulator.text.length;
|
|
|
|
// Selection can be extracted from value.
|
|
if (value.start !== undefined) {
|
|
accumulator.start = currentLength + value.start;
|
|
// Range indicates that the current node has selection.
|
|
} else if (node === startContainer && node.nodeType === node.TEXT_NODE) {
|
|
accumulator.start = currentLength + startOffset;
|
|
// Range indicates that the current node is selected.
|
|
} else if (parentNode === startContainer && node === startContainer.childNodes[startOffset]) {
|
|
accumulator.start = currentLength;
|
|
// Range indicates that the selection is after the current node.
|
|
} else if (parentNode === startContainer && node === startContainer.childNodes[startOffset - 1]) {
|
|
accumulator.start = currentLength + value.text.length;
|
|
// Fallback if no child inside handled the selection.
|
|
} else if (node === startContainer) {
|
|
accumulator.start = currentLength;
|
|
}
|
|
|
|
// Selection can be extracted from value.
|
|
if (value.end !== undefined) {
|
|
accumulator.end = currentLength + value.end;
|
|
// Range indicates that the current node has selection.
|
|
} else if (node === endContainer && node.nodeType === node.TEXT_NODE) {
|
|
accumulator.end = currentLength + endOffset;
|
|
// Range indicates that the current node is selected.
|
|
} else if (parentNode === endContainer && node === endContainer.childNodes[endOffset - 1]) {
|
|
accumulator.end = currentLength + value.text.length;
|
|
// Range indicates that the selection is before the current node.
|
|
} else if (parentNode === endContainer && node === endContainer.childNodes[endOffset]) {
|
|
accumulator.end = currentLength;
|
|
// Fallback if no child inside handled the selection.
|
|
} else if (node === endContainer) {
|
|
accumulator.end = currentLength + endOffset;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Adjusts the start and end offsets from a range based on a text filter.
|
|
*
|
|
* @param {Node} node Node of which the text should be filtered.
|
|
* @param {Range} range The range to filter.
|
|
* @param {Function} filter Function to use to filter the text.
|
|
*
|
|
* @return {Object|void} Object containing range properties.
|
|
*/
|
|
function filterRange(node, range, filter) {
|
|
if (!range) {
|
|
return;
|
|
}
|
|
const {
|
|
startContainer,
|
|
endContainer
|
|
} = range;
|
|
let {
|
|
startOffset,
|
|
endOffset
|
|
} = range;
|
|
if (node === startContainer) {
|
|
startOffset = filter(node.nodeValue.slice(0, startOffset)).length;
|
|
}
|
|
if (node === endContainer) {
|
|
endOffset = filter(node.nodeValue.slice(0, endOffset)).length;
|
|
}
|
|
return {
|
|
startContainer,
|
|
startOffset,
|
|
endContainer,
|
|
endOffset
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Collapse any whitespace used for HTML formatting to one space character,
|
|
* because it will also be displayed as such by the browser.
|
|
*
|
|
* We need to strip it from the content because we use white-space: pre-wrap for
|
|
* displaying editable rich text. Without using white-space: pre-wrap, the
|
|
* browser will litter the content with non breaking spaces, among other issues.
|
|
* See packages/rich-text/src/component/use-default-style.js.
|
|
*
|
|
* @see
|
|
* https://developer.mozilla.org/en-US/docs/Web/CSS/white-space-collapse#collapsing_of_white_space
|
|
*
|
|
* @param {HTMLElement} element
|
|
* @param {boolean} isRoot
|
|
*
|
|
* @return {HTMLElement} New element with collapsed whitespace.
|
|
*/
|
|
function collapseWhiteSpace(element, isRoot = true) {
|
|
const clone = element.cloneNode(true);
|
|
clone.normalize();
|
|
Array.from(clone.childNodes).forEach((node, i, nodes) => {
|
|
if (node.nodeType === node.TEXT_NODE) {
|
|
let newNodeValue = node.nodeValue;
|
|
if (/[\n\t\r\f]/.test(newNodeValue)) {
|
|
newNodeValue = newNodeValue.replace(/[\n\t\r\f]+/g, ' ');
|
|
}
|
|
if (newNodeValue.indexOf(' ') !== -1) {
|
|
newNodeValue = newNodeValue.replace(/ {2,}/g, ' ');
|
|
}
|
|
if (i === 0 && newNodeValue.startsWith(' ')) {
|
|
newNodeValue = newNodeValue.slice(1);
|
|
} else if (isRoot && i === nodes.length - 1 && newNodeValue.endsWith(' ')) {
|
|
newNodeValue = newNodeValue.slice(0, -1);
|
|
}
|
|
node.nodeValue = newNodeValue;
|
|
} else if (node.nodeType === node.ELEMENT_NODE) {
|
|
collapseWhiteSpace(node, false);
|
|
}
|
|
});
|
|
return clone;
|
|
}
|
|
|
|
/**
|
|
* We need to normalise line breaks to `\n` so they are consistent across
|
|
* platforms and serialised properly. Not removing \r would cause it to
|
|
* linger and result in double line breaks when whitespace is preserved.
|
|
*/
|
|
const CARRIAGE_RETURN = '\r';
|
|
|
|
/**
|
|
* Removes reserved characters used by rich-text (zero width non breaking spaces
|
|
* added by `toTree` and object replacement characters).
|
|
*
|
|
* @param {string} string
|
|
*/
|
|
function removeReservedCharacters(string) {
|
|
// with the global flag, note that we should create a new regex each time OR
|
|
// reset lastIndex state.
|
|
return string.replace(new RegExp(`[${_specialCharacters.ZWNBSP}${_specialCharacters.OBJECT_REPLACEMENT_CHARACTER}${CARRIAGE_RETURN}]`, 'gu'), '');
|
|
}
|
|
|
|
/**
|
|
* Creates a Rich Text value from a DOM element and range.
|
|
*
|
|
* @param {Object} $1 Named argements.
|
|
* @param {Element} [$1.element] Element to create value from.
|
|
* @param {Range} [$1.range] Range to create value from.
|
|
* @param {boolean} [$1.isEditableTree]
|
|
*
|
|
* @return {RichTextValue} A rich text value.
|
|
*/
|
|
function createFromElement({
|
|
element,
|
|
range,
|
|
isEditableTree
|
|
}) {
|
|
const accumulator = createEmptyValue();
|
|
if (!element) {
|
|
return accumulator;
|
|
}
|
|
if (!element.hasChildNodes()) {
|
|
accumulateSelection(accumulator, element, range, createEmptyValue());
|
|
return accumulator;
|
|
}
|
|
const length = element.childNodes.length;
|
|
|
|
// Optimise for speed.
|
|
for (let index = 0; index < length; index++) {
|
|
const node = element.childNodes[index];
|
|
const tagName = node.nodeName.toLowerCase();
|
|
if (node.nodeType === node.TEXT_NODE) {
|
|
const text = removeReservedCharacters(node.nodeValue);
|
|
range = filterRange(node, range, removeReservedCharacters);
|
|
accumulateSelection(accumulator, node, range, {
|
|
text
|
|
});
|
|
// Create a sparse array of the same length as `text`, in which
|
|
// formats can be added.
|
|
accumulator.formats.length += text.length;
|
|
accumulator.replacements.length += text.length;
|
|
accumulator.text += text;
|
|
continue;
|
|
}
|
|
if (node.nodeType !== node.ELEMENT_NODE) {
|
|
continue;
|
|
}
|
|
if (isEditableTree &&
|
|
// Ignore any line breaks that are not inserted by us.
|
|
tagName === 'br' && !node.getAttribute('data-rich-text-line-break')) {
|
|
accumulateSelection(accumulator, node, range, createEmptyValue());
|
|
continue;
|
|
}
|
|
if (tagName === 'script') {
|
|
const value = {
|
|
formats: [,],
|
|
replacements: [{
|
|
type: tagName,
|
|
attributes: {
|
|
'data-rich-text-script': node.getAttribute('data-rich-text-script') || encodeURIComponent(node.innerHTML)
|
|
}
|
|
}],
|
|
text: _specialCharacters.OBJECT_REPLACEMENT_CHARACTER
|
|
};
|
|
accumulateSelection(accumulator, node, range, value);
|
|
(0, _concat.mergePair)(accumulator, value);
|
|
continue;
|
|
}
|
|
if (tagName === 'br') {
|
|
accumulateSelection(accumulator, node, range, createEmptyValue());
|
|
(0, _concat.mergePair)(accumulator, create({
|
|
text: '\n'
|
|
}));
|
|
continue;
|
|
}
|
|
const format = toFormat({
|
|
tagName,
|
|
attributes: getAttributes({
|
|
element: node
|
|
})
|
|
});
|
|
|
|
// When a format type is declared as not editable, replace it with an
|
|
// object replacement character and preserve the inner HTML.
|
|
if (format?.formatType?.contentEditable === false) {
|
|
delete format.formatType;
|
|
accumulateSelection(accumulator, node, range, createEmptyValue());
|
|
(0, _concat.mergePair)(accumulator, {
|
|
formats: [,],
|
|
replacements: [{
|
|
...format,
|
|
innerHTML: node.innerHTML
|
|
}],
|
|
text: _specialCharacters.OBJECT_REPLACEMENT_CHARACTER
|
|
});
|
|
continue;
|
|
}
|
|
if (format) {
|
|
delete format.formatType;
|
|
}
|
|
const value = createFromElement({
|
|
element: node,
|
|
range,
|
|
isEditableTree
|
|
});
|
|
accumulateSelection(accumulator, node, range, value);
|
|
|
|
// Ignore any placeholders, but keep their content since the browser
|
|
// might insert text inside them when the editable element is flex.
|
|
if (!format || node.getAttribute('data-rich-text-placeholder')) {
|
|
(0, _concat.mergePair)(accumulator, value);
|
|
} else if (value.text.length === 0) {
|
|
if (format.attributes) {
|
|
(0, _concat.mergePair)(accumulator, {
|
|
formats: [,],
|
|
replacements: [format],
|
|
text: _specialCharacters.OBJECT_REPLACEMENT_CHARACTER
|
|
});
|
|
}
|
|
} else {
|
|
// Indices should share a reference to the same formats array.
|
|
// Only create a new reference if `formats` changes.
|
|
function mergeFormats(formats) {
|
|
if (mergeFormats.formats === formats) {
|
|
return mergeFormats.newFormats;
|
|
}
|
|
const newFormats = formats ? [format, ...formats] : [format];
|
|
mergeFormats.formats = formats;
|
|
mergeFormats.newFormats = newFormats;
|
|
return newFormats;
|
|
}
|
|
|
|
// Since the formats parameter can be `undefined`, preset
|
|
// `mergeFormats` with a new reference.
|
|
mergeFormats.newFormats = [format];
|
|
(0, _concat.mergePair)(accumulator, {
|
|
...value,
|
|
formats: Array.from(value.formats, mergeFormats)
|
|
});
|
|
}
|
|
}
|
|
return accumulator;
|
|
}
|
|
|
|
/**
|
|
* Gets the attributes of an element in object shape.
|
|
*
|
|
* @param {Object} $1 Named argements.
|
|
* @param {Element} $1.element Element to get attributes from.
|
|
*
|
|
* @return {Object|void} Attribute object or `undefined` if the element has no
|
|
* attributes.
|
|
*/
|
|
function getAttributes({
|
|
element
|
|
}) {
|
|
if (!element.hasAttributes()) {
|
|
return;
|
|
}
|
|
const length = element.attributes.length;
|
|
let accumulator;
|
|
|
|
// Optimise for speed.
|
|
for (let i = 0; i < length; i++) {
|
|
const {
|
|
name,
|
|
value
|
|
} = element.attributes[i];
|
|
if (name.indexOf('data-rich-text-') === 0) {
|
|
continue;
|
|
}
|
|
const safeName = /^on/i.test(name) ? 'data-disable-rich-text-' + name : name;
|
|
accumulator = accumulator || {};
|
|
accumulator[safeName] = value;
|
|
}
|
|
return accumulator;
|
|
}
|
|
//# sourceMappingURL=create.js.map
|