fix: prevent asset conflicts between React and Grid.js versions

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>
This commit is contained in:
dwindown
2026-04-18 17:02:14 +07:00
parent bd9cdac02e
commit e8fbfb14c1
74973 changed files with 6658406 additions and 71 deletions

113
node_modules/@wordpress/rich-text/src/apply-format.js generated vendored Normal file
View File

@@ -0,0 +1,113 @@
/**
* Internal dependencies
*/
import { normaliseFormats } from './normalise-formats';
/** @typedef {import('./types').RichTextValue} RichTextValue */
/** @typedef {import('./types').RichTextFormat} RichTextFormat */
function replace( array, index, value ) {
array = array.slice();
array[ index ] = value;
return array;
}
/**
* Apply a format object to a Rich Text value from the given `startIndex` to the
* given `endIndex`. Indices are retrieved from the selection if none are
* provided.
*
* @param {RichTextValue} value Value to modify.
* @param {RichTextFormat} format Format to apply.
* @param {number} [startIndex] Start index.
* @param {number} [endIndex] End index.
*
* @return {RichTextValue} A new value with the format applied.
*/
export function applyFormat(
value,
format,
startIndex = value.start,
endIndex = value.end
) {
const { formats, activeFormats } = value;
const newFormats = formats.slice();
// The selection is collapsed.
if ( startIndex === endIndex ) {
const startFormat = newFormats[ startIndex ]?.find(
( { type } ) => type === format.type
);
// If the caret is at a format of the same type, expand start and end to
// the edges of the format. This is useful to apply new attributes.
if ( startFormat ) {
const index = newFormats[ startIndex ].indexOf( startFormat );
while (
newFormats[ startIndex ] &&
newFormats[ startIndex ][ index ] === startFormat
) {
newFormats[ startIndex ] = replace(
newFormats[ startIndex ],
index,
format
);
startIndex--;
}
endIndex++;
while (
newFormats[ endIndex ] &&
newFormats[ endIndex ][ index ] === startFormat
) {
newFormats[ endIndex ] = replace(
newFormats[ endIndex ],
index,
format
);
endIndex++;
}
}
} else {
// Determine the highest position the new format can be inserted at.
let position = +Infinity;
for ( let index = startIndex; index < endIndex; index++ ) {
if ( newFormats[ index ] ) {
newFormats[ index ] = newFormats[ index ].filter(
( { type } ) => type !== format.type
);
const length = newFormats[ index ].length;
if ( length < position ) {
position = length;
}
} else {
newFormats[ index ] = [];
position = 0;
}
}
for ( let index = startIndex; index < endIndex; index++ ) {
newFormats[ index ].splice( position, 0, format );
}
}
return normaliseFormats( {
...value,
formats: newFormats,
// Always revise active formats. This serves as a placeholder for new
// inputs with the format so new input appears with the format applied,
// and ensures a format of the same type uses the latest values.
activeFormats: [
...( activeFormats?.filter(
( { type } ) => type !== format.type
) || [] ),
format,
],
} );
}

View File

@@ -0,0 +1,41 @@
/**
* Internal dependencies
*/
import { toHTMLString } from '../../to-html-string';
import { isCollapsed } from '../../is-collapsed';
import { slice } from '../../slice';
import { getTextContent } from '../../get-text-content';
export default ( props ) => ( element ) => {
function onCopy( event ) {
const { record } = props.current;
const { ownerDocument } = element;
if (
isCollapsed( record.current ) ||
! element.contains( ownerDocument.activeElement )
) {
return;
}
const selectedRecord = slice( record.current );
const plainText = getTextContent( selectedRecord );
const html = toHTMLString( { value: selectedRecord } );
event.clipboardData.setData( 'text/plain', plainText );
event.clipboardData.setData( 'text/html', html );
event.clipboardData.setData( 'rich-text', 'true' );
event.preventDefault();
if ( event.type === 'cut' ) {
ownerDocument.execCommand( 'delete' );
}
}
const { defaultView } = element.ownerDocument;
defaultView.addEventListener( 'copy', onCopy );
defaultView.addEventListener( 'cut', onCopy );
return () => {
defaultView.removeEventListener( 'copy', onCopy );
defaultView.removeEventListener( 'cut', onCopy );
};
};

View File

@@ -0,0 +1,38 @@
/**
* WordPress dependencies
*/
import { BACKSPACE, DELETE } from '@wordpress/keycodes';
/**
* Internal dependencies
*/
import { remove } from '../../remove';
export default ( props ) => ( element ) => {
function onKeyDown( event ) {
const { keyCode } = event;
const { createRecord, handleChange } = props.current;
if ( event.defaultPrevented ) {
return;
}
if ( keyCode !== DELETE && keyCode !== BACKSPACE ) {
return;
}
const currentValue = createRecord();
const { start, end, text } = currentValue;
// Always handle full content deletion ourselves.
if ( start === 0 && end !== 0 && end === text.length ) {
handleChange( remove( currentValue ) );
event.preventDefault();
}
}
element.addEventListener( 'keydown', onKeyDown );
return () => {
element.removeEventListener( 'keydown', onKeyDown );
};
};

View File

@@ -0,0 +1,103 @@
/**
* WordPress dependencies
*/
import { LEFT, RIGHT } from '@wordpress/keycodes';
/**
* Internal dependencies
*/
import { isCollapsed } from '../../is-collapsed';
const EMPTY_ACTIVE_FORMATS = [];
export default ( props ) => ( element ) => {
function onKeyDown( event ) {
const { keyCode, shiftKey, altKey, metaKey, ctrlKey } = event;
if (
// Only override left and right keys without modifiers pressed.
shiftKey ||
altKey ||
metaKey ||
ctrlKey ||
( keyCode !== LEFT && keyCode !== RIGHT )
) {
return;
}
const { record, applyRecord, forceRender } = props.current;
const {
text,
formats,
start,
end,
activeFormats: currentActiveFormats = [],
} = record.current;
const collapsed = isCollapsed( record.current );
const { ownerDocument } = element;
const { defaultView } = ownerDocument;
// To do: ideally, we should look at visual position instead.
const { direction } = defaultView.getComputedStyle( element );
const reverseKey = direction === 'rtl' ? RIGHT : LEFT;
const isReverse = event.keyCode === reverseKey;
// If the selection is collapsed and at the very start, do nothing if
// navigating backward.
// If the selection is collapsed and at the very end, do nothing if
// navigating forward.
if ( collapsed && currentActiveFormats.length === 0 ) {
if ( start === 0 && isReverse ) {
return;
}
if ( end === text.length && ! isReverse ) {
return;
}
}
// If the selection is not collapsed, let the browser handle collapsing
// the selection for now. Later we could expand this logic to set
// boundary positions if needed.
if ( ! collapsed ) {
return;
}
const formatsBefore = formats[ start - 1 ] || EMPTY_ACTIVE_FORMATS;
const formatsAfter = formats[ start ] || EMPTY_ACTIVE_FORMATS;
const destination = isReverse ? formatsBefore : formatsAfter;
const isIncreasing = currentActiveFormats.every(
( format, index ) => format === destination[ index ]
);
let newActiveFormatsLength = currentActiveFormats.length;
if ( ! isIncreasing ) {
newActiveFormatsLength--;
} else if ( newActiveFormatsLength < destination.length ) {
newActiveFormatsLength++;
}
if ( newActiveFormatsLength === currentActiveFormats.length ) {
record.current._newActiveFormats = destination;
return;
}
event.preventDefault();
const origin = isReverse ? formatsAfter : formatsBefore;
const source = isIncreasing ? destination : origin;
const newActiveFormats = source.slice( 0, newActiveFormatsLength );
const newValue = {
...record.current,
activeFormats: newActiveFormats,
};
record.current = newValue;
applyRecord( newValue );
forceRender();
}
element.addEventListener( 'keydown', onKeyDown );
return () => {
element.removeEventListener( 'keydown', onKeyDown );
};
};

View File

@@ -0,0 +1,43 @@
/**
* WordPress dependencies
*/
import { useMemo, useRef } from '@wordpress/element';
import { useRefEffect } from '@wordpress/compose';
/**
* Internal dependencies
*/
import copyHandler from './copy-handler';
import selectObject from './select-object';
import formatBoundaries from './format-boundaries';
import deleteHandler from './delete';
import inputAndSelection from './input-and-selection';
import selectionChangeCompat from './selection-change-compat';
const allEventListeners = [
copyHandler,
selectObject,
formatBoundaries,
deleteHandler,
inputAndSelection,
selectionChangeCompat,
];
export function useEventListeners( props ) {
const propsRef = useRef( props );
propsRef.current = props;
const refEffects = useMemo(
() => allEventListeners.map( ( refEffect ) => refEffect( propsRef ) ),
[ propsRef ]
);
return useRefEffect(
( element ) => {
const cleanups = refEffects.map( ( effect ) => effect( element ) );
return () => {
cleanups.forEach( ( cleanup ) => cleanup() );
};
},
[ refEffects ]
);
}

View File

@@ -0,0 +1,259 @@
/**
* Internal dependencies
*/
import { getActiveFormats } from '../../get-active-formats';
import { updateFormats } from '../../update-formats';
/**
* All inserting input types that would insert HTML into the DOM.
*
* @see https://www.w3.org/TR/input-events-2/#interface-InputEvent-Attributes
*
* @type {Set}
*/
const INSERTION_INPUT_TYPES_TO_IGNORE = new Set( [
'insertParagraph',
'insertOrderedList',
'insertUnorderedList',
'insertHorizontalRule',
'insertLink',
] );
const EMPTY_ACTIVE_FORMATS = [];
const PLACEHOLDER_ATTR_NAME = 'data-rich-text-placeholder';
/**
* If the selection is set on the placeholder element, collapse the selection to
* the start (before the placeholder).
*
* @param {Window} defaultView
*/
function fixPlaceholderSelection( defaultView ) {
const selection = defaultView.getSelection();
const { anchorNode, anchorOffset } = selection;
if ( anchorNode.nodeType !== anchorNode.ELEMENT_NODE ) {
return;
}
const targetNode = anchorNode.childNodes[ anchorOffset ];
if (
! targetNode ||
targetNode.nodeType !== targetNode.ELEMENT_NODE ||
! targetNode.hasAttribute( PLACEHOLDER_ATTR_NAME )
) {
return;
}
selection.collapseToStart();
}
export default ( props ) => ( element ) => {
const { ownerDocument } = element;
const { defaultView } = ownerDocument;
let isComposing = false;
function onInput( event ) {
// Do not trigger a change if characters are being composed. Browsers
// will usually emit a final `input` event when the characters are
// composed. As of December 2019, Safari doesn't support
// nativeEvent.isComposing.
if ( isComposing ) {
return;
}
let inputType;
if ( event ) {
inputType = event.inputType;
}
const { record, applyRecord, createRecord, handleChange } =
props.current;
// The browser formatted something or tried to insert HTML. Overwrite
// it. It will be handled later by the format library if needed.
if (
inputType &&
( inputType.indexOf( 'format' ) === 0 ||
INSERTION_INPUT_TYPES_TO_IGNORE.has( inputType ) )
) {
applyRecord( record.current );
return;
}
const currentValue = createRecord();
const { start, activeFormats: oldActiveFormats = [] } = record.current;
// Update the formats between the last and new caret position.
const change = updateFormats( {
value: currentValue,
start,
end: currentValue.start,
formats: oldActiveFormats,
} );
handleChange( change );
}
/**
* Syncs the selection to local state. A callback for the `selectionchange`
* event.
*/
function handleSelectionChange() {
const { record, applyRecord, createRecord, onSelectionChange } =
props.current;
// Check if the implementor disabled editing. `contentEditable` does
// disable input, but not text selection, so we must ignore selection
// changes.
if ( element.contentEditable !== 'true' ) {
return;
}
// Ensure the active element is the rich text element.
if ( ownerDocument.activeElement !== element ) {
// If it is not, we can stop listening for selection changes. We
// resume listening when the element is focused.
ownerDocument.removeEventListener(
'selectionchange',
handleSelectionChange
);
return;
}
// In case of a keyboard event, ignore selection changes during
// composition.
if ( isComposing ) {
return;
}
const { start, end, text } = createRecord();
const oldRecord = record.current;
// Fallback mechanism for IE11, which doesn't support the input event.
// Any input results in a selection change.
if ( text !== oldRecord.text ) {
onInput();
return;
}
if ( start === oldRecord.start && end === oldRecord.end ) {
// Sometimes the browser may set the selection on the placeholder
// element, in which case the caret is not visible. We need to set
// the caret before the placeholder if that's the case.
if ( oldRecord.text.length === 0 && start === 0 ) {
fixPlaceholderSelection( defaultView );
}
return;
}
const newValue = {
...oldRecord,
start,
end,
// _newActiveFormats may be set on arrow key navigation to control
// the right boundary position. If undefined, getActiveFormats will
// give the active formats according to the browser.
activeFormats: oldRecord._newActiveFormats,
_newActiveFormats: undefined,
};
const newActiveFormats = getActiveFormats(
newValue,
EMPTY_ACTIVE_FORMATS
);
// Update the value with the new active formats.
newValue.activeFormats = newActiveFormats;
// It is important that the internal value is updated first,
// otherwise the value will be wrong on render!
record.current = newValue;
applyRecord( newValue, { domOnly: true } );
onSelectionChange( start, end );
}
function onCompositionStart() {
isComposing = true;
// Do not update the selection when characters are being composed as
// this rerenders the component and might destroy internal browser
// editing state.
ownerDocument.removeEventListener(
'selectionchange',
handleSelectionChange
);
// Remove the placeholder. Since the rich text value doesn't update
// during composition, the placeholder doesn't get removed. There's no
// need to re-add it, when the value is updated on compositionend it
// will be re-added when the value is empty.
element.querySelector( `[${ PLACEHOLDER_ATTR_NAME }]` )?.remove();
}
function onCompositionEnd() {
isComposing = false;
// Ensure the value is up-to-date for browsers that don't emit a final
// input event after composition.
onInput( { inputType: 'insertText' } );
// Tracking selection changes can be resumed.
ownerDocument.addEventListener(
'selectionchange',
handleSelectionChange
);
}
function onFocus() {
const { record, isSelected, onSelectionChange, applyRecord } =
props.current;
// When the whole editor is editable, let writing flow handle
// selection.
if ( element.parentElement.closest( '[contenteditable="true"]' ) ) {
return;
}
if ( ! isSelected ) {
// We know for certain that on focus, the old selection is invalid.
// It will be recalculated on the next mouseup, keyup, or touchend
// event.
const index = undefined;
record.current = {
...record.current,
start: index,
end: index,
activeFormats: EMPTY_ACTIVE_FORMATS,
};
} else {
applyRecord( record.current, { domOnly: true } );
}
onSelectionChange( record.current.start, record.current.end );
// There is no selection change event when the element is focused, so
// we need to manually trigger it. The selection is also not available
// yet in this call stack.
window.queueMicrotask( handleSelectionChange );
ownerDocument.addEventListener(
'selectionchange',
handleSelectionChange
);
}
element.addEventListener( 'input', onInput );
element.addEventListener( 'compositionstart', onCompositionStart );
element.addEventListener( 'compositionend', onCompositionEnd );
element.addEventListener( 'focus', onFocus );
return () => {
element.removeEventListener( 'input', onInput );
element.removeEventListener( 'compositionstart', onCompositionStart );
element.removeEventListener( 'compositionend', onCompositionEnd );
element.removeEventListener( 'focus', onFocus );
};
};

View File

@@ -0,0 +1,54 @@
export default () => ( element ) => {
function onClick( event ) {
const { target } = event;
// If the child element has no text content, it must be an object.
if (
target === element ||
( target.textContent && target.isContentEditable )
) {
return;
}
const { ownerDocument } = target;
const { defaultView } = ownerDocument;
const selection = defaultView.getSelection();
// If it's already selected, do nothing and let default behavior happen.
// This means it's "click-through".
if ( selection.containsNode( target ) ) {
return;
}
const range = ownerDocument.createRange();
// If the target is within a non editable element, select the non
// editable element.
const nodeToSelect = target.isContentEditable
? target
: target.closest( '[contenteditable]' );
range.selectNode( nodeToSelect );
selection.removeAllRanges();
selection.addRange( range );
event.preventDefault();
}
function onFocusIn( event ) {
// When there is incoming focus from a link, select the object.
if (
event.relatedTarget &&
! element.contains( event.relatedTarget ) &&
event.relatedTarget.tagName === 'A'
) {
onClick( event );
}
}
element.addEventListener( 'click', onClick );
element.addEventListener( 'focusin', onFocusIn );
return () => {
element.removeEventListener( 'click', onClick );
element.removeEventListener( 'focusin', onFocusIn );
};
};

View File

@@ -0,0 +1,53 @@
/**
* Internal dependencies
*/
import { isRangeEqual } from '../../is-range-equal';
/**
* Sometimes some browsers are not firing a `selectionchange` event when
* changing the selection by mouse or keyboard. This hook makes sure that, if we
* detect no `selectionchange` or `input` event between the up and down events,
* we fire a `selectionchange` event.
*/
export default () => ( element ) => {
const { ownerDocument } = element;
const { defaultView } = ownerDocument;
const selection = defaultView?.getSelection();
let range;
function getRange() {
return selection.rangeCount ? selection.getRangeAt( 0 ) : null;
}
function onDown( event ) {
const type = event.type === 'keydown' ? 'keyup' : 'pointerup';
function onCancel() {
ownerDocument.removeEventListener( type, onUp );
ownerDocument.removeEventListener( 'selectionchange', onCancel );
ownerDocument.removeEventListener( 'input', onCancel );
}
function onUp() {
onCancel();
if ( isRangeEqual( range, getRange() ) ) {
return;
}
ownerDocument.dispatchEvent( new Event( 'selectionchange' ) );
}
ownerDocument.addEventListener( type, onUp );
ownerDocument.addEventListener( 'selectionchange', onCancel );
ownerDocument.addEventListener( 'input', onCancel );
range = getRange();
}
element.addEventListener( 'pointerdown', onDown );
element.addEventListener( 'keydown', onDown );
return () => {
element.removeEventListener( 'pointerdown', onDown );
element.removeEventListener( 'keydown', onDown );
};
};

View File

@@ -0,0 +1,212 @@
/**
* WordPress dependencies
*/
import { useRef, useLayoutEffect, useReducer } from '@wordpress/element';
import { useMergeRefs, useRefEffect } from '@wordpress/compose';
import { useRegistry } from '@wordpress/data';
/**
* Internal dependencies
*/
import { create, RichTextData } from '../create';
import { apply } from '../to-dom';
import { toHTMLString } from '../to-html-string';
import { useDefaultStyle } from './use-default-style';
import { useBoundaryStyle } from './use-boundary-style';
import { useEventListeners } from './event-listeners';
export function useRichText( {
value = '',
selectionStart,
selectionEnd,
placeholder,
onSelectionChange,
preserveWhiteSpace,
onChange,
__unstableDisableFormats: disableFormats,
__unstableIsSelected: isSelected,
__unstableDependencies = [],
__unstableAfterParse,
__unstableBeforeSerialize,
__unstableAddInvisibleFormats,
} ) {
const registry = useRegistry();
const [ , forceRender ] = useReducer( () => ( {} ) );
const ref = useRef();
function createRecord() {
const {
ownerDocument: { defaultView },
} = ref.current;
const selection = defaultView.getSelection();
const range =
selection.rangeCount > 0 ? selection.getRangeAt( 0 ) : null;
return create( {
element: ref.current,
range,
__unstableIsEditableTree: true,
} );
}
function applyRecord( newRecord, { domOnly } = {} ) {
apply( {
value: newRecord,
current: ref.current,
prepareEditableTree: __unstableAddInvisibleFormats,
__unstableDomOnly: domOnly,
placeholder,
} );
}
// Internal values are updated synchronously, unlike props and state.
const _value = useRef( value );
const record = useRef();
function setRecordFromProps() {
_value.current = value;
record.current = value;
if ( ! ( value instanceof RichTextData ) ) {
record.current = value
? RichTextData.fromHTMLString( value, { preserveWhiteSpace } )
: RichTextData.empty();
}
// To do: make rich text internally work with RichTextData.
record.current = {
text: record.current.text,
formats: record.current.formats,
replacements: record.current.replacements,
};
if ( disableFormats ) {
record.current.formats = Array( value.length );
record.current.replacements = Array( value.length );
}
if ( __unstableAfterParse ) {
record.current.formats = __unstableAfterParse( record.current );
}
record.current.start = selectionStart;
record.current.end = selectionEnd;
}
const hadSelectionUpdate = useRef( false );
if ( ! record.current ) {
hadSelectionUpdate.current = isSelected;
setRecordFromProps();
} else if (
selectionStart !== record.current.start ||
selectionEnd !== record.current.end
) {
hadSelectionUpdate.current = isSelected;
record.current = {
...record.current,
start: selectionStart,
end: selectionEnd,
activeFormats: undefined,
};
}
/**
* Sync the value to global state. The node tree and selection will also be
* updated if differences are found.
*
* @param {Object} newRecord The record to sync and apply.
*/
function handleChange( newRecord ) {
record.current = newRecord;
applyRecord( newRecord );
if ( disableFormats ) {
_value.current = newRecord.text;
} else {
const newFormats = __unstableBeforeSerialize
? __unstableBeforeSerialize( newRecord )
: newRecord.formats;
newRecord = { ...newRecord, formats: newFormats };
if ( typeof value === 'string' ) {
_value.current = toHTMLString( {
value: newRecord,
preserveWhiteSpace,
} );
} else {
_value.current = new RichTextData( newRecord );
}
}
const { start, end, formats, text } = record.current;
// Selection must be updated first, so it is recorded in history when
// the content change happens.
// We batch both calls to only attempt to rerender once.
registry.batch( () => {
onSelectionChange( start, end );
onChange( _value.current, {
__unstableFormats: formats,
__unstableText: text,
} );
} );
forceRender();
}
function applyFromProps() {
setRecordFromProps();
applyRecord( record.current );
}
const didMount = useRef( false );
// Value updates must happen synchonously to avoid overwriting newer values.
useLayoutEffect( () => {
if ( didMount.current && value !== _value.current ) {
applyFromProps();
forceRender();
}
}, [ value ] );
// Value updates must happen synchonously to avoid overwriting newer values.
useLayoutEffect( () => {
if ( ! hadSelectionUpdate.current ) {
return;
}
if ( ref.current.ownerDocument.activeElement !== ref.current ) {
ref.current.focus();
}
applyRecord( record.current );
hadSelectionUpdate.current = false;
}, [ hadSelectionUpdate.current ] );
const mergedRefs = useMergeRefs( [
ref,
useDefaultStyle(),
useBoundaryStyle( { record } ),
useEventListeners( {
record,
handleChange,
applyRecord,
createRecord,
isSelected,
onSelectionChange,
forceRender,
} ),
useRefEffect( () => {
applyFromProps();
didMount.current = true;
}, [ placeholder, ...__unstableDependencies ] ),
] );
return {
value: record.current,
// A function to get the most recent value so event handlers in
// useRichText implementations have access to it. For example when
// listening to input events, we internally update the state, but this
// state is not yet available to the input event handler because React
// may re-render asynchronously.
getValue: () => record.current,
onChange: handleChange,
ref: mergedRefs,
};
}
export default function __experimentalRichText() {}

