Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rich text: combine all ref effects #60936

Merged
merged 4 commits into from
May 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/**
* WordPress dependencies
*/
import { insert, isCollapsed } from '@wordpress/rich-text';
import { applyFilters } from '@wordpress/hooks';

/**
* Internal dependencies
*/
import { store as blockEditorStore } from '../../../store';

/**
* When typing over a selection, the selection will we wrapped by a matching
* character pair. The second character is optional, it defaults to the first
* character.
*
* @type {string[]} Array of character pairs.
*/
const wrapSelectionSettings = [ '`', '"', "'", '“”', '‘’' ];

export default ( props ) => ( element ) => {
function onInput( event ) {
const { inputType, data } = event;
const { value, onChange, registry } = props.current;

// Only run the rules when inserting text.
if ( inputType !== 'insertText' ) {
return;
}

if ( isCollapsed( value ) ) {
return;
}

const pair = applyFilters(
'blockEditor.wrapSelectionSettings',
wrapSelectionSettings
).find(
( [ startChar, endChar ] ) => startChar === data || endChar === data
);

if ( ! pair ) {
return;
}

const [ startChar, endChar = startChar ] = pair;
const start = value.start;
const end = value.end + startChar.length;

let newValue = insert( value, startChar, start, start );
newValue = insert( newValue, endChar, end, end );

const {
__unstableMarkLastChangeAsPersistent,
__unstableMarkAutomaticChange,
} = registry.dispatch( blockEditorStore );

__unstableMarkLastChangeAsPersistent();
onChange( newValue );
__unstableMarkAutomaticChange();

const init = {};

for ( const key in event ) {
init[ key ] = event[ key ];
}

init.data = endChar;

const { ownerDocument } = element;
const { defaultView } = ownerDocument;
const newEvent = new defaultView.InputEvent( 'input', init );

// Dispatch an `input` event with the new data. This will trigger the
// input rules.
// Postpone the `input` to the next event loop tick so that the dispatch
// doesn't happen synchronously in the middle of `beforeinput` dispatch.
// This is closer to how native `input` event would be timed, and also
// makes sure that the `input` event is dispatched only after the `onChange`
// call few lines above has fully updated the data store state and rerendered
// all affected components.
window.queueMicrotask( () => {
event.target.dispatchEvent( newEvent );
} );
event.preventDefault();
}

element.addEventListener( 'beforeinput', onInput );
return () => {
element.removeEventListener( 'beforeinput', onInput );
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* WordPress dependencies
*/
import { DELETE, BACKSPACE } from '@wordpress/keycodes';
import { isCollapsed, isEmpty } from '@wordpress/rich-text';

export default ( props ) => ( element ) => {
function onKeyDown( event ) {
const { keyCode } = event;

if ( event.defaultPrevented ) {
return;
}

const { value, onMerge, onRemove } = props.current;

if ( keyCode === DELETE || keyCode === BACKSPACE ) {
const { start, end, text } = value;
const isReverse = keyCode === BACKSPACE;
const hasActiveFormats =
value.activeFormats && !! value.activeFormats.length;

// Only process delete if the key press occurs at an uncollapsed edge.
if (
! isCollapsed( value ) ||
hasActiveFormats ||
( isReverse && start !== 0 ) ||
( ! isReverse && end !== text.length )
) {
return;
}

if ( onMerge ) {
onMerge( ! isReverse );
}

// Only handle remove on Backspace. This serves dual-purpose of being
// an intentional user interaction distinguishing between Backspace and
// Delete to remove the empty field, but also to avoid merge & remove
// causing destruction of two fields (merge, then removed merged).
else if ( onRemove && isEmpty( value ) && isReverse ) {
onRemove( ! isReverse );
}

event.preventDefault();
}
}

element.addEventListener( 'keydown', onKeyDown );
return () => {
element.removeEventListener( 'keydown', onKeyDown );
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* WordPress dependencies
*/
import { ENTER } from '@wordpress/keycodes';
import { insert, remove } from '@wordpress/rich-text';
import { getBlockTransforms, findTransform } from '@wordpress/blocks';

/**
* Internal dependencies
*/
import { store as blockEditorStore } from '../../../store';
import { splitValue } from '../split-value';

export default ( props ) => ( element ) => {
function onKeyDown( event ) {
if ( event.target.contentEditable !== 'true' ) {
return;
}

if ( event.defaultPrevented ) {
return;
}

if ( event.keyCode !== ENTER ) {
return;
}

const {
removeEditorOnlyFormats,
value,
onReplace,
onSplit,
onChange,
disableLineBreaks,
onSplitAtEnd,
onSplitAtDoubleLineEnd,
registry,
} = props.current;

event.preventDefault();

const _value = { ...value };
_value.formats = removeEditorOnlyFormats( value );
const canSplit = onReplace && onSplit;

if ( onReplace ) {
const transforms = getBlockTransforms( 'from' ).filter(
( { type } ) => type === 'enter'
);
const transformation = findTransform( transforms, ( item ) => {
return item.regExp.test( _value.text );
} );

if ( transformation ) {
onReplace( [
transformation.transform( {
content: _value.text,
} ),
] );
registry
.dispatch( blockEditorStore )
.__unstableMarkAutomaticChange();
return;
}
}

const { text, start, end } = _value;

if ( event.shiftKey ) {
if ( ! disableLineBreaks ) {
onChange( insert( _value, '\n' ) );
}
} else if ( canSplit ) {
splitValue( {
value: _value,
onReplace,
onSplit,
} );
} else if ( onSplitAtEnd && start === end && end === text.length ) {
onSplitAtEnd();
} else if (
// For some blocks it's desirable to split at the end of the
// block when there are two line breaks at the end of the
// block, so triple Enter exits the block.
onSplitAtDoubleLineEnd &&
start === end &&
end === text.length &&
text.slice( -2 ) === '\n\n'
) {
registry.batch( () => {
_value.start = _value.end - 2;
onChange( remove( _value ) );
onSplitAtDoubleLineEnd();
} );
} else if ( ! disableLineBreaks ) {
onChange( insert( _value, '\n' ) );
}
}

element.addEventListener( 'keydown', onKeyDown );
return () => {
element.removeEventListener( 'keydown', onKeyDown );
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* Internal dependencies
*/
import { store as blockEditorStore } from '../../../store';

export default ( props ) => ( element ) => {
function onFocus() {
const { registry } = props.current;
if ( ! registry.select( blockEditorStore ).isMultiSelecting() ) {
return;
}

// This is a little hack to work around focus issues with nested
// editable elements in Firefox. For some reason the editable child
// element sometimes regains focus, while it should not be focusable
// and focus should remain on the editable parent element.
// To do: try to find the cause of the shifting focus.
const parentEditable = element.parentElement.closest(
'[contenteditable="true"]'
);

if ( parentEditable ) {
parentEditable.focus();
}
}

element.addEventListener( 'focus', onFocus );
return () => {
element.removeEventListener( 'focus', onFocus );
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* WordPress dependencies
*/
import { useMemo, useRef } from '@wordpress/element';
import { useRefEffect } from '@wordpress/compose';

/**
* Internal dependencies
*/
import beforeInputRules from './before-input-rules';
import inputRules from './input-rules';
import insertReplacementText from './insert-replacement-text';
import removeBrowserShortcuts from './remove-browser-shortcuts';
import shortcuts from './shortcuts';
import inputEvents from './input-events';
import undoAutomaticChange from './undo-automatic-change';
import pasteHandler from './paste-handler';
import _delete from './delete';
import enter from './enter';
import firefoxCompat from './firefox-compat';

const allEventListeners = [
beforeInputRules,
inputRules,
insertReplacementText,
removeBrowserShortcuts,
shortcuts,
inputEvents,
undoAutomaticChange,
pasteHandler,
_delete,
enter,
firefoxCompat,
];

export function useEventListeners( props ) {
const propsRef = useRef( props );
propsRef.current = props;
const refEffects = useMemo(
() => allEventListeners.map( ( refEffect ) => refEffect( propsRef ) ),
[ propsRef ]
);

return useRefEffect(
( element ) => {
if ( ! props.isSelected ) {
return;
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additionally this PR avoids attaching all these listeners for non selected blocks.

const cleanups = refEffects.map( ( effect ) => effect( element ) );
return () => {
cleanups.forEach( ( cleanup ) => cleanup() );
};
},
[ refEffects, props.isSelected ]
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export default ( props ) => ( element ) => {
const { inputEvents } = props.current;
function onInput( event ) {
for ( const keyboardShortcut of inputEvents.current ) {
keyboardShortcut( event );
}
}

element.addEventListener( 'input', onInput );
return () => {
element.removeEventListener( 'input', onInput );
};
};
Loading
Loading