View File

@@ -0,0 +1,74 @@
/**
* WordPress dependencies
*/
import { useMemo } from '@wordpress/element';
import deprecated from '@wordpress/deprecated';
/**
* Internal dependencies
*/
import { getActiveFormat } from '../get-active-format';
/**
* @template T
* @typedef {import('@wordpress/element').RefObject<T>} RefObject<T>
*/
/** @typedef {import('../register-format-type').WPFormat} WPFormat */
/** @typedef {import('../types').RichTextValue} RichTextValue */
/**
* This hook, to be used in a format type's Edit component, returns the active
* element that is formatted, or the selection range if no format is active.
* The returned value is meant to be used for positioning UI, e.g. by passing it
* to the `Popover` component.
*
* @param {Object} $1 Named parameters.
* @param {RefObject<HTMLElement>} $1.ref React ref of the element
* containing the editable content.
* @param {RichTextValue} $1.value Value to check for selection.
* @param {WPFormat} $1.settings The format type's settings.
*
* @return {Element|Range} The active element or selection range.
*/
export function useAnchorRef( { ref, value, settings = {} } ) {
deprecated( '`useAnchorRef` hook', {
since: '6.1',
alternative: '`useAnchor` hook',
} );
const { tagName, className, name } = settings;
const activeFormat = name ? getActiveFormat( value, name ) : undefined;
return useMemo( () => {
if ( ! ref.current ) {
return;
}
const {
ownerDocument: { defaultView },
} = ref.current;
const selection = defaultView.getSelection();
if ( ! selection.rangeCount ) {
return;
}
const range = selection.getRangeAt( 0 );
if ( ! activeFormat ) {
return range;
}
let element = range.startContainer;
// If the caret is right before the element, select the next element.
element = element.nextElementSibling || element;
while ( element.nodeType !== element.ELEMENT_NODE ) {
element = element.parentNode;
}
return element.closest(
tagName + ( className ? '.' + className : '' )
);
}, [ activeFormat, value.start, value.end, tagName, className ] );
}

View File

@@ -0,0 +1,211 @@
/**
* WordPress dependencies
*/
import { usePrevious } from '@wordpress/compose';
import { useState, useLayoutEffect } from '@wordpress/element';
/** @typedef {import('../register-format-type').WPFormat} WPFormat */
/** @typedef {import('../types').RichTextValue} RichTextValue */
/**
* Given a range and a format tag name and class name, returns the closest
* format element.
*
* @param {Range} range The Range to check.
* @param {HTMLElement} editableContentElement The editable wrapper.
* @param {string} tagName The tag name of the format element.
* @param {string} className The class name of the format element.
*
* @return {HTMLElement|undefined} The format element, if found.
*/
function getFormatElement( range, editableContentElement, tagName, className ) {
let element = range.startContainer;
// Even if the active format is defined, the actualy DOM range's start
// container may be outside of the format's DOM element:
// `a‸<strong>b</strong>` (DOM) while visually it's `a<strong>‸b</strong>`.
// So at a given selection index, start with the deepest format DOM element.
if (
element.nodeType === element.TEXT_NODE &&
range.startOffset === element.length &&
element.nextSibling
) {
element = element.nextSibling;
while ( element.firstChild ) {
element = element.firstChild;
}
}
if ( element.nodeType !== element.ELEMENT_NODE ) {
element = element.parentElement;
}
if ( ! element ) {
return;
}
if ( element === editableContentElement ) {
return;
}
if ( ! editableContentElement.contains( element ) ) {
return;
}
const selector = tagName + ( className ? '.' + className : '' );
// .closest( selector ), but with a boundary. Check if the element matches
// the selector. If it doesn't match, try the parent element if it's not the
// editable wrapper. We don't want to try to match ancestors of the editable
// wrapper, which is what .closest( selector ) would do. When the element is
// the editable wrapper (which is most likely the case because most text is
// unformatted), this never runs.
while ( element !== editableContentElement ) {
if ( element.matches( selector ) ) {
return element;
}
element = element.parentElement;
}
}
/**
* @typedef {Object} VirtualAnchorElement
* @property {() => DOMRect} getBoundingClientRect A function returning a DOMRect
* @property {HTMLElement} contextElement The actual DOM element
*/
/**
* Creates a virtual anchor element for a range.
*
* @param {Range} range The range to create a virtual anchor element for.
* @param {HTMLElement} editableContentElement The editable wrapper.
*
* @return {VirtualAnchorElement} The virtual anchor element.
*/
function createVirtualAnchorElement( range, editableContentElement ) {
return {
contextElement: editableContentElement,
getBoundingClientRect() {
return editableContentElement.contains( range.startContainer )
? range.getBoundingClientRect()
: editableContentElement.getBoundingClientRect();
},
};
}
/**
* Get the anchor: a format element if there is a matching one based on the
* tagName and className or a range otherwise.
*
* @param {HTMLElement} editableContentElement The editable wrapper.
* @param {string} tagName The tag name of the format
* element.
* @param {string} className The class name of the format
* element.
*
* @return {HTMLElement|VirtualAnchorElement|undefined} The anchor.
*/
function getAnchor( editableContentElement, tagName, className ) {
if ( ! editableContentElement ) {
return;
}
const { ownerDocument } = editableContentElement;
const { defaultView } = ownerDocument;
const selection = defaultView.getSelection();
if ( ! selection ) {
return;
}
if ( ! selection.rangeCount ) {
return;
}
const range = selection.getRangeAt( 0 );
if ( ! range || ! range.startContainer ) {
return;
}
const formatElement = getFormatElement(
range,
editableContentElement,
tagName,
className
);
if ( formatElement ) {
return formatElement;
}
return createVirtualAnchorElement( range, editableContentElement );
}
/**
* This hook, to be used in a format type's Edit component, returns the active
* element that is formatted, or a virtual element for the selection range if
* no format is active. The returned value is meant to be used for positioning
* UI, e.g. by passing it to the `Popover` component via the `anchor` prop.
*
* @param {Object} $1 Named parameters.
* @param {HTMLElement|null} $1.editableContentElement The element containing
* the editable content.
* @param {WPFormat=} $1.settings The format type's settings.
* @return {Element|VirtualAnchorElement|undefined|null} The active element or selection range.
*/
export function useAnchor( { editableContentElement, settings = {} } ) {
const { tagName, className, isActive } = settings;
const [ anchor, setAnchor ] = useState( () =>
getAnchor( editableContentElement, tagName, className )
);
const wasActive = usePrevious( isActive );
useLayoutEffect( () => {
if ( ! editableContentElement ) {
return;
}
function callback() {
setAnchor(
getAnchor( editableContentElement, tagName, className )
);
}
function attach() {
ownerDocument.addEventListener( 'selectionchange', callback );
}
function detach() {
ownerDocument.removeEventListener( 'selectionchange', callback );
}
const { ownerDocument } = editableContentElement;
if (
editableContentElement === ownerDocument.activeElement ||
// When a link is created, we need to attach the popover to the newly created anchor.
( ! wasActive && isActive ) ||
// Sometimes we're _removing_ an active anchor, such as the inline color popover.
// When we add the color, it switches from a virtual anchor to a `<mark>` element.
// When we _remove_ the color, it switches from a `<mark>` element to a virtual anchor.
( wasActive && ! isActive )
) {
setAnchor(
getAnchor( editableContentElement, tagName, className )
);
attach();
}
editableContentElement.addEventListener( 'focusin', attach );
editableContentElement.addEventListener( 'focusout', detach );
return () => {
detach();
editableContentElement.removeEventListener( 'focusin', attach );
editableContentElement.removeEventListener( 'focusout', detach );
};
}, [ editableContentElement, tagName, className, isActive, wasActive ] );
return anchor;
}

View File

@@ -0,0 +1,55 @@
/**
* WordPress dependencies
*/
import { useEffect, useRef } from '@wordpress/element';
/*
* Calculates and renders the format boundary style when the active formats
* change.
*/
export function useBoundaryStyle( { record } ) {
const ref = useRef();
const { activeFormats = [], replacements, start } = record.current;
const activeReplacement = replacements[ start ];
useEffect( () => {
// There's no need to recalculate the boundary styles if no formats are
// active, because no boundary styles will be visible.
if (
( ! activeFormats || ! activeFormats.length ) &&
! activeReplacement
) {
return;
}
const boundarySelector = '*[data-rich-text-format-boundary]';
const element = ref.current.querySelector( boundarySelector );
if ( ! element ) {
return;
}
const { ownerDocument } = element;
const { defaultView } = ownerDocument;
const computedStyle = defaultView.getComputedStyle( element );
const newColor = computedStyle.color
.replace( ')', ', 0.2)' )
.replace( 'rgb', 'rgba' );
const selector = `.rich-text:focus ${ boundarySelector }`;
const rule = `background-color: ${ newColor }`;
const style = `${ selector } {${ rule }}`;
const globalStyleId = 'rich-text-boundary-style';
let globalStyle = ownerDocument.getElementById( globalStyleId );
if ( ! globalStyle ) {
globalStyle = ownerDocument.createElement( 'style' );
globalStyle.id = globalStyleId;
ownerDocument.head.appendChild( globalStyle );
}
if ( globalStyle.innerHTML !== style ) {
globalStyle.innerHTML = style;
}
}, [ activeFormats, activeReplacement ] );
return ref;
}

View File

@@ -0,0 +1,42 @@
/**
* WordPress dependencies
*/
import { useCallback } from '@wordpress/element';
/**
* In HTML, leading and trailing spaces are not visible, and multiple spaces
* elsewhere are visually reduced to one space. This rule prevents spaces from
* collapsing so all space is visible in the editor and can be removed. It also
* prevents some browsers from inserting non-breaking spaces at the end of a
* line to prevent the space from visually disappearing. Sometimes these non
* breaking spaces can linger in the editor causing unwanted non breaking spaces
* in between words. If also prevent Firefox from inserting a trailing `br` node
* to visualise any trailing space, causing the element to be saved.
*
* > Authors are encouraged to set the 'white-space' property on editing hosts
* > and on markup that was originally created through these editing mechanisms
* > to the value 'pre-wrap'. Default HTML whitespace handling is not well
* > suited to WYSIWYG editing, and line wrapping will not work correctly in
* > some corner cases if 'white-space' is left at its default value.
*
* https://html.spec.whatwg.org/multipage/interaction.html#best-practices-for-in-page-editors
*
* @type {string}
*/
const whiteSpace = 'pre-wrap';
/**
* A minimum width of 1px will prevent the rich text container from collapsing
* to 0 width and hiding the caret. This is useful for inline containers.
*/
const minWidth = '1px';
export function useDefaultStyle() {
return useCallback( ( element ) => {
if ( ! element ) {
return;
}
element.style.whiteSpace = whiteSpace;
element.style.minWidth = minWidth;
}, [] );
}

37
node_modules/@wordpress/rich-text/src/concat.js generated vendored Normal file
View File

@@ -0,0 +1,37 @@
/**
* Internal dependencies
*/
import { normaliseFormats } from './normalise-formats';
import { create } from './create';
/** @typedef {import('./types').RichTextValue} RichTextValue */
/**
* Concats a pair of rich text values. Not that this mutates `a` and does NOT
* normalise formats!
*
* @param {Object} a Value to mutate.
* @param {Object} b Value to add read from.
*
* @return {Object} `a`, mutated.
*/
export function mergePair( a, b ) {
a.formats = a.formats.concat( b.formats );
a.replacements = a.replacements.concat( b.replacements );
a.text += b.text;
return a;
}
/**
* Combine all Rich Text values into one. This is similar to
* `String.prototype.concat`.
*
* @param {...RichTextValue} values Objects to combine.
*
* @return {RichTextValue} A new value combining all given records.
*/
export function concat( ...values ) {
return normaliseFormats( values.reduce( mergePair, create() ) );
}

View File

@@ -0,0 +1,25 @@
/**
* Parse the given HTML into a body element.
*
* Note: The current implementation will return a shared reference, reset on
* each call to `createElement`. Therefore, you should not hold a reference to
* the value to operate upon asynchronously, as it may have unexpected results.
*
* @param {HTMLDocument} document The HTML document to use to parse.
* @param {string} html The HTML to parse.
*
* @return {HTMLBodyElement} Body element with parsed HTML.
*/
export function createElement( { implementation }, html ) {
// Because `createHTMLDocument` is an expensive operation, and with this
// function being internal to `rich-text` (full control in avoiding a risk
// of asynchronous operations on the shared reference), a single document
// is reused and reset for each call to the function.
if ( ! createElement.body ) {
createElement.body = implementation.createHTMLDocument( '' ).body;
}
createElement.body.innerHTML = html;
return createElement.body;
}

625
node_modules/@wordpress/rich-text/src/create.js generated vendored Normal file
View File

@@ -0,0 +1,625 @@
/**
* WordPress dependencies
*/
import { select } from '@wordpress/data';
/**
* Internal dependencies
*/
import { store as richTextStore } from './store';
import { createElement } from './create-element';
import { mergePair } from './concat';
import { OBJECT_REPLACEMENT_CHARACTER, ZWNBSP } from './special-characters';
import { toHTMLString } from './to-html-string';
import { getTextContent } from './get-text-content';
/** @typedef {import('./types').RichTextValue} RichTextValue */
function createEmptyValue() {
return {
formats: [],
replacements: [],
text: '',
};
}
function toFormat( { tagName, attributes } ) {
let formatType;
if ( attributes && attributes.class ) {
formatType = select( richTextStore ).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 =
select( richTextStore ).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.
*/
export 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 getTextContent( this.#value );
}
// We could expose `toHTMLElement` at some point as well, but we'd only use
// it internally.
toHTMLString( { preserveWhiteSpace } = {} ) {
return (
this.originalHTML ||
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;
}
}
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.
*/
export 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 = 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
*/
export 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(
`[${ ZWNBSP }${ 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: OBJECT_REPLACEMENT_CHARACTER,
};
accumulateSelection( accumulator, node, range, value );
mergePair( accumulator, value );
continue;
}
if ( tagName === 'br' ) {
accumulateSelection( accumulator, node, range, createEmptyValue() );
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() );
mergePair( accumulator, {
formats: [ , ],
replacements: [
{
...format,
innerHTML: node.innerHTML,
},
],
text: 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' ) ) {
mergePair( accumulator, value );
} else if ( value.text.length === 0 ) {
if ( format.attributes ) {
mergePair( accumulator, {
formats: [ , ],
replacements: [ format ],
text: 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 ];
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;
}

View File

@@ -0,0 +1,25 @@
/**
* Internal dependencies
*/
import { getActiveFormats } from './get-active-formats';
/** @typedef {import('./types').RichTextValue} RichTextValue */
/** @typedef {import('./types').RichTextFormat} RichTextFormat */
/**
* Gets the format object by type at the start of the selection. This can be
* used to get e.g. the URL of a link format at the current selection, but also
* to check if a format is active at the selection. Returns undefined if there
* is no format at the selection.
*
* @param {RichTextValue} value Value to inspect.
* @param {string} formatType Format type to look for.
*
* @return {RichTextFormat|undefined} Active format object of the specified
* type, or undefined.
*/
export function getActiveFormat( value, formatType ) {
return getActiveFormats( value ).find(
( { type } ) => type === formatType
);
}

View File

@@ -0,0 +1,88 @@
/** @typedef {import('./types').RichTextValue} RichTextValue */
/** @typedef {import('./types').RichTextFormatList} RichTextFormatList */
/**
* Internal dependencies
*/
import { isFormatEqual } from './is-format-equal';
/**
* Gets the all format objects at the start of the selection.
*
* @param {RichTextValue} value Value to inspect.
* @param {Array} EMPTY_ACTIVE_FORMATS Array to return if there are no
* active formats.
*
* @return {RichTextFormatList} Active format objects.
*/
export function getActiveFormats( value, EMPTY_ACTIVE_FORMATS = [] ) {
const { formats, start, end, activeFormats } = value;
if ( start === undefined ) {
return EMPTY_ACTIVE_FORMATS;
}
if ( start === end ) {
// For a collapsed caret, it is possible to override the active formats.
if ( activeFormats ) {
return activeFormats;
}
const formatsBefore = formats[ start - 1 ] || EMPTY_ACTIVE_FORMATS;
const formatsAfter = formats[ start ] || EMPTY_ACTIVE_FORMATS;
// By default, select the lowest amount of formats possible (which means
// the caret is positioned outside the format boundary). The user can
// then use arrow keys to define `activeFormats`.
if ( formatsBefore.length < formatsAfter.length ) {
return formatsBefore;
}
return formatsAfter;
}
// If there's no formats at the start index, there are not active formats.
if ( ! formats[ start ] ) {
return EMPTY_ACTIVE_FORMATS;
}
const selectedFormats = formats.slice( start, end );
// Clone the formats so we're not mutating the live value.
const _activeFormats = [ ...selectedFormats[ 0 ] ];
let i = selectedFormats.length;
// For performance reasons, start from the end where it's much quicker to
// realise that there are no active formats.
while ( i-- ) {
const formatsAtIndex = selectedFormats[ i ];
// If we run into any index without formats, we're sure that there's no
// active formats.
if ( ! formatsAtIndex ) {
return EMPTY_ACTIVE_FORMATS;
}
let ii = _activeFormats.length;
// Loop over the active formats and remove any that are not present at
// the current index.
while ( ii-- ) {
const format = _activeFormats[ ii ];
if (
! formatsAtIndex.find( ( _format ) =>
isFormatEqual( format, _format )
)
) {
_activeFormats.splice( ii, 1 );
}
}
// If there are no active formats, we can stop.
if ( _activeFormats.length === 0 ) {
return EMPTY_ACTIVE_FORMATS;
}
}
return _activeFormats || EMPTY_ACTIVE_FORMATS;
}

View File

@@ -0,0 +1,23 @@
/**
* Internal dependencies
*/
import { OBJECT_REPLACEMENT_CHARACTER } from './special-characters';
/** @typedef {import('./types').RichTextValue} RichTextValue */
/** @typedef {import('./types').RichTextFormat} RichTextFormat */
/**
* Gets the active object, if there is any.
*
* @param {RichTextValue} value Value to inspect.
*
* @return {RichTextFormat|void} Active object, or undefined.
*/
export function getActiveObject( { start, end, replacements, text } ) {
if ( start + 1 !== end || text[ start ] !== OBJECT_REPLACEMENT_CHARACTER ) {
return;
}
return replacements[ start ];
}

View File

@@ -0,0 +1,21 @@
/**
* WordPress dependencies
*/
import { select } from '@wordpress/data';
/**
* Internal dependencies
*/
import { store as richTextStore } from './store';
/** @typedef {import('./register-format-type').RichTextFormatType} RichTextFormatType */
/**
* Returns a registered format type.
*
* @param {string} name Format name.
*
* @return {RichTextFormatType|undefined} Format type.
*/
export function getFormatType( name ) {
return select( richTextStore ).getFormatType( name );
}

View File

@@ -0,0 +1,19 @@
/**
* WordPress dependencies
*/
import { select } from '@wordpress/data';
/**
* Internal dependencies
*/
import { store as richTextStore } from './store';
/** @typedef {import('./register-format-type').RichTextFormatType} RichTextFormatType */
/**
* Returns all registered formats.
*
* @return {Array<RichTextFormatType>} Format settings.
*/
export function getFormatTypes() {
return select( richTextStore ).getFormatTypes();
}

View File

@@ -0,0 +1,18 @@
/**
* Internal dependencies
*/
import { OBJECT_REPLACEMENT_CHARACTER } from './special-characters';
/** @typedef {import('./types').RichTextValue} RichTextValue */
/**
* Get the textual content of a Rich Text value. This is similar to
* `Element.textContent`.
*
* @param {RichTextValue} value Value to use.
*
* @return {string} The text content.
*/
export function getTextContent( { text } ) {
return text.replace( OBJECT_REPLACEMENT_CHARACTER, '' );
}

38
node_modules/@wordpress/rich-text/src/index.ts generated vendored Normal file
View File

@@ -0,0 +1,38 @@
export { store } from './store';
export { applyFormat } from './apply-format';
export { concat } from './concat';
export { RichTextData, create } from './create';
export { getActiveFormat } from './get-active-format';
export { getActiveFormats } from './get-active-formats';
export { getActiveObject } from './get-active-object';
export { getTextContent } from './get-text-content';
export { isCollapsed } from './is-collapsed';
export { isEmpty } from './is-empty';
export { join } from './join';
export { registerFormatType } from './register-format-type';
export { removeFormat } from './remove-format';
export { remove } from './remove';
export { replace } from './replace';
export { insert } from './insert';
export { insertObject } from './insert-object';
export { slice } from './slice';
export { split } from './split';
export { toDom as __unstableToDom } from './to-dom';
export { toHTMLString } from './to-html-string';
export { toggleFormat } from './toggle-format';
export { unregisterFormatType } from './unregister-format-type';
export { createElement as __unstableCreateElement } from './create-element';
export { useAnchorRef } from './component/use-anchor-ref';
export { useAnchor } from './component/use-anchor';
export {
default as __experimentalRichText,
useRichText as __unstableUseRichText,
} from './component';
/**
* An object which represents a formatted string. See main `@wordpress/rich-text`
* documentation for more information.
*/
export type { RichTextValue } from './types';

31
node_modules/@wordpress/rich-text/src/insert-object.js generated vendored Normal file
View File

@@ -0,0 +1,31 @@
/**
* Internal dependencies
*/
import { insert } from './insert';
import { OBJECT_REPLACEMENT_CHARACTER } from './special-characters';
/** @typedef {import('./types').RichTextValue} RichTextValue */
/** @typedef {import('./types').RichTextFormat} RichTextFormat */
/**
* Insert a format as an object into a Rich Text value at the given
* `startIndex`. Any content between `startIndex` and `endIndex` will be
* removed. Indices are retrieved from the selection if none are provided.
*
* @param {RichTextValue} value Value to modify.
* @param {RichTextFormat} formatToInsert Format to insert as object.
* @param {number} [startIndex] Start index.
* @param {number} [endIndex] End index.
*
* @return {RichTextValue} A new value with the object inserted.
*/
export function insertObject( value, formatToInsert, startIndex, endIndex ) {
const valueToInsert = {
formats: [ , ],
replacements: [ formatToInsert ],
text: OBJECT_REPLACEMENT_CHARACTER,
};
return insert( value, valueToInsert, startIndex, endIndex );
}

54
node_modules/@wordpress/rich-text/src/insert.js generated vendored Normal file
View File

@@ -0,0 +1,54 @@
/**
* Internal dependencies
*/
import { create } from './create';
import { normaliseFormats } from './normalise-formats';
/** @typedef {import('./types').RichTextValue} RichTextValue */
/**
* Insert a Rich Text value, an HTML string, or a plain text string, into a
* Rich Text value at the given `startIndex`. Any content between `startIndex`
* and `endIndex` will be removed. Indices are retrieved from the selection if
* none are provided.
*
* @param {RichTextValue} value Value to modify.
* @param {RichTextValue|string} valueToInsert Value to insert.
* @param {number} [startIndex] Start index.
* @param {number} [endIndex] End index.
*
* @return {RichTextValue} A new value with the value inserted.
*/
export function insert(
value,
valueToInsert,
startIndex = value.start,
endIndex = value.end
) {
const { formats, replacements, text } = value;
if ( typeof valueToInsert === 'string' ) {
valueToInsert = create( { text: valueToInsert } );
}
const index = startIndex + valueToInsert.text.length;
return normaliseFormats( {
formats: formats
.slice( 0, startIndex )
.concat( valueToInsert.formats, formats.slice( endIndex ) ),
replacements: replacements
.slice( 0, startIndex )
.concat(
valueToInsert.replacements,
replacements.slice( endIndex )
),
text:
text.slice( 0, startIndex ) +
valueToInsert.text +
text.slice( endIndex ),
start: index,
end: index,
} );
}

26
node_modules/@wordpress/rich-text/src/is-collapsed.ts generated vendored Normal file
View File

@@ -0,0 +1,26 @@
/**
* Internal dependencies
*/
import type { RichTextValue } from './types';
/**
* Check if the selection of a Rich Text value is collapsed or not. Collapsed
* means that no characters are selected, but there is a caret present. If there
* is no selection, `undefined` will be returned. This is similar to
* `window.getSelection().isCollapsed()`.
*
* @param props The rich text value to check.
* @param props.start
* @param props.end
* @return True if the selection is collapsed, false if not, undefined if there is no selection.
*/
export function isCollapsed( {
start,
end,
}: RichTextValue ): boolean | undefined {
if ( start === undefined || end === undefined ) {
return;
}
return start === end;
}

13
node_modules/@wordpress/rich-text/src/is-empty.js generated vendored Normal file
View File

@@ -0,0 +1,13 @@
/** @typedef {import('./types').RichTextValue} RichTextValue */
/**
* Check if a Rich Text value is Empty, meaning it contains no text or any
* objects (such as images).
*
* @param {RichTextValue} value Value to use.
*
* @return {boolean} True if the value is empty, false if not.
*/
export function isEmpty( { text } ) {
return text.length === 0;
}

View File

@@ -0,0 +1,58 @@
/** @typedef {import('./types').RichTextFormat} RichTextFormat */
/**
* Optimised equality check for format objects.
*
* @param {?RichTextFormat} format1 Format to compare.
* @param {?RichTextFormat} format2 Format to compare.
*
* @return {boolean} True if formats are equal, false if not.
*/
export function isFormatEqual( format1, format2 ) {
// Both not defined.
if ( format1 === format2 ) {
return true;
}
// Either not defined.
if ( ! format1 || ! format2 ) {
return false;
}
if ( format1.type !== format2.type ) {
return false;
}
const attributes1 = format1.attributes;
const attributes2 = format2.attributes;
// Both not defined.
if ( attributes1 === attributes2 ) {
return true;
}
// Either not defined.
if ( ! attributes1 || ! attributes2 ) {
return false;
}
const keys1 = Object.keys( attributes1 );
const keys2 = Object.keys( attributes2 );
if ( keys1.length !== keys2.length ) {
return false;
}
const length = keys1.length;
// Optimise for speed.
for ( let i = 0; i < length; i++ ) {
const name = keys1[ i ];
if ( attributes1[ name ] !== attributes2[ name ] ) {
return false;
}
}
return true;
}

View File

@@ -0,0 +1,21 @@
/**
* Returns true if two ranges are equal, or false otherwise. Ranges are
* considered equal if their start and end occur in the same container and
* offset.
*
* @param {Range|null} a First range object to test.
* @param {Range|null} b First range object to test.
*
* @return {boolean} Whether the two ranges are equal.
*/
export function isRangeEqual( a, b ) {
return (
a === b ||
( a &&
b &&
a.startContainer === b.startContainer &&
a.startOffset === b.startOffset &&
a.endContainer === b.endContainer &&
a.endOffset === b.endOffset )
);
}

35
node_modules/@wordpress/rich-text/src/join.js generated vendored Normal file
View File

@@ -0,0 +1,35 @@
/**
* Internal dependencies
*/
import { create } from './create';
import { normaliseFormats } from './normalise-formats';
/** @typedef {import('./types').RichTextValue} RichTextValue */
/**
* Combine an array of Rich Text values into one, optionally separated by
* `separator`, which can be a Rich Text value, HTML string, or plain text
* string. This is similar to `Array.prototype.join`.
*
* @param {Array<RichTextValue>} values An array of values to join.
* @param {string|RichTextValue} [separator] Separator string or value.
*
* @return {RichTextValue} A new combined value.
*/
export function join( values, separator = '' ) {
if ( typeof separator === 'string' ) {
separator = create( { text: separator } );
}
return normaliseFormats(
values.reduce( ( accumlator, { formats, replacements, text } ) => ( {
formats: accumlator.formats.concat( separator.formats, formats ),
replacements: accumlator.replacements.concat(
separator.replacements,
replacements
),
text: accumlator.text + separator.text + text,
} ) )
);
}

View File

@@ -0,0 +1,42 @@
/**
* Internal dependencies
*/
import { isFormatEqual } from './is-format-equal';
/** @typedef {import('./types').RichTextValue} RichTextValue */
/**
* Normalises formats: ensures subsequent adjacent equal formats have the same
* reference.
*
* @param {RichTextValue} value Value to normalise formats of.
*
* @return {RichTextValue} New value with normalised formats.
*/
export function normaliseFormats( value ) {
const newFormats = value.formats.slice();
newFormats.forEach( ( formatsAtIndex, index ) => {
const formatsAtPreviousIndex = newFormats[ index - 1 ];
if ( formatsAtPreviousIndex ) {
const newFormatsAtIndex = formatsAtIndex.slice();
newFormatsAtIndex.forEach( ( format, formatIndex ) => {
const previousFormat = formatsAtPreviousIndex[ formatIndex ];
if ( isFormatEqual( format, previousFormat ) ) {
newFormatsAtIndex[ formatIndex ] = previousFormat;
}
} );
newFormats[ index ] = newFormatsAtIndex;
}
} );
return {
...value,
formats: newFormats,
};
}

View File

@@ -0,0 +1,132 @@
/**
* WordPress dependencies
*/
import { select, dispatch } from '@wordpress/data';
/**
* Internal dependencies
*/
import { store as richTextStore } from './store';
/**
* @typedef {Object} WPFormat
*
* @property {string} name A string identifying the format. Must be
* unique across all registered formats.
* @property {string} tagName The HTML tag this format will wrap the
* selection with.
* @property {boolean} interactive Whether format makes content interactive or not.
* @property {string | null} [className] A class to match the format.
* @property {string} title Name of the format.
* @property {Function} edit Should return a component for the user to
* interact with the new registered format.
*/
/**
* Registers a new format provided a unique name and an object defining its
* behavior.
*
* @param {string} name Format name.
* @param {WPFormat} settings Format settings.
*
* @return {WPFormat|undefined} The format, if it has been successfully
* registered; otherwise `undefined`.
*/
export function registerFormatType( name, settings ) {
settings = {
name,
...settings,
};
if ( typeof settings.name !== 'string' ) {
window.console.error( 'Format names must be strings.' );
return;
}
if ( ! /^[a-z][a-z0-9-]*\/[a-z][a-z0-9-]*$/.test( settings.name ) ) {
window.console.error(
'Format names must contain a namespace prefix, include only lowercase alphanumeric characters or dashes, and start with a letter. Example: my-plugin/my-custom-format'
);
return;
}
if ( select( richTextStore ).getFormatType( settings.name ) ) {
window.console.error(
'Format "' + settings.name + '" is already registered.'
);
return;
}
if ( typeof settings.tagName !== 'string' || settings.tagName === '' ) {
window.console.error( 'Format tag names must be a string.' );
return;
}
if (
( typeof settings.className !== 'string' ||
settings.className === '' ) &&
settings.className !== null
) {
window.console.error(
'Format class names must be a string, or null to handle bare elements.'
);
return;
}
if ( ! /^[_a-zA-Z]+[a-zA-Z0-9_-]*$/.test( settings.className ) ) {
window.console.error(
'A class name must begin with a letter, followed by any number of hyphens, underscores, letters, or numbers.'
);
return;
}
if ( settings.className === null ) {
const formatTypeForBareElement = select(
richTextStore
).getFormatTypeForBareElement( settings.tagName );
if (
formatTypeForBareElement &&
formatTypeForBareElement.name !== 'core/unknown'
) {
window.console.error(
`Format "${ formatTypeForBareElement.name }" is already registered to handle bare tag name "${ settings.tagName }".`
);
return;
}
} else {
const formatTypeForClassName = select(
richTextStore
).getFormatTypeForClassName( settings.className );
if ( formatTypeForClassName ) {
window.console.error(
`Format "${ formatTypeForClassName.name }" is already registered to handle class name "${ settings.className }".`
);
return;
}
}
if ( ! ( 'title' in settings ) || settings.title === '' ) {
window.console.error(
'The format "' + settings.name + '" must have a title.'
);
return;
}
if ( 'keywords' in settings && settings.keywords.length > 3 ) {
window.console.error(
'The format "' +
settings.name +
'" can have a maximum of 3 keywords.'
);
return;
}
if ( typeof settings.title !== 'string' ) {
window.console.error( 'Format titles must be strings.' );
return;
}
dispatch( richTextStore ).addFormatTypes( settings );
return settings;
}

84
node_modules/@wordpress/rich-text/src/remove-format.js generated vendored Normal file
View File

@@ -0,0 +1,84 @@
/**
* Internal dependencies
*/
import { normaliseFormats } from './normalise-formats';
/** @typedef {import('./types').RichTextValue} RichTextValue */
/**
* Remove any format object from a Rich Text value by type from the given
* `startIndex` to the given `endIndex`. Indices are retrieved from the
* selection if none are provided.
*
* @param {RichTextValue} value Value to modify.
* @param {string} formatType Format type to remove.
* @param {number} [startIndex] Start index.
* @param {number} [endIndex] End index.
*
* @return {RichTextValue} A new value with the format applied.
*/
export function removeFormat(
value,
formatType,
startIndex = value.start,
endIndex = value.end
) {
const { formats, activeFormats } = value;
const newFormats = formats.slice();
// If the selection is collapsed, expand start and end to the edges of the
// format.
if ( startIndex === endIndex ) {
const format = newFormats[ startIndex ]?.find(
( { type } ) => type === formatType
);
if ( format ) {
while (
newFormats[ startIndex ]?.find(
( newFormat ) => newFormat === format
)
) {
filterFormats( newFormats, startIndex, formatType );
startIndex--;
}
endIndex++;
while (
newFormats[ endIndex ]?.find(
( newFormat ) => newFormat === format
)
) {
filterFormats( newFormats, endIndex, formatType );
endIndex++;
}
}
} else {
for ( let i = startIndex; i < endIndex; i++ ) {
if ( newFormats[ i ] ) {
filterFormats( newFormats, i, formatType );
}
}
}
return normaliseFormats( {
...value,
formats: newFormats,
activeFormats:
activeFormats?.filter( ( { type } ) => type !== formatType ) || [],
} );
}
function filterFormats( formats, index, formatType ) {
const newFormats = formats[ index ].filter(
( { type } ) => type !== formatType
);
if ( newFormats.length ) {
formats[ index ] = newFormats;
} else {
delete formats[ index ];
}
}

22
node_modules/@wordpress/rich-text/src/remove.js generated vendored Normal file
View File

@@ -0,0 +1,22 @@
/**
* Internal dependencies
*/
import { insert } from './insert';
import { create } from './create';
/** @typedef {import('./types').RichTextValue} RichTextValue */
/**
* Remove content from a Rich Text value between the given `startIndex` and
* `endIndex`. Indices are retrieved from the selection if none are provided.
*
* @param {RichTextValue} value Value to modify.
* @param {number} [startIndex] Start index.
* @param {number} [endIndex] End index.
*
* @return {RichTextValue} A new value with the content removed.
*/
export function remove( value, startIndex, endIndex ) {
return insert( value, create(), startIndex, endIndex );
}

71
node_modules/@wordpress/rich-text/src/replace.js generated vendored Normal file
View File

@@ -0,0 +1,71 @@
/**
* Internal dependencies
*/
import { normaliseFormats } from './normalise-formats';
/** @typedef {import('./types').RichTextValue} RichTextValue */
/**
* Search a Rich Text value and replace the match(es) with `replacement`. This
* is similar to `String.prototype.replace`.
*
* @param {RichTextValue} value The value to modify.
* @param {RegExp|string} pattern A RegExp object or literal. Can also be
* a string. It is treated as a verbatim
* string and is not interpreted as a
* regular expression. Only the first
* occurrence will be replaced.
* @param {Function|string} replacement The match or matches are replaced with
* the specified or the value returned by
* the specified function.
*
* @return {RichTextValue} A new value with replacements applied.
*/
export function replace(
{ formats, replacements, text, start, end },
pattern,
replacement
) {
text = text.replace( pattern, ( match, ...rest ) => {
const offset = rest[ rest.length - 2 ];
let newText = replacement;
let newFormats;
let newReplacements;
if ( typeof newText === 'function' ) {
newText = replacement( match, ...rest );
}
if ( typeof newText === 'object' ) {
newFormats = newText.formats;
newReplacements = newText.replacements;
newText = newText.text;
} else {
newFormats = Array( newText.length );
newReplacements = Array( newText.length );
if ( formats[ offset ] ) {
newFormats = newFormats.fill( formats[ offset ] );
}
}
formats = formats
.slice( 0, offset )
.concat( newFormats, formats.slice( offset + match.length ) );
replacements = replacements
.slice( 0, offset )
.concat(
newReplacements,
replacements.slice( offset + match.length )
);
if ( start ) {
start = end = offset + newText.length;
}
return newText;
} );
return normaliseFormats( { formats, replacements, text, start, end } );
}

26
node_modules/@wordpress/rich-text/src/slice.js generated vendored Normal file
View File

@@ -0,0 +1,26 @@
/** @typedef {import('./types').RichTextValue} RichTextValue */
/**
* Slice a Rich Text value from `startIndex` to `endIndex`. Indices are
* retrieved from the selection if none are provided. This is similar to
* `String.prototype.slice`.
*
* @param {RichTextValue} value Value to modify.
* @param {number} [startIndex] Start index.
* @param {number} [endIndex] End index.
*
* @return {RichTextValue} A new extracted value.
*/
export function slice( value, startIndex = value.start, endIndex = value.end ) {
const { formats, replacements, text } = value;
if ( startIndex === undefined || endIndex === undefined ) {
return { ...value };
}
return {
formats: formats.slice( startIndex, endIndex ),
replacements: replacements.slice( startIndex, endIndex ),
text: text.slice( startIndex, endIndex ),
};
}

View File

@@ -0,0 +1,10 @@
/**
* Object replacement character, used as a placeholder for objects.
*/
export const OBJECT_REPLACEMENT_CHARACTER = '\ufffc';
/**
* Zero width non-breaking space, used as padding in the editable DOM tree when
* it is empty otherwise.
*/
export const ZWNBSP = '\ufeff';

78
node_modules/@wordpress/rich-text/src/split.js generated vendored Normal file
View File

@@ -0,0 +1,78 @@
/**
* Internal dependencies
*/
/** @typedef {import('./types').RichTextValue} RichTextValue */
/**
* Split a Rich Text value in two at the given `startIndex` and `endIndex`, or
* split at the given separator. This is similar to `String.prototype.split`.
* Indices are retrieved from the selection if none are provided.
*
* @param {RichTextValue} value
* @param {number|string} [string] Start index, or string at which to split.
*
* @return {Array<RichTextValue>|undefined} An array of new values.
*/
export function split( { formats, replacements, text, start, end }, string ) {
if ( typeof string !== 'string' ) {
return splitAtSelection( ...arguments );
}
let nextStart = 0;
return text.split( string ).map( ( substring ) => {
const startIndex = nextStart;
const value = {
formats: formats.slice( startIndex, startIndex + substring.length ),
replacements: replacements.slice(
startIndex,
startIndex + substring.length
),
text: substring,
};
nextStart += string.length + substring.length;
if ( start !== undefined && end !== undefined ) {
if ( start >= startIndex && start < nextStart ) {
value.start = start - startIndex;
} else if ( start < startIndex && end > startIndex ) {
value.start = 0;
}
if ( end >= startIndex && end < nextStart ) {
value.end = end - startIndex;
} else if ( start < nextStart && end > nextStart ) {
value.end = substring.length;
}
}
return value;
} );
}
function splitAtSelection(
{ formats, replacements, text, start, end },
startIndex = start,
endIndex = end
) {
if ( start === undefined || end === undefined ) {
return;
}
const before = {
formats: formats.slice( 0, startIndex ),
replacements: replacements.slice( 0, startIndex ),
text: text.slice( 0, startIndex ),
};
const after = {
formats: formats.slice( endIndex ),
replacements: replacements.slice( endIndex ),
text: text.slice( endIndex ),
start: 0,
end: 0,
};
return [ before, after ];
}

37
node_modules/@wordpress/rich-text/src/store/actions.js generated vendored Normal file
View File

@@ -0,0 +1,37 @@
/**
* Returns an action object used in signalling that format types have been
* added.
* Ignored from documentation as registerFormatType should be used instead from @wordpress/rich-text
*
* @ignore
*
* @param {Array|Object} formatTypes Format types received.
*
* @return {Object} Action object.
*/
export function addFormatTypes( formatTypes ) {
return {
type: 'ADD_FORMAT_TYPES',
formatTypes: Array.isArray( formatTypes )
? formatTypes
: [ formatTypes ],
};
}
/**
* Returns an action object used to remove a registered format type.
*
* Ignored from documentation as unregisterFormatType should be used instead from @wordpress/rich-text
*
* @ignore
*
* @param {string|Array} names Format name.
*
* @return {Object} Action object.
*/
export function removeFormatTypes( names ) {
return {
type: 'REMOVE_FORMAT_TYPES',
names: Array.isArray( names ) ? names : [ names ],
};
}

28
node_modules/@wordpress/rich-text/src/store/index.js generated vendored Normal file
View File

@@ -0,0 +1,28 @@
/**
* WordPress dependencies
*/
import { createReduxStore, register } from '@wordpress/data';
/**
* Internal dependencies
*/
import reducer from './reducer';
import * as selectors from './selectors';
import * as actions from './actions';
const STORE_NAME = 'core/rich-text';
/**
* Store definition for the rich-text namespace.
*
* @see https://github.com/WordPress/gutenberg/blob/HEAD/packages/data/README.md#createReduxStore
*
* @type {Object}
*/
export const store = createReduxStore( STORE_NAME, {
reducer,
selectors,
actions,
} );
register( store );

39
node_modules/@wordpress/rich-text/src/store/reducer.js generated vendored Normal file
View File

@@ -0,0 +1,39 @@
/**
* WordPress dependencies
*/
import { combineReducers } from '@wordpress/data';
/**
* Reducer managing the format types
*
* @param {Object} state Current state.
* @param {Object} action Dispatched action.
*
* @return {Object} Updated state.
*/
export function formatTypes( state = {}, action ) {
switch ( action.type ) {
case 'ADD_FORMAT_TYPES':
return {
...state,
// Key format types by their name.
...action.formatTypes.reduce(
( newFormatTypes, type ) => ( {
...newFormatTypes,
[ type.name ]: type,
} ),
{}
),
};
case 'REMOVE_FORMAT_TYPES':
return Object.fromEntries(
Object.entries( state ).filter(
( [ key ] ) => ! action.names.includes( key )
)
);
}
return state;
}
export default combineReducers( { formatTypes } );

View File

@@ -0,0 +1,159 @@
/**
* WordPress dependencies
*/
import { createSelector } from '@wordpress/data';
/**
* Returns all the available format types.
*
* @param {Object} state Data state.
*
* @example
* ```js
* import { __, sprintf } from '@wordpress/i18n';
* import { store as richTextStore } from '@wordpress/rich-text';
* import { useSelect } from '@wordpress/data';
*
* const ExampleComponent = () => {
* const { getFormatTypes } = useSelect(
* ( select ) => select( richTextStore ),
* []
* );
*
* const availableFormats = getFormatTypes();
*
* return availableFormats ? (
* <ul>
* { availableFormats?.map( ( format ) => (
* <li>{ format.name }</li>
* ) ) }
* </ul>
* ) : (
* __( 'No Formats available' )
* );
* };
* ```
*
* @return {Array} Format types.
*/
export const getFormatTypes = createSelector(
( state ) => Object.values( state.formatTypes ),
( state ) => [ state.formatTypes ]
);
/**
* Returns a format type by name.
*
* @param {Object} state Data state.
* @param {string} name Format type name.
*
* @example
* ```js
* import { __, sprintf } from '@wordpress/i18n';
* import { store as richTextStore } from '@wordpress/rich-text';
* import { useSelect } from '@wordpress/data';
*
* const ExampleComponent = () => {
* const { getFormatType } = useSelect(
* ( select ) => select( richTextStore ),
* []
* );
*
* const boldFormat = getFormatType( 'core/bold' );
*
* return boldFormat ? (
* <ul>
* { Object.entries( boldFormat )?.map( ( [ key, value ] ) => (
* <li>
* { key } : { value }
* </li>
* ) ) }
* </ul>
* ) : (
* __( 'Not Found' )
* ;
* };
* ```
*
* @return {Object?} Format type.
*/
export function getFormatType( state, name ) {
return state.formatTypes[ name ];
}
/**
* Gets the format type, if any, that can handle a bare element (without a
* data-format-type attribute), given the tag name of this element.
*
* @param {Object} state Data state.
* @param {string} bareElementTagName The tag name of the element to find a
* format type for.
*
* @example
* ```js
* import { __, sprintf } from '@wordpress/i18n';
* import { store as richTextStore } from '@wordpress/rich-text';
* import { useSelect } from '@wordpress/data';
*
* const ExampleComponent = () => {
* const { getFormatTypeForBareElement } = useSelect(
* ( select ) => select( richTextStore ),
* []
* );
*
* const format = getFormatTypeForBareElement( 'strong' );
*
* return format && <p>{ sprintf( __( 'Format name: %s' ), format.name ) }</p>;
* }
* ```
*
* @return {?Object} Format type.
*/
export function getFormatTypeForBareElement( state, bareElementTagName ) {
const formatTypes = getFormatTypes( state );
return (
formatTypes.find( ( { className, tagName } ) => {
return className === null && bareElementTagName === tagName;
} ) ||
formatTypes.find( ( { className, tagName } ) => {
return className === null && '*' === tagName;
} )
);
}
/**
* Gets the format type, if any, that can handle an element, given its classes.
*
* @param {Object} state Data state.
* @param {string} elementClassName The classes of the element to find a format
* type for.
*
* @example
* ```js
* import { __, sprintf } from '@wordpress/i18n';
* import { store as richTextStore } from '@wordpress/rich-text';
* import { useSelect } from '@wordpress/data';
*
* const ExampleComponent = () => {
* const { getFormatTypeForClassName } = useSelect(
* ( select ) => select( richTextStore ),
* []
* );
*
* const format = getFormatTypeForClassName( 'has-inline-color' );
*
* return format && <p>{ sprintf( __( 'Format name: %s' ), format.name ) }</p>;
* };
* ```
*
* @return {?Object} Format type.
*/
export function getFormatTypeForClassName( state, elementClassName ) {
return getFormatTypes( state ).find( ( { className } ) => {
if ( className === null ) {
return false;
}
return ` ${ elementClassName } `.indexOf( ` ${ className } ` ) >= 0;
} );
}

View File

@@ -0,0 +1,30 @@
/**
* Internal dependencies
*/
import { addFormatTypes, removeFormatTypes } from '../actions';
describe( 'actions', () => {
describe( 'addFormatTypes', () => {
it( 'should cast format types as an array', () => {
const formatTypes = { name: 'core/test-format' };
const expected = {
type: 'ADD_FORMAT_TYPES',
formatTypes: [ formatTypes ],
};
expect( addFormatTypes( formatTypes ) ).toEqual( expected );
} );
} );
describe( 'removeFormatTypes', () => {
it( 'should cast format types as an array', () => {
const names = 'core/test-format';
const expected = {
type: 'REMOVE_FORMAT_TYPES',
names: [ names ],
};
expect( removeFormatTypes( names ) ).toEqual( expected );
} );
} );
} );

View File

@@ -0,0 +1,47 @@
/**
* External dependencies
*/
import deepFreeze from 'deep-freeze';
/**
* Internal dependencies
*/
import { formatTypes } from '../reducer';
describe( 'formatTypes', () => {
it( 'should return an empty object as default state', () => {
expect( formatTypes( undefined, {} ) ).toEqual( {} );
} );
it( 'should add a new format type', () => {
const original = deepFreeze( {
'core/bold': { name: 'core/bold' },
} );
const state = formatTypes( original, {
type: 'ADD_FORMAT_TYPES',
formatTypes: [ { name: 'core/code' } ],
} );
expect( state ).toEqual( {
'core/bold': { name: 'core/bold' },
'core/code': { name: 'core/code' },
} );
} );
it( 'should remove format types', () => {
const original = deepFreeze( {
'core/bold': { name: 'core/bold' },
'core/code': { name: 'core/code' },
} );
const state = formatTypes( original, {
type: 'REMOVE_FORMAT_TYPES',
names: [ 'core/code' ],
} );
expect( state ).toEqual( {
'core/bold': { name: 'core/bold' },
} );
} );
} );

View File

@@ -0,0 +1,82 @@
/**
* External dependencies
*/
import deepFreeze from 'deep-freeze';
/**
* Internal dependencies
*/
import {
getFormatTypes,
getFormatType,
getFormatTypeForBareElement,
getFormatTypeForClassName,
} from '../selectors';
describe( 'selectors', () => {
const formatType = {
name: 'core/test-format',
className: null,
tagName: 'format',
};
const formatTypeClassName = {
name: 'core/test-format-class-name',
className: 'class-name',
tagName: 'strong',
};
const formatTypeBareTag = {
name: 'core/test-format-bare-tag',
className: null,
tagName: 'strong',
};
const defaultState = deepFreeze( {
formatTypes: {
'core/test-format': formatType,
'core/test-format-class-name': formatTypeClassName,
// Order matters: we need to ensure that
// `core/test-format-class-name` is not considered bare.
'core/test-format-bare-tag': formatTypeBareTag,
},
} );
describe( 'getFormatTypes', () => {
it( 'should get format types', () => {
const expected = [
formatType,
formatTypeClassName,
formatTypeBareTag,
];
expect( getFormatTypes( defaultState ) ).toEqual( expected );
} );
} );
describe( 'getFormatType', () => {
it( 'should get a format type', () => {
const result = getFormatType( defaultState, 'core/test-format' );
expect( result ).toEqual( formatType );
} );
} );
describe( 'getFormatTypeForBareElement', () => {
it( 'should get a format type', () => {
const result = getFormatTypeForBareElement(
defaultState,
'strong'
);
expect( result ).toEqual( formatTypeBareTag );
} );
} );
describe( 'getFormatTypeForClassName', () => {
it( 'should get a format type', () => {
const result = getFormatTypeForClassName(
defaultState,
'class-name'
);
expect( result ).toEqual( formatTypeClassName );
} );
} );
} );

View File

@@ -0,0 +1,303 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`recordToDom should create a value with formatting 1`] = `
<body>
<em
data-rich-text-format-boundary="true"
>
test
</em>
</body>
`;
exports[`recordToDom should create a value with formatting for split tags 1`] = `
<body>
<em
data-rich-text-format-boundary="true"
>
test
</em>
</body>
`;
exports[`recordToDom should create a value with formatting with attributes 1`] = `
<body>
<a
data-rich-text-format-boundary="true"
href="#"
>
test
</a>
</body>
`;
exports[`recordToDom should create a value with image object 1`] = `
<body>
<img
src=""
/>
</body>
`;
exports[`recordToDom should create a value with image object and formatting 1`] = `
<body>
<em
data-rich-text-format-boundary="true"
>
<img
src=""
/>
</em>
</body>
`;
exports[`recordToDom should create a value with image object and text after 1`] = `
<body>
<em>
<img
src=""
/>
te
</em>
st
</body>
`;
exports[`recordToDom should create a value with image object and text before 1`] = `
<body>
te
<em>
st
<img
src=""
/>
</em>
</body>
`;
exports[`recordToDom should create a value with nested formatting 1`] = `
<body>
<em>
<strong
data-rich-text-format-boundary="true"
>
test
</strong>
</em>
</body>
`;
exports[`recordToDom should create a value without formatting 1`] = `
<body>
test
</body>
`;
exports[`recordToDom should create an empty value 1`] = `
<body>

</body>
`;
exports[`recordToDom should create an empty value from empty tags 1`] = `
<body>

</body>
`;
exports[`recordToDom should disarm on* attribute 1`] = `
<body>
<img
data-disable-rich-text-onerror="alert('1')"
/>
</body>
`;
exports[`recordToDom should disarm script 1`] = `
<body>
<script
data-rich-text-script="alert(%221%22)"
/>
</body>
`;
exports[`recordToDom should filter format boundary attributes 1`] = `
<body>
<strong
data-rich-text-format-boundary="true"
>
test
</strong>
</body>
`;
exports[`recordToDom should handle br 1`] = `
<body>
<br
data-rich-text-line-break="true"
/>

</body>
`;
exports[`recordToDom should handle br with formatting 1`] = `
<body>
<em
data-rich-text-format-boundary="true"
>
<br
data-rich-text-line-break="true"
/>
</em>

</body>
`;
exports[`recordToDom should handle br with text 1`] = `
<body>
te
<br
data-rich-text-line-break="true"
/>
st
</body>
`;
exports[`recordToDom should handle double br 1`] = `
<body>
a
<br
data-rich-text-line-break="true"
/>
<br
data-rich-text-line-break="true"
/>
b
</body>
`;
exports[`recordToDom should handle selection before br 1`] = `
<body>
a
<br
data-rich-text-line-break="true"
/>
<br
data-rich-text-line-break="true"
/>
b
</body>
`;
exports[`recordToDom should ignore manually added object replacement character 1`] = `
<body>
test
</body>
`;
exports[`recordToDom should ignore manually added object replacement character with formatting 1`] = `
<body>
<em
data-rich-text-format-boundary="true"
>
hi
</em>
</body>
`;
exports[`recordToDom should not error with overlapping formats (1) 1`] = `
<body>
<a
href="#"
>
<em>
1
</em>
<strong
data-rich-text-format-boundary="true"
>
2
</strong>
</a>
</body>
`;
exports[`recordToDom should not error with overlapping formats (2) 1`] = `
<body>
<em>
<a
data-rich-text-format-boundary="true"
href="#"
>
1
</a>
</em>
<strong>
<a
data-rich-text-format-boundary="true"
href="#"
>
2
</a>
</strong>
</body>
`;
exports[`recordToDom should preserve emoji 1`] = `
<body>
🍒
</body>
`;
exports[`recordToDom should preserve emoji in formatting 1`] = `
<body>
<em
data-rich-text-format-boundary="true"
>
🍒
</em>
</body>
`;
exports[`recordToDom should preserve non breaking space 1`] = `
<body>
test  test
</body>
`;
exports[`recordToDom should remove padding 1`] = `
<body>

</body>
`;

View File

@@ -0,0 +1,272 @@
/**
* External dependencies
*/
import deepFreeze from 'deep-freeze';
/**
* Internal dependencies
*/
import { applyFormat } from '../apply-format';
import { getSparseArrayLength } from './helpers';
describe( 'applyFormat', () => {
const strong = { type: 'strong' };
const em = { type: 'em' };
const a = { type: 'a', attributes: { href: '#' } };
const a2 = { type: 'a', attributes: { href: '#test' } };
it( 'should apply format', () => {
const record = {
formats: [ , , , , ],
text: 'test',
};
const expected = {
...record,
activeFormats: [ em ],
formats: [ [ em ], [ em ], [ em ], [ em ] ],
};
const result = applyFormat( deepFreeze( record ), em, 0, 4 );
expect( result ).toEqual( expected );
expect( result ).not.toBe( record );
expect( getSparseArrayLength( result.formats ) ).toBe( 4 );
} );
it( 'should apply format on top of existing format', () => {
const record = {
formats: [ [ strong ], [ strong ], [ strong ], [ strong ] ],
text: 'test',
};
const expected = {
...record,
activeFormats: [ em ],
formats: [
[ strong, em ],
[ strong, em ],
[ strong, em ],
[ strong, em ],
],
};
const result = applyFormat( deepFreeze( record ), em, 0, 4 );
expect( result ).toEqual( expected );
expect( result ).not.toBe( record );
expect( getSparseArrayLength( result.formats ) ).toBe( 4 );
} );
it( 'should apply format and remove same format type', () => {
const record = {
formats: [ [ strong ], [ em, strong ], [ em, strong ], [ strong ] ],
text: 'test',
};
const expected = {
...record,
activeFormats: [ em ],
formats: [
[ strong, em ],
[ strong, em ],
[ strong, em ],
[ strong, em ],
],
};
const result = applyFormat( deepFreeze( record ), em, 0, 4 );
expect( result ).toEqual( expected );
expect( result ).not.toBe( record );
expect( getSparseArrayLength( result.formats ) ).toBe( 4 );
} );
it( 'should apply format around existing format', () => {
const record = {
formats: [ , [ em ], [ em ], , ],
text: 'test',
};
const expected = {
...record,
activeFormats: [ strong ],
formats: [ [ strong ], [ strong, em ], [ strong, em ], [ strong ] ],
};
const result = applyFormat( deepFreeze( record ), strong, 0, 4 );
expect( result ).toEqual( expected );
expect( result ).not.toBe( record );
expect( getSparseArrayLength( result.formats ) ).toBe( 4 );
} );
it( 'should apply format around existing format with edge right', () => {
const record = {
formats: [ , [ em ], [ em ], , ],
text: 'test',
};
const expected = {
...record,
activeFormats: [ strong ],
formats: [ [ strong ], [ strong, em ], [ strong, em ], , ],
};
const result = applyFormat( deepFreeze( record ), strong, 0, 3 );
expect( result ).toEqual( expected );
expect( result ).not.toBe( record );
expect( getSparseArrayLength( result.formats ) ).toBe( 3 );
} );
it( 'should apply format around existing format with edge left', () => {
const record = {
formats: [ , [ em ], [ em ], , ],
text: 'test',
};
const expected = {
...record,
activeFormats: [ strong ],
formats: [ , [ strong, em ], [ strong, em ], [ strong ] ],
};
const result = applyFormat( deepFreeze( record ), strong, 1, 4 );
expect( result ).toEqual( expected );
expect( result ).not.toBe( record );
expect( getSparseArrayLength( result.formats ) ).toBe( 3 );
} );
it( 'should apply format around existing format with break', () => {
const record = {
formats: [ , [ em ], , [ em ] ],
text: 'test',
};
const expected = {
...record,
activeFormats: [ strong ],
formats: [ , [ strong, em ], [ strong ], [ strong, em ] ],
};
const result = applyFormat( deepFreeze( record ), strong, 1, 4 );
expect( result ).toEqual( expected );
expect( result ).not.toBe( record );
expect( getSparseArrayLength( result.formats ) ).toBe( 3 );
} );
it( 'should apply format crossing existing format', () => {
const record = {
formats: [ , , , , [ em ], [ em ], [ em ], , , , , , , ],
text: 'one two three',
};
const expected = {
activeFormats: [ strong ],
formats: [
,
,
,
[ strong ],
[ strong, em ],
[ strong, em ],
[ em ],
,
,
,
,
,
,
],
text: 'one two three',
};
const result = applyFormat( deepFreeze( record ), strong, 3, 6 );
expect( result ).toEqual( expected );
expect( result ).not.toBe( record );
expect( getSparseArrayLength( result.formats ) ).toBe( 4 );
} );
it( 'should apply format by selection', () => {
const record = {
formats: [ , , , , [ em ], [ em ], [ em ], , , , , , , ],
text: 'one two three',
start: 3,
end: 6,
};
const expected = {
activeFormats: [ strong ],
formats: [
,
,
,
[ strong ],
[ strong, em ],
[ strong, em ],
[ em ],
,
,
,
,
,
,
],
text: 'one two three',
start: 3,
end: 6,
};
const result = applyFormat( deepFreeze( record ), strong );
expect( result ).toEqual( expected );
expect( result ).not.toBe( record );
expect( getSparseArrayLength( result.formats ) ).toBe( 4 );
} );
it( 'should apply format in placeholder if selection is collapsed', () => {
const record = {
formats: [ , , , , [ a ], [ a ], [ a ], , , , , , , ],
text: 'one two three',
start: 0,
end: 0,
};
const expected = {
...record,
activeFormats: [ a2 ],
};
const result = applyFormat( deepFreeze( record ), a2 );
expect( result ).toEqual( expected );
expect( result ).not.toBe( record );
expect( getSparseArrayLength( result.formats ) ).toBe( 3 );
} );
it( 'should apply format on existing format if selection is collapsed', () => {
const record = {
activeFormats: [ a ],
formats: [ , , , , [ a ], [ a ], [ a ], , , , , , , ],
text: 'one two three',
start: 4,
end: 4,
};
const expected = {
activeFormats: [ a2 ],
formats: [ , , , , [ a2 ], [ a2 ], [ a2 ], , , , , , , ],
text: 'one two three',
start: 4,
end: 4,
};
const result = applyFormat( deepFreeze( record ), a2 );
expect( result ).toEqual( expected );
expect( result ).not.toBe( record );
expect( getSparseArrayLength( result.formats ) ).toBe( 3 );
} );
it( 'should merge equal neighbouring formats', () => {
const record = {
// Use a different reference but equal content.
formats: [ , , [ { ...em } ], [ { ...em } ] ],
text: 'test',
};
const expected = {
...record,
activeFormats: [ em ],
// All references should be the same.
formats: [ [ em ], [ em ], [ em ], [ em ] ],
};
const result = applyFormat( deepFreeze( record ), em, 0, 2 );
expect( result ).toEqual( expected );
expect( result ).not.toBe( record );
expect( getSparseArrayLength( result.formats ) ).toBe( 4 );
} );
} );

39
node_modules/@wordpress/rich-text/src/test/concat.js generated vendored Normal file
View File

@@ -0,0 +1,39 @@
/**
* External dependencies
*/
import deepFreeze from 'deep-freeze';
/**
* Internal dependencies
*/
import { concat } from '../concat';
import { getSparseArrayLength } from './helpers';
describe( 'concat', () => {
const em = { type: 'em' };
it( 'should merge records', () => {
const one = {
formats: [ , , [ em ] ],
replacements: [ , , , ],
text: 'one',
};
const two = {
formats: [ [ em ], , , ],
replacements: [ , , , ],
text: 'two',
};
const three = {
formats: [ , , [ em ], [ em ], , , ],
replacements: [ , , , , , , ],
text: 'onetwo',
};
const merged = concat( deepFreeze( one ), deepFreeze( two ) );
expect( merged ).not.toBe( one );
expect( merged ).toEqual( three );
expect( getSparseArrayLength( merged.formats ) ).toBe( 2 );
} );
} );

126
node_modules/@wordpress/rich-text/src/test/create.js generated vendored Normal file
View File

@@ -0,0 +1,126 @@
/**
* Internal dependencies
*/
import { create, removeReservedCharacters } from '../create';
import { OBJECT_REPLACEMENT_CHARACTER, ZWNBSP } from '../special-characters';
import { createElement } from '../create-element';
import { registerFormatType } from '../register-format-type';
import { unregisterFormatType } from '../unregister-format-type';
import { getSparseArrayLength, spec, specWithRegistration } from './helpers';
describe( 'create', () => {
const em = { type: 'em' };
const strong = { type: 'strong' };
beforeAll( () => {
// Initialize the rich-text store.
require( '../store' );
} );
spec.forEach( ( { description, html, createRange, record } ) => {
if ( html === undefined ) {
return;
}
// eslint-disable-next-line jest/valid-title
it( description, () => {
const element = createElement( document, html );
const range = createRange( element );
const createdRecord = create( {
element,
range,
} );
const formatsLength = getSparseArrayLength( record.formats );
const createdFormatsLength = getSparseArrayLength(
createdRecord.formats
);
expect( createdRecord ).toEqual( record );
expect( createdFormatsLength ).toEqual( formatsLength );
} );
} );
specWithRegistration.forEach(
( {
description,
formatName,
formatType,
html,
value: expectedValue,
} ) => {
// eslint-disable-next-line jest/valid-title
it( description, () => {
if ( formatName ) {
registerFormatType( formatName, formatType );
}
const result = create( { html } );
if ( formatName ) {
unregisterFormatType( formatName );
}
expect( result ).toEqual( expectedValue );
} );
}
);
it( 'should reference formats', () => {
const value = create( { html: '<em>te<strong>st</strong></em>' } );
expect( value ).toEqual( {
formats: [ [ em ], [ em ], [ em, strong ], [ em, strong ] ],
replacements: [ , , , , ],
text: 'test',
} );
// Format objects.
expect( value.formats[ 0 ][ 0 ] ).toBe( value.formats[ 1 ][ 0 ] );
expect( value.formats[ 0 ][ 0 ] ).toBe( value.formats[ 2 ][ 0 ] );
expect( value.formats[ 2 ][ 1 ] ).toBe( value.formats[ 3 ][ 1 ] );
// Format arrays per index.
expect( value.formats[ 0 ] ).toBe( value.formats[ 1 ] );
expect( value.formats[ 2 ] ).toBe( value.formats[ 3 ] );
} );
it( 'should use different reference for equal format', () => {
const value = create( { html: '<a href="#">a</a><a href="#">a</a>' } );
// Format objects.
expect( value.formats[ 0 ][ 0 ] ).not.toBe( value.formats[ 1 ][ 0 ] );
// Format arrays per index.
expect( value.formats[ 0 ] ).not.toBe( value.formats[ 1 ] );
} );
it( 'should use different reference for different format', () => {
const value = create( { html: '<a href="#">a</a><a href="#a">a</a>' } );
// Format objects.
expect( value.formats[ 0 ][ 0 ] ).not.toBe( value.formats[ 1 ][ 0 ] );
// Format arrays per index.
expect( value.formats[ 0 ] ).not.toBe( value.formats[ 1 ] );
} );
it( 'removeReservedCharacters should remove all reserved characters', () => {
expect(
removeReservedCharacters( `${ OBJECT_REPLACEMENT_CHARACTER }` )
).toEqual( '' );
expect( removeReservedCharacters( `${ ZWNBSP }` ) ).toEqual( '' );
expect(
removeReservedCharacters(
`${ OBJECT_REPLACEMENT_CHARACTER }c${ OBJECT_REPLACEMENT_CHARACTER }at${ OBJECT_REPLACEMENT_CHARACTER }`
)
).toEqual( 'cat' );
expect(
removeReservedCharacters( `${ ZWNBSP }b${ ZWNBSP }at${ ZWNBSP }` )
).toEqual( 'bat' );
expect(
removeReservedCharacters(
`te${ OBJECT_REPLACEMENT_CHARACTER }st${ ZWNBSP }${ ZWNBSP }`
)
).toEqual( 'test' );
} );
} );

View File

@@ -0,0 +1,98 @@
/**
* Internal dependencies
*/
import { getActiveFormat } from '../get-active-format';
describe( 'getActiveFormat', () => {
const em = { type: 'em' };
const strong = { type: 'strong' };
it( 'should return undefined if there is no selection', () => {
const record = {
formats: [ [ em ], [ em ], [ em ] ],
text: 'one',
};
expect( getActiveFormat( record, 'em' ) ).toBe( undefined );
} );
it( 'should return format when active over whole selection', () => {
const record = {
formats: [ [ em ], [ strong ], , ],
text: 'one',
start: 0,
end: 1,
};
expect( getActiveFormat( record, 'em' ) ).toBe( em );
} );
it( 'should return not return format when not active over whole selection', () => {
const record = {
formats: [ [ em ], [ strong ], , ],
text: 'one',
start: 0,
end: 2,
};
expect( getActiveFormat( record, 'em' ) ).toBe( undefined );
} );
it( 'should return undefined if at the boundary before', () => {
const record = {
formats: [ [ em ], , [ em ] ],
text: 'one',
start: 3,
end: 3,
};
expect( getActiveFormat( record, 'em' ) ).toBe( undefined );
} );
it( 'should return undefined if at the boundary after', () => {
const record = {
formats: [ [ em ], , [ em ] ],
text: 'one',
start: 1,
end: 1,
};
expect( getActiveFormat( record, 'em' ) ).toBe( undefined );
} );
it( 'should return format if inside format', () => {
const record = {
formats: [ [ em ], [ em ], [ em ] ],
text: 'one',
start: 1,
end: 1,
};
expect( getActiveFormat( record, 'em' ) ).toBe( em );
} );
it( 'should return activeFormats', () => {
const record = {
formats: [ [ em ], , [ em ] ],
text: 'one',
start: 1,
end: 1,
activeFormats: [ em ],
};
expect( getActiveFormat( record, 'em' ) ).toBe( em );
} );
it( 'should not return activeFormats for uncollapsed selection', () => {
const record = {
formats: [ [ em ], , [ em ] ],
text: 'one',
start: 1,
end: 2,
activeFormats: [ em ],
};
expect( getActiveFormat( record, 'em' ) ).toBe( undefined );
} );
} );

View File

@@ -0,0 +1,41 @@
/**
* Internal dependencies
*/
import { getActiveObject } from '../get-active-object';
import { OBJECT_REPLACEMENT_CHARACTER } from '../special-characters';
describe( 'getActiveObject', () => {
it( 'should return object if selected', () => {
const record = {
replacements: [ { type: 'img' } ],
text: OBJECT_REPLACEMENT_CHARACTER,
start: 0,
end: 1,
};
expect( getActiveObject( record ) ).toEqual( { type: 'img' } );
} );
it( 'should return nothing if nothing is selected', () => {
const record = {
replacements: [ { type: 'img' } ],
text: OBJECT_REPLACEMENT_CHARACTER,
start: 0,
end: 0,
};
expect( getActiveObject( record ) ).toBe( undefined );
} );
it( 'should return nothing if te selection is not an object', () => {
const record = {
replacements: [ { type: 'em' } ],
text: 'a',
start: 0,
end: 1,
};
expect( getActiveObject( record ) ).toBe( undefined );
} );
} );

View File

@@ -0,0 +1,40 @@
/**
* Internal dependencies
*/
import { getFormatType } from '../get-format-type';
import { unregisterFormatType } from '../unregister-format-type';
import { registerFormatType } from '../register-format-type';
import { getFormatTypes } from '../get-format-types';
const noop = () => {};
describe( 'getFormatType', () => {
beforeAll( () => {
// Initialize the rich-text store.
require( '../store' );
} );
afterEach( () => {
getFormatTypes().forEach( ( format ) => {
unregisterFormatType( format.name );
} );
} );
it( 'should return all format type elements', () => {
const formatType = {
edit: noop,
title: 'format title',
keywords: [ 'one', 'two', 'three' ],
formatTestSetting: 'settingTestValue',
tagName: 'test',
className: null,
};
registerFormatType( 'core/test-format-with-settings', formatType );
expect( getFormatType( 'core/test-format-with-settings' ) ).toEqual( {
name: 'core/test-format-with-settings',
...formatType,
} );
} );
} );

View File

@@ -0,0 +1,57 @@
/**
* Internal dependencies
*/
import { getFormatTypes } from '../get-format-types';
import { unregisterFormatType } from '../unregister-format-type';
import { registerFormatType } from '../register-format-type';
const noop = () => {};
describe( 'getFormatTypes', () => {
beforeAll( () => {
// Initialize the rich-text store.
require( '../store' );
} );
afterEach( () => {
getFormatTypes().forEach( ( format ) => {
unregisterFormatType( format.name );
} );
} );
it( 'should return an empty array at first', () => {
expect( getFormatTypes() ).toEqual( [] );
} );
it( 'should return all registered formats', () => {
const testFormat = {
edit: noop,
title: 'format title',
tagName: 'test',
className: null,
};
const testFormatWithSettings = {
edit: noop,
title: 'format title 2',
keywords: [ 'one', 'two', 'three' ],
tagName: 'test 2',
className: null,
formatTestSetting: 'settingTestValue',
};
registerFormatType( 'core/test-format', testFormat );
registerFormatType(
'core/test-format-with-settings',
testFormatWithSettings
);
expect( getFormatTypes() ).toEqual( [
{
name: 'core/test-format',
...testFormat,
},
{
name: 'core/test-format-with-settings',
...testFormatWithSettings,
},
] );
} );
} );

View File

@@ -0,0 +1,725 @@
/**
* Internal dependencies
*/
import { ZWNBSP, OBJECT_REPLACEMENT_CHARACTER } from '../../special-characters';
export function getSparseArrayLength( array ) {
return array.reduce( ( accumulator ) => accumulator + 1, 0 );
}
const em = { type: 'em' };
const strong = { type: 'strong' };
const img = { type: 'img', attributes: { src: '' } };
const a = { type: 'a', attributes: { href: '#' } };
export const spec = [
{
description: 'should create an empty value',
html: '',
createRange: ( element ) => ( {
startOffset: 0,
startContainer: element,
endOffset: 0,
endContainer: element,
} ),
startPath: [ 0, 0 ],
endPath: [ 0, 0 ],
record: {
start: 0,
end: 0,
formats: [],
replacements: [],
text: '',
},
},
{
description:
'should ignore manually added object replacement character',
html: `test${ OBJECT_REPLACEMENT_CHARACTER }`,
createRange: ( element ) => ( {
startOffset: 0,
startContainer: element,
endOffset: 1,
endContainer: element,
} ),
startPath: [ 0, 0 ],
endPath: [ 0, 4 ],
record: {
start: 0,
end: 4,
formats: [ , , , , ],
replacements: [ , , , , ],
text: 'test',
},
},
{
description:
'should ignore manually added object replacement character with formatting',
html: `<em>h${ OBJECT_REPLACEMENT_CHARACTER }i</em>`,
createRange: ( element ) => ( {
startOffset: 0,
startContainer: element,
endOffset: 1,
endContainer: element,
} ),
startPath: [ 0, 0, 0 ],
endPath: [ 0, 0, 2 ],
record: {
start: 0,
end: 2,
formats: [ [ em ], [ em ] ],
replacements: [ , , ],
text: 'hi',
},
},
{
description: 'should preserve non breaking space',
html: 'test\u00a0 test',
createRange: ( element ) => ( {
startOffset: 5,
startContainer: element.firstChild,
endOffset: 5,
endContainer: element.firstChild,
} ),
startPath: [ 0, 5 ],
endPath: [ 0, 5 ],
record: {
start: 5,
end: 5,
formats: [ , , , , , , , , , , ],
replacements: [ , , , , , , , , , , ],
text: 'test\u00a0 test',
},
},
{
description: 'should create an empty value from empty tags',
html: '<em></em>',
createRange: ( element ) => ( {
startOffset: 0,
startContainer: element,
endOffset: 1,
endContainer: element,
} ),
startPath: [ 0, 0 ],
endPath: [ 0, 0 ],
record: {
start: 0,
end: 0,
formats: [],
replacements: [],
text: '',
},
},
{
description: 'should create a value without formatting',
html: 'test',
createRange: ( element ) => ( {
startOffset: 0,
startContainer: element.firstChild,
endOffset: 4,
endContainer: element.firstChild,
} ),
startPath: [ 0, 0 ],
endPath: [ 0, 4 ],
record: {
start: 0,
end: 4,
formats: [ , , , , ],
replacements: [ , , , , ],
text: 'test',
},
},
{
description: 'should preserve emoji',
html: '🍒',
createRange: ( element ) => ( {
startOffset: 0,
startContainer: element,
endOffset: 1,
endContainer: element,
} ),
startPath: [ 0, 0 ],
endPath: [ 0, 2 ],
record: {
start: 0,
end: 2,
formats: [ , , ],
replacements: [ , , ],
text: '🍒',
},
},
{
description: 'should preserve emoji in formatting',
html: '<em>🍒</em>',
createRange: ( element ) => ( {
startOffset: 0,
startContainer: element,
endOffset: 1,
endContainer: element,
} ),
startPath: [ 0, 0, 0 ],
endPath: [ 0, 0, 2 ],
record: {
start: 0,
end: 2,
formats: [ [ em ], [ em ] ],
replacements: [ , , ],
text: '🍒',
},
},
{
description: 'should create a value with formatting',
html: '<em>test</em>',
createRange: ( element ) => ( {
startOffset: 0,
startContainer: element.firstChild,
endOffset: 1,
endContainer: element.firstChild,
} ),
startPath: [ 0, 0, 0 ],
endPath: [ 0, 0, 4 ],
record: {
start: 0,
end: 4,
formats: [ [ em ], [ em ], [ em ], [ em ] ],
replacements: [ , , , , ],
text: 'test',
},
},
{
description: 'should create a value with nested formatting',
html: '<em><strong>test</strong></em>',
createRange: ( element ) => ( {
startOffset: 0,
startContainer: element,
endOffset: 1,
endContainer: element,
} ),
startPath: [ 0, 0, 0, 0 ],
endPath: [ 0, 0, 0, 4 ],
record: {
start: 0,
end: 4,
formats: [
[ em, strong ],
[ em, strong ],
[ em, strong ],
[ em, strong ],
],
replacements: [ , , , , ],
text: 'test',
},
},
{
description: 'should create a value with formatting for split tags',
html: '<em>te</em><em>st</em>',
createRange: ( element ) => ( {
startOffset: 0,
startContainer: element.querySelector( 'em' ),
endOffset: 1,
endContainer: element.querySelector( 'em' ),
} ),
startPath: [ 0, 0, 0 ],
endPath: [ 0, 0, 2 ],
record: {
start: 0,
end: 2,
formats: [ [ em ], [ em ], [ em ], [ em ] ],
replacements: [ , , , , ],
text: 'test',
},
},
{
description: 'should create a value with formatting with attributes',
html: '<a href="#">test</a>',
createRange: ( element ) => ( {
startOffset: 0,
startContainer: element,
endOffset: 1,
endContainer: element,
} ),
startPath: [ 0, 0, 0 ],
endPath: [ 0, 0, 4 ],
record: {
start: 0,
end: 4,
formats: [ [ a ], [ a ], [ a ], [ a ] ],
replacements: [ , , , , ],
text: 'test',
},
},
{
description: 'should create a value with image object',
html: '<img src="">',
createRange: ( element ) => ( {
startOffset: 0,
startContainer: element,
endOffset: 1,
endContainer: element,
} ),
startPath: [ 0, 0 ],
endPath: [ 0, 0 ],
record: {
start: 0,
end: 0,
formats: [ , ],
replacements: [ img ],
text: '\ufffc',
},
},
{
description: 'should create a value with image object and formatting',
html: '<em><img src=""></em>',
createRange: ( element ) => ( {
startOffset: 0,
startContainer: element.querySelector( 'img' ),
endOffset: 1,
endContainer: element.querySelector( 'img' ),
} ),
startPath: [ 0, 0, 0 ],
endPath: [ 0, 2, 0 ],
record: {
start: 0,
end: 1,
formats: [ [ em ] ],
replacements: [ img ],
text: '\ufffc',
},
},
{
description: 'should create a value with image object and text before',
html: 'te<em>st<img src=""></em>',
createRange: ( element ) => ( {
startOffset: 0,
startContainer: element,
endOffset: 2,
endContainer: element,
} ),
startPath: [ 0, 0 ],
endPath: [ 1, 2, 0 ],
record: {
start: 0,
end: 5,
formats: [ , , [ em ], [ em ], [ em ] ],
replacements: [ , , , , img ],
text: 'test\ufffc',
},
},
{
description: 'should create a value with image object and text after',
html: '<em><img src="">te</em>st',
createRange: ( element ) => ( {
startOffset: 0,
startContainer: element,
endOffset: 2,
endContainer: element,
} ),
startPath: [ 0, 0, 0 ],
endPath: [ 1, 2 ],
record: {
start: 0,
end: 5,
formats: [ [ em ], [ em ], [ em ], , , ],
replacements: [ img, , , , , ],
text: '\ufffctest',
},
},
{
description: 'should handle br',
html: '<br>',
createRange: ( element ) => ( {
startOffset: 0,
startContainer: element,
endOffset: 1,
endContainer: element,
} ),
startPath: [ 0, 0 ],
endPath: [ 0, 0 ],
record: {
start: 0,
end: 0,
formats: [ , ],
replacements: [ , ],
text: '\n',
},
},
{
description: 'should handle br with text',
html: 'te<br>st',
createRange: ( element ) => ( {
startOffset: 1,
startContainer: element,
endOffset: 2,
endContainer: element,
} ),
startPath: [ 0, 2 ],
endPath: [ 2, 0 ],
record: {
start: 2,
end: 3,
formats: [ , , , , , ],
replacements: [ , , , , , ],
text: 'te\nst',
},
},
{
description: 'should handle br with formatting',
html: '<em><br></em>',
createRange: ( element ) => ( {
startOffset: 0,
startContainer: element,
endOffset: 1,
endContainer: element,
} ),
startPath: [ 0, 0, 0 ],
endPath: [ 0, 2, 0 ],
record: {
start: 0,
end: 1,
formats: [ [ em ] ],
replacements: [ , ],
text: '\n',
},
},
{
description: 'should handle double br',
html: 'a<br><br>b',
createRange: ( element ) => ( {
startOffset: 2,
startContainer: element,
endOffset: 3,
endContainer: element,
} ),
startPath: [ 2, 0 ],
endPath: [ 4, 0 ],
record: {
formats: [ , , , , ],
replacements: [ , , , , ],
text: 'a\n\nb',
start: 2,
end: 3,
},
},
{
description: 'should handle selection before br',
html: 'a<br><br>b',
createRange: ( element ) => ( {
startOffset: 2,
startContainer: element,
endOffset: 2,
endContainer: element,
} ),
startPath: [ 2, 0 ],
endPath: [ 2, 0 ],
record: {
formats: [ , , , , ],
replacements: [ , , , , ],
text: 'a\n\nb',
start: 2,
end: 2,
},
},
{
description: 'should remove padding',
html: ZWNBSP,
createRange: ( element ) => ( {
startOffset: 0,
startContainer: element,
endOffset: 1,
endContainer: element,
} ),
startPath: [ 0, 0 ],
endPath: [ 0, 0 ],
record: {
start: 0,
end: 0,
formats: [],
replacements: [],
text: '',
},
},
{
description: 'should filter format boundary attributes',
html: '<strong data-rich-text-format-boundary="true">test</strong>',
createRange: ( element ) => ( {
startOffset: 0,
startContainer: element,
endOffset: 1,
endContainer: element,
} ),
startPath: [ 0, 0, 0 ],
endPath: [ 0, 0, 4 ],
record: {
start: 0,
end: 4,
formats: [ [ strong ], [ strong ], [ strong ], [ strong ] ],
replacements: [ , , , , ],
text: 'test',
},
},
{
description: 'should not error with overlapping formats (1)',
html: '<a href="#"><em>1</em><strong>2</strong></a>',
createRange: ( element ) => ( {
startOffset: 1,
startContainer: element.firstChild,
endOffset: 1,
endContainer: element.firstChild,
} ),
startPath: [ 0, 0, 0, 1 ],
endPath: [ 0, 0, 0, 1 ],
record: {
start: 1,
end: 1,
formats: [
[ a, em ],
[ a, strong ],
],
replacements: [ , , ],
text: '12',
},
},
{
description: 'should not error with overlapping formats (2)',
html: '<em><a href="#">1</a></em><strong><a href="#">2</a></strong>',
createRange: ( element ) => ( {
startOffset: 1,
startContainer: element.firstChild,
endOffset: 1,
endContainer: element.firstChild,
} ),
startPath: [ 0, 0, 0, 1 ],
endPath: [ 0, 0, 0, 1 ],
record: {
start: 1,
end: 1,
formats: [
[ em, a ],
[ strong, a ],
],
replacements: [ , , ],
text: '12',
},
},
{
description: 'should disarm script',
html: '<script>alert("1")</script>',
createRange: ( element ) => ( {
startOffset: 0,
startContainer: element,
endOffset: 0,
endContainer: element,
} ),
startPath: [ 0, 0 ],
endPath: [ 0, 0 ],
record: {
start: 0,
end: 0,
formats: [ , ],
replacements: [
{
attributes: { 'data-rich-text-script': 'alert(%221%22)' },
type: 'script',
},
],
text: '\ufffc',
},
},
{
description: 'should disarm on* attribute',
html: '<img onerror="alert(\'1\')">',
createRange: ( element ) => ( {
startOffset: 0,
startContainer: element,
endOffset: 0,
endContainer: element,
} ),
startPath: [ 0, 0 ],
endPath: [ 0, 0 ],
record: {
start: 0,
end: 0,
formats: [ , ],
replacements: [
{
attributes: {
'data-disable-rich-text-onerror': "alert('1')",
},
type: 'img',
},
],
text: '\ufffc',
},
},
];
export const specWithRegistration = [
{
description: 'should create format by matching the class',
formatName: 'my-plugin/link',
formatType: {
title: 'Custom Link',
tagName: 'a',
className: 'custom-format',
edit() {},
},
html: '<a class="custom-format">a</a>',
value: {
formats: [
[
{
type: 'my-plugin/link',
tagName: 'a',
attributes: {},
unregisteredAttributes: {},
},
],
],
replacements: [ , ],
text: 'a',
},
},
{
description: 'should retain class names',
formatName: 'my-plugin/link',
formatType: {
title: 'Custom Link',
tagName: 'a',
className: 'custom-format',
edit() {},
},
html: '<a class="custom-format test">a</a>',
value: {
formats: [
[
{
type: 'my-plugin/link',
tagName: 'a',
attributes: {},
unregisteredAttributes: {
class: 'test',
},
},
],
],
replacements: [ , ],
text: 'a',
},
},
{
description: 'should create base format',
formatName: 'core/link',
formatType: {
title: 'Link',
tagName: 'a',
className: null,
edit() {},
},
html: '<a class="custom-format">a</a>',
value: {
formats: [
[
{
type: 'core/link',
tagName: 'a',
attributes: {},
unregisteredAttributes: {
class: 'custom-format',
},
},
],
],
replacements: [ , ],
text: 'a',
},
},
{
description: 'should create fallback format',
html: '<a class="custom-format">a</a>',
value: {
formats: [
[
{
type: 'a',
attributes: {
class: 'custom-format',
},
},
],
],
replacements: [ , ],
text: 'a',
},
},
{
description: 'should not create format if editable tree only',
formatName: 'my-plugin/link',
formatType: {
title: 'Custom Link',
tagName: 'a',
className: 'custom-format',
edit() {},
__experimentalCreatePrepareEditableTree() {},
},
html: '<a class="custom-format">a</a>',
value: {
formats: [ , ],
replacements: [ , ],
text: 'a',
},
noToHTMLString: true,
},
{
description:
'should create format if editable tree only but changes need to be recorded',
formatName: 'my-plugin/link',
formatType: {
title: 'Custom Link',
tagName: 'a',
className: 'custom-format',
edit() {},
__experimentalCreatePrepareEditableTree() {},
__experimentalCreateOnChangeEditableValue() {},
},
html: '<a class="custom-format">a</a>',
value: {
formats: [
[
{
type: 'my-plugin/link',
tagName: 'a',
attributes: {},
unregisteredAttributes: {},
},
],
],
replacements: [ , ],
text: 'a',
},
},
{
description: 'should be non editable',
formatName: 'my-plugin/non-editable',
formatType: {
title: 'Non Editable',
tagName: 'a',
className: 'non-editable',
contentEditable: false,
edit() {},
},
html: '<a class="non-editable">a</a>',
value: {
formats: [ , ],
replacements: [
{
type: 'my-plugin/non-editable',
tagName: 'a',
attributes: {},
unregisteredAttributes: {},
innerHTML: 'a',
},
],
text: OBJECT_REPLACEMENT_CHARACTER,
},
},
];

View File

@@ -0,0 +1,39 @@
/**
* External dependencies
*/
import deepFreeze from 'deep-freeze';
/**
* Internal dependencies
*/
import { insertObject } from '../insert-object';
import { getSparseArrayLength } from './helpers';
import { OBJECT_REPLACEMENT_CHARACTER } from '../special-characters';
describe( 'insert', () => {
const obj = { type: 'obj' };
const em = { type: 'em' };
it( 'should delete and insert', () => {
const record = {
formats: [ , , , , [ em ], [ em ], [ em ], , , , , , , ],
replacements: [ , , , , , , , , , , , , , ],
text: 'one two three',
start: 6,
end: 6,
};
const expected = {
formats: [ , , , [ em ], , , , , , , ],
replacements: [ , , obj, , , , , , , , ],
text: `on${ OBJECT_REPLACEMENT_CHARACTER }o three`,
start: 3,
end: 3,
};
const result = insertObject( deepFreeze( record ), obj, 2, 6 );
expect( result ).toEqual( expected );
expect( result ).not.toBe( record );
expect( getSparseArrayLength( result.formats ) ).toBe( 1 );
expect( getSparseArrayLength( result.replacements ) ).toBe( 1 );
} );
} );

70
node_modules/@wordpress/rich-text/src/test/insert.js generated vendored Normal file
View File

@@ -0,0 +1,70 @@
/**
* External dependencies
*/
import deepFreeze from 'deep-freeze';
/**
* Internal dependencies
*/
import { insert } from '../insert';
import { getSparseArrayLength } from './helpers';
describe( 'insert', () => {
const em = { type: 'em' };
const strong = { type: 'strong' };
it( 'should delete and insert', () => {
const record = {
formats: [ , , , , [ em ], [ em ], [ em ], , , , , , , ],
replacements: [],
text: 'one two three',
start: 6,
end: 6,
};
const toInsert = {
formats: [ [ strong ] ],
replacements: [],
text: 'a',
};
const expected = {
formats: [ , , [ strong ], [ em ], , , , , , , ],
replacements: [],
text: 'onao three',
start: 3,
end: 3,
};
const result = insert( deepFreeze( record ), toInsert, 2, 6 );
expect( result ).toEqual( expected );
expect( result ).not.toBe( record );
expect( getSparseArrayLength( result.formats ) ).toBe( 2 );
} );
it( 'should insert line break with selection', () => {
const record = {
formats: [ , , ],
replacements: [],
text: 'tt',
start: 1,
end: 1,
};
const toInsert = {
formats: [ , ],
replacements: [],
text: '\n',
};
const expected = {
formats: [ , , , ],
replacements: [],
text: 't\nt',
start: 2,
end: 2,
};
const result = insert( deepFreeze( record ), toInsert );
expect( result ).toEqual( expected );
expect( result ).not.toBe( record );
expect( getSparseArrayLength( result.formats ) ).toBe( 0 );
} );
} );

View File

@@ -0,0 +1,16 @@
/**
* Internal dependencies
*/
import { isCollapsed } from '../is-collapsed';
describe( 'isCollapsed', () => {
it( 'should return true for a collapsed selection', () => {
const record = {
start: 4,
end: 4,
};
expect( isCollapsed( record ) ).toBe( true );
} );
} );

25
node_modules/@wordpress/rich-text/src/test/is-empty.js generated vendored Normal file
View File

@@ -0,0 +1,25 @@
/**
* Internal dependencies
*/
import { isEmpty } from '../is-empty';
describe( 'isEmpty', () => {
it( 'should return true', () => {
const one = {
formats: [],
text: '',
};
expect( isEmpty( one ) ).toBe( true );
} );
it( 'should return false', () => {
const one = {
formats: [],
text: 'test',
};
expect( isEmpty( one ) ).toBe( false );
} );
} );

View File

@@ -0,0 +1,73 @@
/**
* Internal dependencies
*/
import { isFormatEqual } from '../is-format-equal';
describe( 'isFormatEqual', () => {
const spec = [
{
format1: undefined,
format2: undefined,
isEqual: true,
description: 'should return true if both are undefined',
},
{
format1: {},
format2: undefined,
isEqual: false,
description: 'should return false if one is undefined',
},
{
format1: { type: 'bold' },
format2: { type: 'bold' },
isEqual: true,
description: 'should return true if both have same type',
},
{
format1: { type: 'bold' },
format2: { type: 'italic' },
isEqual: false,
description: 'should return false if one has different type',
},
{
format1: { type: 'bold', attributes: {} },
format2: { type: 'bold' },
isEqual: false,
description: 'should return false if one has undefined attributes',
},
{
format1: { type: 'bold', attributes: { a: '1' } },
format2: { type: 'bold', attributes: { a: '1' } },
isEqual: true,
description: 'should return true if both have same attributes',
},
{
format1: { type: 'bold', attributes: { a: '1' } },
format2: { type: 'bold', attributes: { b: '1' } },
isEqual: false,
description: 'should return false if one has different attributes',
},
{
format1: { type: 'bold', attributes: { a: '1' } },
format2: { type: 'bold', attributes: { a: '1', b: '1' } },
isEqual: false,
description:
'should return false if one has a different amount of attributes',
},
{
format1: { type: 'bold', attributes: { b: '1', a: '1' } },
format2: { type: 'bold', attributes: { a: '1', b: '1' } },
isEqual: true,
description:
'should return true both have same attributes but different order',
},
];
spec.forEach( ( { format1, format2, isEqual, description } ) => {
// eslint-disable-next-line jest/valid-title
it( description, () => {
expect( isFormatEqual( format1, format2 ) ).toBe( isEqual );
} );
} );
} );

52
node_modules/@wordpress/rich-text/src/test/join.js generated vendored Normal file
View File

@@ -0,0 +1,52 @@
/**
* External dependencies
*/
import deepFreeze from 'deep-freeze';
/**
* Internal dependencies
*/
import { join } from '../join';
import { getSparseArrayLength } from './helpers';
describe( 'join', () => {
const em = { type: 'em' };
const separators = [
' ',
{
formats: [ , ],
replacements: [ , ],
text: ' ',
},
];
separators.forEach( ( separator ) => {
it( 'should join records with string separator', () => {
const one = {
formats: [ , , [ em ] ],
replacements: [ , , , ],
text: 'one',
};
const two = {
formats: [ [ em ], , , ],
replacements: [ , , , ],
text: 'two',
};
const three = {
formats: [ , , [ em ], , [ em ], , , ],
replacements: [ , , , , , , , ],
text: 'one two',
};
const result = join(
[ deepFreeze( one ), deepFreeze( two ) ],
separator
);
expect( result ).not.toBe( one );
expect( result ).not.toBe( two );
expect( result ).toEqual( three );
expect( getSparseArrayLength( result.formats ) ).toBe( 2 );
} );
} );
} );

View File

@@ -0,0 +1,39 @@
/**
* External dependencies
*/
import deepFreeze from 'deep-freeze';
/**
* Internal dependencies
*/
import { normaliseFormats } from '../normalise-formats';
import { getSparseArrayLength } from './helpers';
describe( 'normaliseFormats', () => {
const strong = { type: 'strong' };
const em = { type: 'em' };
it( 'should normalise formats', () => {
const record = {
formats: [
,
[ em ],
[ { ...em }, { ...strong } ],
[ em, strong ],
,
[ { ...em } ],
],
text: 'one two three',
};
const result = normaliseFormats( deepFreeze( record ) );
expect( result ).toEqual( record );
expect( result ).not.toBe( record );
expect( getSparseArrayLength( result.formats ) ).toBe( 4 );
expect( result.formats[ 1 ][ 0 ] ).toBe( result.formats[ 2 ][ 0 ] );
expect( result.formats[ 1 ][ 0 ] ).toBe( result.formats[ 3 ][ 0 ] );
expect( result.formats[ 1 ][ 0 ] ).not.toBe( result.formats[ 5 ][ 0 ] );
expect( result.formats[ 2 ][ 1 ] ).toBe( result.formats[ 3 ][ 1 ] );
} );
} );

View File

@@ -0,0 +1,245 @@
/**
* WordPress dependencies
*/
import { dispatch, select } from '@wordpress/data';
/**
* Internal dependencies
*/
import { registerFormatType } from '../register-format-type';
import { unregisterFormatType } from '../unregister-format-type';
import { getFormatType } from '../get-format-type';
import { store as richTextStore } from '../store';
const UNKNOWN_FORMAT = {
name: 'core/unknown',
title: 'Clear Unknown Formatting',
tagName: '*',
className: null,
edit: () => null,
};
describe( 'registerFormatType', () => {
beforeAll( () => {
// Initialize the rich-text store.
require( '../store' );
// Register "core/unknown" format
dispatch( richTextStore ).addFormatTypes( UNKNOWN_FORMAT );
} );
afterEach( () => {
select( richTextStore )
.getFormatTypes()
.forEach( ( { name } ) => {
unregisterFormatType( name );
} );
} );
const validName = 'plugin/test';
const validSettings = {
tagName: 'test',
className: null,
title: 'Test',
edit() {},
};
it( 'should register format', () => {
const settings = registerFormatType( validName, validSettings );
expect( settings ).toEqual( { ...validSettings, name: validName } );
expect( console ).not.toHaveErrored();
} );
it( 'should error without arguments', () => {
const format = registerFormatType();
expect( console ).toHaveErroredWith( 'Format names must be strings.' );
expect( format ).toBeUndefined();
} );
it( 'should reject format types without a namespace', () => {
const format = registerFormatType( 'doing-it-wrong' );
expect( console ).toHaveErroredWith(
'Format names must contain a namespace prefix, include only lowercase alphanumeric characters or dashes, and start with a letter. Example: my-plugin/my-custom-format'
);
expect( format ).toBeUndefined();
} );
it( 'should reject format types with too many namespaces', () => {
const format = registerFormatType( 'doing/it/wrong' );
expect( console ).toHaveErroredWith(
'Format names must contain a namespace prefix, include only lowercase alphanumeric characters or dashes, and start with a letter. Example: my-plugin/my-custom-format'
);
expect( format ).toBeUndefined();
} );
it( 'should reject format types with invalid characters', () => {
const format = registerFormatType( 'still/_doing_it_wrong' );
expect( console ).toHaveErroredWith(
'Format names must contain a namespace prefix, include only lowercase alphanumeric characters or dashes, and start with a letter. Example: my-plugin/my-custom-format'
);
expect( format ).toBeUndefined();
} );
it( 'should reject format types with uppercase characters', () => {
const format = registerFormatType( 'Core/Bold' );
expect( console ).toHaveErroredWith(
'Format names must contain a namespace prefix, include only lowercase alphanumeric characters or dashes, and start with a letter. Example: my-plugin/my-custom-format'
);
expect( format ).toBeUndefined();
} );
it( 'should reject format types not starting with a letter', () => {
const format = registerFormatType( 'my-plugin/4-fancy-format' );
expect( console ).toHaveErroredWith(
'Format names must contain a namespace prefix, include only lowercase alphanumeric characters or dashes, and start with a letter. Example: my-plugin/my-custom-format'
);
expect( format ).toBeUndefined();
} );
it( 'should reject numbers', () => {
const format = registerFormatType( 42 );
expect( console ).toHaveErroredWith( 'Format names must be strings.' );
expect( format ).toBeUndefined();
} );
it( 'should accept valid format names', () => {
const format = registerFormatType(
'my-plugin/fancy-format-4',
validSettings
);
expect( console ).not.toHaveErrored();
expect( format ).toEqual( {
name: 'my-plugin/fancy-format-4',
...validSettings,
} );
} );
it( 'should error on already registered name', () => {
registerFormatType( validName, validSettings );
const duplicateFormat = registerFormatType( validName, validSettings );
expect( console ).toHaveErroredWith(
'Format "plugin/test" is already registered.'
);
expect( duplicateFormat ).toBeUndefined();
} );
it( 'should reject formats without tag name', () => {
const settings = { ...validSettings };
delete settings.tagName;
const format = registerFormatType( validName, settings );
expect( console ).toHaveErroredWith(
'Format tag names must be a string.'
);
expect( format ).toBeUndefined();
} );
it( 'should error on empty tagName property', () => {
const format = registerFormatType( validName, {
...validSettings,
tagName: '',
} );
expect( console ).toHaveErroredWith(
'Format tag names must be a string.'
);
expect( format ).toBeUndefined();
} );
it( 'should reject formats without class name', () => {
const settings = { ...validSettings };
delete settings.className;
const format = registerFormatType( validName, settings );
expect( console ).toHaveErroredWith(
'Format class names must be a string, or null to handle bare elements.'
);
expect( format ).toBeUndefined();
} );
it( 'should error on invalid empty className property', () => {
const format = registerFormatType( validName, {
...validSettings,
className: '',
} );
expect( console ).toHaveErroredWith(
'Format class names must be a string, or null to handle bare elements.'
);
expect( format ).toBeUndefined();
} );
it( 'should error on invalid className property', () => {
const format = registerFormatType( validName, {
...validSettings,
className: 'invalid class name',
} );
expect( console ).toHaveErroredWith(
'A class name must begin with a letter, followed by any number of hyphens, underscores, letters, or numbers.'
);
expect( format ).toBeUndefined();
} );
it( 'should error on already registered tagName', () => {
registerFormatType( validName, validSettings );
const duplicateTagNameFormat = registerFormatType(
'plugin/second',
validSettings
);
expect( console ).toHaveErroredWith(
'Format "plugin/test" is already registered to handle bare tag name "test".'
);
expect( duplicateTagNameFormat ).toBeUndefined();
} );
it( 'should error on already registered className', () => {
registerFormatType( validName, {
...validSettings,
className: 'test',
} );
const duplicateClassNameFormat = registerFormatType( 'plugin/second', {
...validSettings,
className: 'test',
} );
expect( console ).toHaveErroredWith(
'Format "plugin/test" is already registered to handle class name "test".'
);
expect( duplicateClassNameFormat ).toBeUndefined();
} );
it( 'should reject formats without title', () => {
const settings = { ...validSettings };
delete settings.title;
const format = registerFormatType( validName, settings );
expect( console ).toHaveErroredWith(
`The format "${ validName }" must have a title.`
);
expect( format ).toBeUndefined();
} );
it( 'should error on empty title property', () => {
const format = registerFormatType( validName, {
...validSettings,
title: '',
} );
expect( console ).toHaveErroredWith(
'The format "plugin/test" must have a title.'
);
expect( format ).toBeUndefined();
} );
it( 'should reject titles which are not strings', () => {
const format = registerFormatType( validName, {
...validSettings,
title: 1337,
} );
expect( console ).toHaveErroredWith( 'Format titles must be strings.' );
expect( format ).toBeUndefined();
} );
it( 'should store a copy of the format type', () => {
const formatType = { ...validSettings };
registerFormatType( validName, formatType );
formatType.mutated = true;
expect( getFormatType( validName ) ).toEqual( {
name: validName,
...validSettings,
} );
} );
} );

View File

@@ -0,0 +1,78 @@
/**
* External dependencies
*/
import deepFreeze from 'deep-freeze';
/**
* Internal dependencies
*/
import { removeFormat } from '../remove-format';
import { getSparseArrayLength } from './helpers';
describe( 'removeFormat', () => {
const strong = { type: 'strong' };
const em = { type: 'em' };
it( 'should remove format', () => {
const record = {
formats: [
,
,
,
[ strong ],
[ em, strong ],
[ em, strong ],
[ em ],
,
,
,
,
,
,
],
text: 'one two three',
};
const expected = {
formats: [ , , , , [ em ], [ em ], [ em ], , , , , , , ],
activeFormats: [],
text: 'one two three',
};
const result = removeFormat( deepFreeze( record ), 'strong', 3, 6 );
expect( result ).toEqual( expected );
expect( result ).not.toBe( record );
expect( getSparseArrayLength( result.formats ) ).toBe( 3 );
} );
it( 'should remove format for collased selection', () => {
const record = {
formats: [
,
,
,
[ strong ],
[ em, strong ],
[ em, strong ],
[ em ],
,
,
,
,
,
,
],
text: 'one two three',
};
const expected = {
formats: [ , , , , [ em ], [ em ], [ em ], , , , , , , ],
activeFormats: [],
text: 'one two three',
};
const result = removeFormat( deepFreeze( record ), 'strong', 4, 4 );
expect( result ).toEqual( expected );
expect( result ).not.toBe( record );
expect( getSparseArrayLength( result.formats ) ).toBe( 3 );
} );
} );

93
node_modules/@wordpress/rich-text/src/test/replace.js generated vendored Normal file
View File

@@ -0,0 +1,93 @@
/**
* External dependencies
*/
import deepFreeze from 'deep-freeze';
/**
* Internal dependencies
*/
import { replace } from '../replace';
import { getSparseArrayLength } from './helpers';
describe( 'replace', () => {
const em = { type: 'em' };
it( 'should replace string to string', () => {
const record = {
formats: [ , , , , [ em ], [ em ], [ em ], , , , , , , ],
replacements: [ , , , , , , , , , , , , , ],
text: 'one two three',
start: 6,
end: 6,
};
const expected = {
formats: [ , , , , [ em ], , , , , , , ],
replacements: [ , , , , , , , , , , , ],
text: 'one 2 three',
start: 5,
end: 5,
};
const result = replace( deepFreeze( record ), 'two', '2' );
expect( result ).toEqual( expected );
expect( result ).not.toBe( record );
expect( getSparseArrayLength( result.formats ) ).toBe( 1 );
} );
it( 'should replace string to record', () => {
const record = {
formats: [ , , , , [ em ], [ em ], [ em ], , , , , , , ],
replacements: [ , , , , , , , , , , , , , ],
text: 'one two three',
start: 6,
end: 6,
};
const replacement = {
formats: [ , ],
replacements: [ , ],
text: '2',
};
const expected = {
formats: [ , , , , , , , , , , , ],
replacements: [ , , , , , , , , , , , ],
text: 'one 2 three',
start: 5,
end: 5,
};
const result = replace( deepFreeze( record ), 'two', replacement );
expect( result ).toEqual( expected );
expect( result ).not.toBe( record );
expect( getSparseArrayLength( result.formats ) ).toBe( 0 );
} );
it( 'should replace string to function', () => {
const record = {
formats: [ , , , , , , , , , , , , ],
replacements: [ , , , , , , , , , , , , ],
text: 'abc12345#$*%',
start: 6,
end: 6,
};
const expected = {
formats: [ , , , , , , , , , , , , , , , , , , ],
replacements: [ , , , , , , , , , , , , , , , , , , ],
text: 'abc - 12345 - #$*%',
start: 18,
end: 18,
};
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replace
const result = replace(
deepFreeze( record ),
/([^\d]*)(\d*)([^\w]*)/,
( match, p1, p2, p3 ) => {
return [ p1, p2, p3 ].join( ' - ' );
}
);
expect( result ).toEqual( expected );
expect( result ).not.toBe( record );
expect( getSparseArrayLength( result.formats ) ).toBe( 0 );
} );
} );

53
node_modules/@wordpress/rich-text/src/test/slice.js generated vendored Normal file
View File

@@ -0,0 +1,53 @@
/**
* External dependencies
*/
import deepFreeze from 'deep-freeze';
/**
* Internal dependencies
*/
import { slice } from '../slice';
import { getSparseArrayLength } from './helpers';
describe( 'slice', () => {
const em = { type: 'em' };
it( 'should slice', () => {
const record = {
formats: [ , , , , [ em ], [ em ], [ em ], , , , , , , ],
replacements: [ , , , , , , , , , , , , , ],
text: 'one two three',
};
const expected = {
formats: [ , [ em ], [ em ] ],
replacements: [ , , , ],
text: ' tw',
};
const result = slice( deepFreeze( record ), 3, 6 );
expect( result ).toEqual( expected );
expect( result ).not.toBe( record );
expect( getSparseArrayLength( result.formats ) ).toBe( 2 );
} );
it( 'should slice record', () => {
const record = {
formats: [ , , , , [ em ], [ em ], [ em ], , , , , , , ],
replacements: [ , , , , , , , , , , , , , ],
text: 'one two three',
start: 3,
end: 6,
};
const expected = {
formats: [ , [ em ], [ em ] ],
replacements: [ , , , ],
text: ' tw',
};
const result = slice( deepFreeze( record ) );
expect( result ).toEqual( expected );
expect( result ).not.toBe( record );
expect( getSparseArrayLength( result.formats ) ).toBe( 2 );
} );
} );

237
node_modules/@wordpress/rich-text/src/test/split.js generated vendored Normal file
View File

@@ -0,0 +1,237 @@
/**
* External dependencies
*/
import deepFreeze from 'deep-freeze';
/**
* Internal dependencies
*/
import { split } from '../split';
import { getSparseArrayLength } from './helpers';
describe( 'split', () => {
const em = { type: 'em' };
it( 'should split', () => {
const record = {
start: 5,
end: 10,
formats: [ , , , , [ em ], [ em ], [ em ], , , , , , , ],
replacements: [ , , , , , , , , , , , , , ],
text: 'one two three',
};
const expected = [
{
formats: [ , , , , [ em ], [ em ] ],
replacements: [ , , , , , , ],
text: 'one tw',
},
{
start: 0,
end: 0,
formats: [ [ em ], , , , , , , ],
replacements: [ , , , , , , , ],
text: 'o three',
},
];
const result = split( deepFreeze( record ), 6, 6 );
expect( result ).toEqual( expected );
result.forEach( ( item, index ) => {
expect( item ).not.toBe( record );
expect( getSparseArrayLength( item.formats ) ).toBe(
getSparseArrayLength( expected[ index ].formats )
);
} );
} );
it( 'should split with selection', () => {
const record = {
formats: [ , , , , [ em ], [ em ], [ em ], , , , , , , ],
replacements: [ , , , , , , , , , , , , , ],
text: 'one two three',
start: 6,
end: 6,
};
const expected = [
{
formats: [ , , , , [ em ], [ em ] ],
replacements: [ , , , , , , ],
text: 'one tw',
},
{
formats: [ [ em ], , , , , , , ],
replacements: [ , , , , , , , ],
text: 'o three',
start: 0,
end: 0,
},
];
const result = split( deepFreeze( record ) );
expect( result ).toEqual( expected );
result.forEach( ( item, index ) => {
expect( item ).not.toBe( record );
expect( getSparseArrayLength( item.formats ) ).toBe(
getSparseArrayLength( expected[ index ].formats )
);
} );
} );
it( 'should split empty', () => {
const record = {
formats: [],
replacements: [],
text: '',
start: 0,
end: 0,
};
const expected = [
{
formats: [],
replacements: [],
text: '',
},
{
formats: [],
replacements: [],
text: '',
start: 0,
end: 0,
},
];
const result = split( deepFreeze( record ) );
expect( result ).toEqual( expected );
result.forEach( ( item, index ) => {
expect( item ).not.toBe( record );
expect( getSparseArrayLength( item.formats ) ).toBe(
getSparseArrayLength( expected[ index ].formats )
);
} );
} );
it( 'should split search', () => {
const record = {
start: 6,
end: 16,
formats: [
,
,
,
,
[ em ],
[ em ],
[ em ],
,
,
,
,
,
,
,
,
,
,
,
,
,
,
,
,
],
replacements: [ , , , , , , , , , , , , , , , , , , , , , , , ],
text: 'one two three four five',
};
const expected = [
{
formats: [ , , , ],
replacements: [ , , , ],
text: 'one',
},
{
start: 2,
end: 3,
formats: [ [ em ], [ em ], [ em ] ],
replacements: [ , , , ],
text: 'two',
},
{
start: 0,
end: 5,
formats: [ , , , , , ],
replacements: [ , , , , , ],
text: 'three',
},
{
start: 0,
end: 2,
formats: [ , , , , ],
replacements: [ , , , , ],
text: 'four',
},
{
formats: [ , , , , ],
replacements: [ , , , , ],
text: 'five',
},
];
const result = split( deepFreeze( record ), ' ' );
expect( result ).toEqual( expected );
result.forEach( ( item, index ) => {
expect( item ).not.toBe( record );
expect( getSparseArrayLength( item.formats ) ).toBe(
getSparseArrayLength( expected[ index ].formats )
);
} );
} );
it( 'should split search 2', () => {
const record = {
start: 5,
end: 6,
formats: [ , , , , [ em ], [ em ], [ em ], , , , , , , ],
replacements: [ , , , , , , , , , , , , , ],
text: 'one two three',
};
const expected = [
{
formats: [ , , , ],
replacements: [ , , , ],
text: 'one',
},
{
start: 1,
end: 2,
formats: [ [ em ], [ em ], [ em ] ],
replacements: [ , , , ],
text: 'two',
},
{
formats: [ , , , , , ],
replacements: [ , , , , , ],
text: 'three',
},
];
const result = split( deepFreeze( record ), ' ' );
expect( result ).toEqual( expected );
result.forEach( ( item, index ) => {
expect( item ).not.toBe( record );
expect( getSparseArrayLength( item.formats ) ).toBe(
getSparseArrayLength( expected[ index ].formats )
);
} );
} );
it( 'should not split without selection', () => {
const record = {
formats: [],
replacements: [],
text: '',
};
expect( split( deepFreeze( record ) ) ).toBe( undefined );
} );
} );

100
node_modules/@wordpress/rich-text/src/test/to-dom.js generated vendored Normal file
View File

@@ -0,0 +1,100 @@
/**
* Internal dependencies
*/
import { toDom, applyValue } from '../to-dom';
import { createElement } from '../create-element';
import { spec } from './helpers';
describe( 'recordToDom', () => {
beforeAll( () => {
// Initialize the rich-text store.
require( '../store' );
} );
spec.forEach( ( { description, record, startPath, endPath } ) => {
// eslint-disable-next-line jest/valid-title
it( description, () => {
const { body, selection } = toDom( {
value: record,
} );
expect( body ).toMatchSnapshot();
expect( selection ).toEqual( { startPath, endPath } );
} );
} );
} );
describe( 'applyValue', () => {
const cases = [
{
current: 'test',
future: '',
movedCount: 0,
description: 'should remove nodes',
},
{
current: '',
future: 'test',
movedCount: 1,
description: 'should add nodes',
},
{
current: 'test',
future: 'test',
movedCount: 0,
description: 'should not modify',
},
{
current: '<span data-1="">b</span>',
future: '<span>b</span>',
movedCount: 0,
description: 'should remove attribute',
},
{
current: '<span data-1="" data-2="">b</span>',
future: '<span>b</span>',
movedCount: 0,
description: 'should remove attributes',
},
{
current: '<span>a</span>',
future: '<span data-1="">c</span>',
movedCount: 0,
description: 'should add attribute',
},
{
current: '<span>a</span>',
future: '<span data-1="" data-2="">c</span>',
movedCount: 0,
description: 'should add attributes',
},
{
current: '<span data-1="i">a</span>',
future: '<span data-1="ii">a</span>',
movedCount: 0,
description: 'should update attribute',
},
{
current: '<span data-1="i" data-2="ii">a</span>',
future: '<span data-1="ii" data-2="i">a</span>',
movedCount: 0,
description: 'should update attributes',
},
];
cases.forEach( ( { current, future, description, movedCount } ) => {
// eslint-disable-next-line jest/valid-title
it( description, () => {
const body = createElement( document, current ).cloneNode( true );
const futureBody = createElement( document, future ).cloneNode(
true
);
const childNodes = Array.from( futureBody.childNodes );
applyValue( futureBody, body );
const count = childNodes.reduce( ( acc, { parentNode } ) => {
return parentNode === body ? acc + 1 : acc;
}, 0 );
expect( body.innerHTML ).toEqual( future );
expect( count ).toEqual( movedCount );
} );
} );
} );

View File

@@ -0,0 +1,117 @@
/**
* Internal dependencies
*/
import { create } from '../create';
import { toHTMLString } from '../to-html-string';
import { registerFormatType } from '../register-format-type';
import { unregisterFormatType } from '../unregister-format-type';
import { specWithRegistration } from './helpers';
function createNode( HTML ) {
const doc = document.implementation.createHTMLDocument( '' );
doc.body.innerHTML = HTML;
return doc.body.firstChild;
}
describe( 'toHTMLString', () => {
beforeAll( () => {
// Initialize the rich-text store.
require( '../store' );
} );
specWithRegistration.forEach(
( {
description,
formatName,
formatType,
html,
value,
noToHTMLString,
} ) => {
if ( noToHTMLString ) {
return;
}
// eslint-disable-next-line jest/valid-title
it( description, () => {
if ( formatName ) {
registerFormatType( formatName, formatType );
}
const result = toHTMLString( { value } );
if ( formatName ) {
unregisterFormatType( formatName );
}
expect( result ).toEqual( html );
} );
}
);
it( 'should extract recreate HTML 1', () => {
const HTML =
'one <em>two 🍒</em> <a href="#"><img src=""><strong>three</strong></a><img src="">';
const element = createNode( `<p>${ HTML }</p>` );
expect( toHTMLString( { value: create( { element } ) } ) ).toEqual(
HTML
);
} );
it( 'should extract recreate HTML 2', () => {
const HTML =
'one <em>two 🍒</em> <a href="#">test <img src=""><strong>three</strong></a><img src="">';
const element = createNode( `<p>${ HTML }</p>` );
expect( toHTMLString( { value: create( { element } ) } ) ).toEqual(
HTML
);
} );
it( 'should extract recreate HTML 3', () => {
const HTML = '<img src="">';
const element = createNode( `<p>${ HTML }</p>` );
expect( toHTMLString( { value: create( { element } ) } ) ).toEqual(
HTML
);
} );
it( 'should extract recreate HTML 4', () => {
const HTML = '<em>two 🍒</em>';
const element = createNode( `<p>${ HTML }</p>` );
expect( toHTMLString( { value: create( { element } ) } ) ).toEqual(
HTML
);
} );
it( 'should extract recreate HTML 5', () => {
const HTML =
'<em>If you want to learn more about how to build additional blocks, or if you are interested in helping with the project, head over to the <a href="https://github.com/WordPress/gutenberg">GitHub repository</a>.</em>';
const element = createNode( `<p>${ HTML }</p>` );
expect( toHTMLString( { value: create( { element } ) } ) ).toEqual(
HTML
);
} );
it( 'should serialize neighbouring formats of same type', () => {
const HTML = '<a href="a">a</a><a href="b">a</a>';
const element = createNode( `<p>${ HTML }</p>` );
expect( toHTMLString( { value: create( { element } ) } ) ).toEqual(
HTML
);
} );
it( 'should serialize neighbouring same formats', () => {
const HTML = '<a href="a">a</a><a href="a">a</a>';
const element = createNode( `<p>${ HTML }</p>` );
expect( toHTMLString( { value: create( { element } ) } ) ).toEqual(
HTML
);
} );
} );

View File

@@ -0,0 +1,89 @@
/**
* External dependencies
*/
import deepFreeze from 'deep-freeze';
/**
* Internal dependencies
*/
import { toggleFormat } from '../toggle-format';
import { getSparseArrayLength } from './helpers';
describe( 'toggleFormat', () => {
const strong = { type: 'strong' };
const em = { type: 'em' };
it( 'should remove format if it is active', () => {
const record = {
formats: [
,
,
,
// In reality, formats at a different index are never the same
// value. Only formats that create the same tag are the same
// value.
[ { type: 'strong' } ],
[ em, strong ],
[ em, strong ],
[ em ],
,
,
,
,
,
,
],
text: 'one two three',
start: 3,
end: 6,
};
const expected = {
formats: [ , , , , [ em ], [ em ], [ em ], , , , , , , ],
activeFormats: [],
text: 'one two three',
start: 3,
end: 6,
};
const result = toggleFormat( deepFreeze( record ), strong );
expect( result ).toEqual( expected );
expect( result ).not.toBe( record );
expect( getSparseArrayLength( result.formats ) ).toBe( 3 );
} );
it( "should apply format if it doesn't exist at start of selection", () => {
const record = {
formats: [ , , , , [ em, strong ], [ em ], [ em ], , , , , , , ],
text: 'one two three',
start: 3,
end: 6,
};
const expected = {
formats: [
,
,
,
[ strong ],
[ strong, em ],
[ strong, em ],
[ em ],
,
,
,
,
,
,
],
activeFormats: [ strong ],
text: 'one two three',
start: 3,
end: 6,
};
const result = toggleFormat( deepFreeze( record ), strong );
expect( result ).toEqual( expected );
expect( result ).not.toBe( record );
expect( getSparseArrayLength( result.formats ) ).toBe( 4 );
} );
} );

View File

@@ -0,0 +1,52 @@
/**
* Internal dependencies
*/
import { unregisterFormatType } from '../unregister-format-type';
import { registerFormatType } from '../register-format-type';
import { getFormatTypes } from '../get-format-types';
import { getFormatType } from '../get-format-type';
const noop = () => {};
describe( 'unregisterFormatType', () => {
const defaultFormatSettings = {
edit: noop,
title: 'format title',
tagName: 'test',
className: null,
};
beforeAll( () => {
// Initialize the rich-text store.
require( '../store' );
} );
afterEach( () => {
getFormatTypes().forEach( ( format ) => {
unregisterFormatType( format.name );
} );
} );
it( 'should fail if the format is not registered', () => {
const oldFormat = unregisterFormatType( 'core/test-format' );
expect( console ).toHaveErroredWith(
'Format core/test-format is not registered.'
);
expect( oldFormat ).toBeUndefined();
} );
it( 'should unregister existing formats', () => {
registerFormatType( 'core/test-format', defaultFormatSettings );
expect( getFormatType( 'core/test-format' ) ).toEqual( {
name: 'core/test-format',
...defaultFormatSettings,
} );
const oldFormat = unregisterFormatType( 'core/test-format' );
expect( console ).not.toHaveErrored();
expect( oldFormat ).toEqual( {
name: 'core/test-format',
...defaultFormatSettings,
} );
expect( getFormatTypes() ).toEqual( [] );
} );
} );

View File

@@ -0,0 +1,55 @@
/**
* Internal dependencies
*/
import { updateFormats } from '../update-formats';
import { getSparseArrayLength } from './helpers';
describe( 'updateFormats', () => {
const em = { type: 'em' };
it( 'should update formats with empty array', () => {
const value = {
formats: [ [ em ] ],
text: '1',
};
const expected = {
...value,
activeFormats: [],
formats: [ , ],
};
const result = updateFormats( {
value,
start: 0,
end: 1,
formats: [],
} );
expect( result ).toEqual( expected );
expect( result ).toBe( value );
expect( getSparseArrayLength( result.formats ) ).toBe( 0 );
} );
it( 'should update formats and update references', () => {
const value = {
formats: [ [ em ], , ],
text: '123',
};
const expected = {
...value,
activeFormats: [ em ],
formats: [ [ em ], [ em ] ],
};
const result = updateFormats( {
value,
start: 1,
end: 2,
formats: [ { ...em } ],
} );
expect( result ).toEqual( expected );
expect( result ).toBe( value );
expect( result.formats[ 1 ][ 0 ] ).toBe( em );
expect( getSparseArrayLength( result.formats ) ).toBe( 2 );
} );
} );

300
node_modules/@wordpress/rich-text/src/to-dom.js generated vendored Normal file
View File

@@ -0,0 +1,300 @@
/**
* Internal dependencies
*/
import { toTree } from './to-tree';
import { createElement } from './create-element';
import { isRangeEqual } from './is-range-equal';
/** @typedef {import('./types').RichTextValue} RichTextValue */
/**
* Creates a path as an array of indices from the given root node to the given
* node.
*
* @param {Node} node Node to find the path of.
* @param {HTMLElement} rootNode Root node to find the path from.
* @param {Array} path Initial path to build on.
*
* @return {Array} The path from the root node to the node.
*/
function createPathToNode( node, rootNode, path ) {
const parentNode = node.parentNode;
let i = 0;
while ( ( node = node.previousSibling ) ) {
i++;
}
path = [ i, ...path ];
if ( parentNode !== rootNode ) {
path = createPathToNode( parentNode, rootNode, path );
}
return path;
}
/**
* Gets a node given a path (array of indices) from the given node.
*
* @param {HTMLElement} node Root node to find the wanted node in.
* @param {Array} path Path (indices) to the wanted node.
*
* @return {Object} Object with the found node and the remaining offset (if any).
*/
function getNodeByPath( node, path ) {
path = [ ...path ];
while ( node && path.length > 1 ) {
node = node.childNodes[ path.shift() ];
}
return {
node,
offset: path[ 0 ],
};
}
function append( element, child ) {
if ( child.html !== undefined ) {
return ( element.innerHTML += child.html );
}
if ( typeof child === 'string' ) {
child = element.ownerDocument.createTextNode( child );
}
const { type, attributes } = child;
if ( type ) {
child = element.ownerDocument.createElement( type );
for ( const key in attributes ) {
child.setAttribute( key, attributes[ key ] );
}
}
return element.appendChild( child );
}
function appendText( node, text ) {
node.appendData( text );
}
function getLastChild( { lastChild } ) {
return lastChild;
}
function getParent( { parentNode } ) {
return parentNode;
}
function isText( node ) {
return node.nodeType === node.TEXT_NODE;
}
function getText( { nodeValue } ) {
return nodeValue;
}
function remove( node ) {
return node.parentNode.removeChild( node );
}
export function toDom( {
value,
prepareEditableTree,
isEditableTree = true,
placeholder,
doc = document,
} ) {
let startPath = [];
let endPath = [];
if ( prepareEditableTree ) {
value = {
...value,
formats: prepareEditableTree( value ),
};
}
/**
* Returns a new instance of a DOM tree upon which RichText operations can be
* applied.
*
* Note: The current implementation will return a shared reference, reset on
* each call to `createEmpty`. Therefore, you should not hold a reference to
* the value to operate upon asynchronously, as it may have unexpected results.
*
* @return {Object} RichText tree.
*/
const createEmpty = () => createElement( doc, '' );
const tree = toTree( {
value,
createEmpty,
append,
getLastChild,
getParent,
isText,
getText,
remove,
appendText,
onStartIndex( body, pointer ) {
startPath = createPathToNode( pointer, body, [
pointer.nodeValue.length,
] );
},
onEndIndex( body, pointer ) {
endPath = createPathToNode( pointer, body, [
pointer.nodeValue.length,
] );
},
isEditableTree,
placeholder,
} );
return {
body: tree,
selection: { startPath, endPath },
};
}
/**
* Create an `Element` tree from a Rich Text value and applies the difference to
* the `Element` tree contained by `current`.
*
* @param {Object} $1 Named arguments.
* @param {RichTextValue} $1.value Value to apply.
* @param {HTMLElement} $1.current The live root node to apply the element tree to.
* @param {Function} [$1.prepareEditableTree] Function to filter editorable formats.
* @param {boolean} [$1.__unstableDomOnly] Only apply elements, no selection.
* @param {string} [$1.placeholder] Placeholder text.
*/
export function apply( {
value,
current,
prepareEditableTree,
__unstableDomOnly,
placeholder,
} ) {
// Construct a new element tree in memory.
const { body, selection } = toDom( {
value,
prepareEditableTree,
placeholder,
doc: current.ownerDocument,
} );
applyValue( body, current );
if ( value.start !== undefined && ! __unstableDomOnly ) {
applySelection( selection, current );
}
}
export function applyValue( future, current ) {
let i = 0;
let futureChild;
while ( ( futureChild = future.firstChild ) ) {
const currentChild = current.childNodes[ i ];
if ( ! currentChild ) {
current.appendChild( futureChild );
} else if ( ! currentChild.isEqualNode( futureChild ) ) {
if (
currentChild.nodeName !== futureChild.nodeName ||
( currentChild.nodeType === currentChild.TEXT_NODE &&
currentChild.data !== futureChild.data )
) {
current.replaceChild( futureChild, currentChild );
} else {
const currentAttributes = currentChild.attributes;
const futureAttributes = futureChild.attributes;
if ( currentAttributes ) {
let ii = currentAttributes.length;
// Reverse loop because `removeAttribute` on `currentChild`
// changes `currentAttributes`.
while ( ii-- ) {
const { name } = currentAttributes[ ii ];
if ( ! futureChild.getAttribute( name ) ) {
currentChild.removeAttribute( name );
}
}
}
if ( futureAttributes ) {
for ( let ii = 0; ii < futureAttributes.length; ii++ ) {
const { name, value } = futureAttributes[ ii ];
if ( currentChild.getAttribute( name ) !== value ) {
currentChild.setAttribute( name, value );
}
}
}
applyValue( futureChild, currentChild );
future.removeChild( futureChild );
}
} else {
future.removeChild( futureChild );
}
i++;
}
while ( current.childNodes[ i ] ) {
current.removeChild( current.childNodes[ i ] );
}
}
export function applySelection( { startPath, endPath }, current ) {
const { node: startContainer, offset: startOffset } = getNodeByPath(
current,
startPath
);
const { node: endContainer, offset: endOffset } = getNodeByPath(
current,
endPath
);
const { ownerDocument } = current;
const { defaultView } = ownerDocument;
const selection = defaultView.getSelection();
const range = ownerDocument.createRange();
range.setStart( startContainer, startOffset );
range.setEnd( endContainer, endOffset );
const { activeElement } = ownerDocument;
if ( selection.rangeCount > 0 ) {
// If the to be added range and the live range are the same, there's no
// need to remove the live range and add the equivalent range.
if ( isRangeEqual( range, selection.getRangeAt( 0 ) ) ) {
return;
}
selection.removeAllRanges();
}
selection.addRange( range );
// This function is not intended to cause a shift in focus. Since the above
// selection manipulations may shift focus, ensure that focus is restored to
// its previous state.
if ( activeElement !== ownerDocument.activeElement ) {
// The `instanceof` checks protect against edge cases where the focused
// element is not of the interface HTMLElement (does not have a `focus`
// or `blur` property).
//
// See: https://github.com/Microsoft/TypeScript/issues/5901#issuecomment-431649653
if ( activeElement instanceof defaultView.HTMLElement ) {
activeElement.focus();
}
}
}

124
node_modules/@wordpress/rich-text/src/to-html-string.js generated vendored Normal file
View File

@@ -0,0 +1,124 @@
/**
* WordPress dependencies
*/
import {
escapeEditableHTML,
escapeAttribute,
isValidAttributeName,
} from '@wordpress/escape-html';
/**
* Internal dependencies
*/
import { toTree } from './to-tree';
/** @typedef {import('./types').RichTextValue} RichTextValue */
/**
* Create an HTML string from a Rich Text value.
*
* @param {Object} $1 Named argements.
* @param {RichTextValue} $1.value Rich text value.
* @param {boolean} [$1.preserveWhiteSpace] Preserves newlines if true.
*
* @return {string} HTML string.
*/
export function toHTMLString( { value, preserveWhiteSpace } ) {
const tree = toTree( {
value,
preserveWhiteSpace,
createEmpty,
append,
getLastChild,
getParent,
isText,
getText,
remove,
appendText,
} );
return createChildrenHTML( tree.children );
}
function createEmpty() {
return {};
}
function getLastChild( { children } ) {
return children && children[ children.length - 1 ];
}
function append( parent, object ) {
if ( typeof object === 'string' ) {
object = { text: object };
}
object.parent = parent;
parent.children = parent.children || [];
parent.children.push( object );
return object;
}
function appendText( object, text ) {
object.text += text;
}
function getParent( { parent } ) {
return parent;
}
function isText( { text } ) {
return typeof text === 'string';
}
function getText( { text } ) {
return text;
}
function remove( object ) {
const index = object.parent.children.indexOf( object );
if ( index !== -1 ) {
object.parent.children.splice( index, 1 );
}
return object;
}
function createElementHTML( { type, attributes, object, children } ) {
let attributeString = '';
for ( const key in attributes ) {
if ( ! isValidAttributeName( key ) ) {
continue;
}
attributeString += ` ${ key }="${ escapeAttribute(
attributes[ key ]
) }"`;
}
if ( object ) {
return `<${ type }${ attributeString }>`;
}
return `<${ type }${ attributeString }>${ createChildrenHTML(
children
) }</${ type }>`;
}
function createChildrenHTML( children = [] ) {
return children
.map( ( child ) => {
if ( child.html !== undefined ) {
return child.html;
}
return child.text === undefined
? createElementHTML( child )
: escapeEditableHTML( child.text );
} )
.join( '' );
}

320
node_modules/@wordpress/rich-text/src/to-tree.js generated vendored Normal file
View File

@@ -0,0 +1,320 @@
/**
* Internal dependencies
*/
import { getActiveFormats } from './get-active-formats';
import { getFormatType } from './get-format-type';
import { OBJECT_REPLACEMENT_CHARACTER, ZWNBSP } from './special-characters';
function restoreOnAttributes( attributes, isEditableTree ) {
if ( isEditableTree ) {
return attributes;
}
const newAttributes = {};
for ( const key in attributes ) {
let newKey = key;
if ( key.startsWith( 'data-disable-rich-text-' ) ) {
newKey = key.slice( 'data-disable-rich-text-'.length );
}
newAttributes[ newKey ] = attributes[ key ];
}
return newAttributes;
}
/**
* Converts a format object to information that can be used to create an element
* from (type, attributes and object).
*
* @param {Object} $1 Named parameters.
* @param {string} $1.type The format type.
* @param {string} $1.tagName The tag name.
* @param {Object} $1.attributes The format attributes.
* @param {Object} $1.unregisteredAttributes The unregistered format
* attributes.
* @param {boolean} $1.object Whether or not it is an object
* format.
* @param {boolean} $1.boundaryClass Whether or not to apply a boundary
* class.
* @param {boolean} $1.isEditableTree
*
* @return {Object} Information to be used for element creation.
*/
function fromFormat( {
type,
tagName,
attributes,
unregisteredAttributes,
object,
boundaryClass,
isEditableTree,
} ) {
const formatType = getFormatType( type );
let elementAttributes = {};
if ( boundaryClass && isEditableTree ) {
elementAttributes[ 'data-rich-text-format-boundary' ] = 'true';
}
if ( ! formatType ) {
if ( attributes ) {
elementAttributes = { ...attributes, ...elementAttributes };
}
return {
type,
attributes: restoreOnAttributes(
elementAttributes,
isEditableTree
),
object,
};
}
elementAttributes = { ...unregisteredAttributes, ...elementAttributes };
for ( const name in attributes ) {
const key = formatType.attributes
? formatType.attributes[ name ]
: false;
if ( key ) {
elementAttributes[ key ] = attributes[ name ];
} else {
elementAttributes[ name ] = attributes[ name ];
}
}
if ( formatType.className ) {
if ( elementAttributes.class ) {
elementAttributes.class = `${ formatType.className } ${ elementAttributes.class }`;
} else {
elementAttributes.class = formatType.className;
}
}
// When a format is declared as non editable, make it non editable in the
// editor.
if ( isEditableTree && formatType.contentEditable === false ) {
elementAttributes.contenteditable = 'false';
}
return {
type: tagName || formatType.tagName,
object: formatType.object,
attributes: restoreOnAttributes( elementAttributes, isEditableTree ),
};
}
/**
* Checks if both arrays of formats up until a certain index are equal.
*
* @param {Array} a Array of formats to compare.
* @param {Array} b Array of formats to compare.
* @param {number} index Index to check until.
*/
function isEqualUntil( a, b, index ) {
do {
if ( a[ index ] !== b[ index ] ) {
return false;
}
} while ( index-- );
return true;
}
export function toTree( {
value,
preserveWhiteSpace,
createEmpty,
append,
getLastChild,
getParent,
isText,
getText,
remove,
appendText,
onStartIndex,
onEndIndex,
isEditableTree,
placeholder,
} ) {
const { formats, replacements, text, start, end } = value;
const formatsLength = formats.length + 1;
const tree = createEmpty();
const activeFormats = getActiveFormats( value );
const deepestActiveFormat = activeFormats[ activeFormats.length - 1 ];
let lastCharacterFormats;
let lastCharacter;
append( tree, '' );
for ( let i = 0; i < formatsLength; i++ ) {
const character = text.charAt( i );
const shouldInsertPadding =
isEditableTree &&
// Pad the line if the line is empty.
( ! lastCharacter ||
// Pad the line if the previous character is a line break, otherwise
// the line break won't be visible.
lastCharacter === '\n' );
const characterFormats = formats[ i ];
let pointer = getLastChild( tree );
if ( characterFormats ) {
characterFormats.forEach( ( format, formatIndex ) => {
if (
pointer &&
lastCharacterFormats &&
// Reuse the last element if all formats remain the same.
isEqualUntil(
characterFormats,
lastCharacterFormats,
formatIndex
)
) {
pointer = getLastChild( pointer );
return;
}
const { type, tagName, attributes, unregisteredAttributes } =
format;
const boundaryClass =
isEditableTree && format === deepestActiveFormat;
const parent = getParent( pointer );
const newNode = append(
parent,
fromFormat( {
type,
tagName,
attributes,
unregisteredAttributes,
boundaryClass,
isEditableTree,
} )
);
if ( isText( pointer ) && getText( pointer ).length === 0 ) {
remove( pointer );
}
pointer = append( newNode, '' );
} );
}
// If there is selection at 0, handle it before characters are inserted.
if ( i === 0 ) {
if ( onStartIndex && start === 0 ) {
onStartIndex( tree, pointer );
}
if ( onEndIndex && end === 0 ) {
onEndIndex( tree, pointer );
}
}
if ( character === OBJECT_REPLACEMENT_CHARACTER ) {
const replacement = replacements[ i ];
if ( ! replacement ) {
continue;
}
const { type, attributes, innerHTML } = replacement;
const formatType = getFormatType( type );
if ( ! isEditableTree && type === 'script' ) {
pointer = append(
getParent( pointer ),
fromFormat( {
type: 'script',
isEditableTree,
} )
);
append( pointer, {
html: decodeURIComponent(
attributes[ 'data-rich-text-script' ]
),
} );
} else if ( formatType?.contentEditable === false ) {
// For non editable formats, render the stored inner HTML.
pointer = append(
getParent( pointer ),
fromFormat( {
...replacement,
isEditableTree,
boundaryClass: start === i && end === i + 1,
} )
);
if ( innerHTML ) {
append( pointer, {
html: innerHTML,
} );
}
} else {
pointer = append(
getParent( pointer ),
fromFormat( {
...replacement,
object: true,
isEditableTree,
} )
);
}
// Ensure pointer is text node.
pointer = append( getParent( pointer ), '' );
} else if ( ! preserveWhiteSpace && character === '\n' ) {
pointer = append( getParent( pointer ), {
type: 'br',
attributes: isEditableTree
? {
'data-rich-text-line-break': 'true',
}
: undefined,
object: true,
} );
// Ensure pointer is text node.
pointer = append( getParent( pointer ), '' );
} else if ( ! isText( pointer ) ) {
pointer = append( getParent( pointer ), character );
} else {
appendText( pointer, character );
}
if ( onStartIndex && start === i + 1 ) {
onStartIndex( tree, pointer );
}
if ( onEndIndex && end === i + 1 ) {
onEndIndex( tree, pointer );
}
if ( shouldInsertPadding && i === text.length ) {
append( getParent( pointer ), ZWNBSP );
if ( placeholder && text.length === 0 ) {
append( getParent( pointer ), {
type: 'span',
attributes: {
'data-rich-text-placeholder': placeholder,
// Necessary to prevent the placeholder from catching
// selection and being editable.
style: 'pointer-events:none;user-select:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;',
},
} );
}
}
lastCharacterFormats = characterFormats;
lastCharacter = character;
}
return tree;
}

42
node_modules/@wordpress/rich-text/src/toggle-format.js generated vendored Normal file
View File

@@ -0,0 +1,42 @@
/**
* WordPress dependencies
*/
import { speak } from '@wordpress/a11y';
import { __, sprintf } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import { getActiveFormat } from './get-active-format';
import { removeFormat } from './remove-format';
import { applyFormat } from './apply-format';
/** @typedef {import('./types').RichTextValue} RichTextValue */
/** @typedef {import('./types').RichTextFormat} RichTextFormat */
/**
* Toggles a format object to a Rich Text value at the current selection.
*
* @param {RichTextValue} value Value to modify.
* @param {RichTextFormat} format Format to apply or remove.
*
* @return {RichTextValue} A new value with the format applied or removed.
*/
export function toggleFormat( value, format ) {
if ( getActiveFormat( value, format.type ) ) {
// For screen readers, will announce if formatting control is disabled.
if ( format.title ) {
// translators: %s: title of the formatting control
speak( sprintf( __( '%s removed.' ), format.title ), 'assertive' );
}
return removeFormat( value, format.type );
}
// For screen readers, will announce if formatting control is enabled.
if ( format.title ) {
// translators: %s: title of the formatting control
speak( sprintf( __( '%s applied.' ), format.title ), 'assertive' );
}
return applyFormat( value, format );
}

31
node_modules/@wordpress/rich-text/src/types.ts generated vendored Normal file
View File

@@ -0,0 +1,31 @@
/**
* Stores the type of a rich text format, such as core/bold.
*/
export type RichTextFormat = {
type:
| 'core/bold'
| 'core/italic'
| 'core/link '
| 'core/strikethrough'
| 'core/image'
| string;
};
/**
* A list of rich text format types.
*/
export type RichTextFormatList = Array< RichTextFormat >;
/**
* An object which represents a formatted string. The text property contains the
* text to be formatted, and the formats property contains an array which indicates
* the formats that are applied to each character in the text. See the main
* `@wordpress/rich-text` documentation for more detail.
*/
export type RichTextValue = {
text: string;
formats: Array< RichTextFormatList >;
replacements: Array< RichTextFormat >;
start: number;
end: number;
};

View File

@@ -0,0 +1,33 @@
/**
* WordPress dependencies
*/
import { select, dispatch } from '@wordpress/data';
/**
* Internal dependencies
*/
import { store as richTextStore } from './store';
/** @typedef {import('./register-format-type').WPFormat} WPFormat */
/**
* Unregisters a format.
*
* @param {string} name Format name.
*
* @return {WPFormat|undefined} The previous format value, if it has
* been successfully unregistered;
* otherwise `undefined`.
*/
export function unregisterFormatType( name ) {
const oldFormat = select( richTextStore ).getFormatType( name );
if ( ! oldFormat ) {
window.console.error( `Format ${ name } is not registered.` );
return;
}
dispatch( richTextStore ).removeFormatTypes( name );
return oldFormat;
}

View File

@@ -0,0 +1,53 @@
/**
* Internal dependencies
*/
import { isFormatEqual } from './is-format-equal';
/** @typedef {import('./types').RichTextValue} RichTextValue */
/**
* Efficiently updates all the formats from `start` (including) until `end`
* (excluding) with the active formats. Mutates `value`.
*
* @param {Object} $1 Named paramentes.
* @param {RichTextValue} $1.value Value te update.
* @param {number} $1.start Index to update from.
* @param {number} $1.end Index to update until.
* @param {Array} $1.formats Replacement formats.
*
* @return {RichTextValue} Mutated value.
*/
export function updateFormats( { value, start, end, formats } ) {
// Start and end may be switched in case of delete.
const min = Math.min( start, end );
const max = Math.max( start, end );
const formatsBefore = value.formats[ min - 1 ] || [];
const formatsAfter = value.formats[ max ] || [];
// First, fix the references. If any format right before or after are
// equal, the replacement format should use the same reference.
value.activeFormats = formats.map( ( format, index ) => {
if ( formatsBefore[ index ] ) {
if ( isFormatEqual( format, formatsBefore[ index ] ) ) {
return formatsBefore[ index ];
}
} else if ( formatsAfter[ index ] ) {
if ( isFormatEqual( format, formatsAfter[ index ] ) ) {
return formatsAfter[ index ];
}
}
return format;
} );
while ( --end >= start ) {
if ( value.activeFormats.length > 0 ) {
value.formats[ end ] = value.activeFormats;
} else {
delete value.formats[ end ];
}
}
return value;
}