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

Allow multi-select on iOS Safari/touch devices #63671

Merged
merged 44 commits into from
Aug 22, 2024
Merged
Show file tree
Hide file tree
Changes from 42 commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
2c8d911
Allow multi-select on iOS Safari
ellatrix Jul 17, 2024
3a8f2c5
Without focus change
ellatrix Jul 17, 2024
6f33c01
use util
ellatrix Jul 23, 2024
bc2921d
Forward input events
ellatrix Jul 24, 2024
e7ff262
Fix formatting, cmd+a
ellatrix Jul 24, 2024
3d17e73
Remove selection change handler on unmount
ellatrix Jul 24, 2024
82bfa7a
Rich text should only check selection within
ellatrix Jul 24, 2024
7b8c1b1
Fix select all
ellatrix Jul 24, 2024
1f0acba
Fix post title selection
ellatrix Jul 24, 2024
d86647d
Fix copy handler
ellatrix Jul 24, 2024
9258ed3
Fix links
ellatrix Jul 24, 2024
fa6c2a5
Set CE on selection removal
ellatrix Jul 25, 2024
c430a10
Must be boolean
ellatrix Jul 25, 2024
781d969
Immediately set CE=false when clearing selection
ellatrix Jul 25, 2024
9e19646
Avoid reselecting block if already selected
ellatrix Jul 25, 2024
acadb15
Handle past for rich text selection correctly
ellatrix Jul 25, 2024
3b1a381
Fix autocomplete, fix home/end
ellatrix Jul 25, 2024
a0ee724
Restore original handler, keep both
ellatrix Jul 25, 2024
af90de1
Restore focus on focusable element
ellatrix Jul 25, 2024
2f45b13
Leave null guard
ellatrix Jul 25, 2024
5b96b59
Fix copy
ellatrix Jul 25, 2024
a6dfaa0
Ensure rich text only handles selection within active element
ellatrix Aug 5, 2024
94cd7b5
Fix select all
ellatrix Aug 5, 2024
62a779f
Fix select all in title
ellatrix Aug 6, 2024
c372ba7
Fix pattern override test
ellatrix Aug 6, 2024
c4ec944
Fix delete test
ellatrix Aug 6, 2024
8ca2b67
Clean up selection after arrow nav to non editable block
ellatrix Aug 6, 2024
5cb8aad
Fix webkit issues
ellatrix Aug 6, 2024
74217b2
Fixes
ellatrix Aug 6, 2024
ad43235
fix arrow nav from selection
ellatrix Aug 6, 2024
e887e04
Only get selection root if writing flow el is focussed
ellatrix Aug 7, 2024
62cc866
Fix select all
ellatrix Aug 7, 2024
0f921d0
Clear native selection when switching focus to different block
ellatrix Aug 7, 2024
dc46520
Fix input by turning off contenteditable and maintaining the previous…
ellatrix Aug 7, 2024
e1c423c
Prevent before input for multi-selection
ellatrix Aug 7, 2024
e42e62d
Move under Unreleased
ellatrix Aug 12, 2024
e1e8324
Format link
ellatrix Aug 12, 2024
b73cc53
Add some inline comments
ellatrix Aug 12, 2024
9c0dc0e
test
ellatrix Aug 13, 2024
55d09d3
Remove unneeded changelog entry
ellatrix Aug 13, 2024
4c2ec17
Turn off content editable before calling getClosestTabbable
ellatrix Aug 14, 2024
8579290
Attempt to avoid DOM selector, check failures again
ellatrix Aug 14, 2024
2ab2f51
Attempt to remove redirect flag
ellatrix Aug 22, 2024
e620fcd
Guard for infinite loop
ellatrix Aug 22, 2024
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
Expand Up @@ -68,6 +68,7 @@ export function useFocusFirstElement( { clientId, initialPosition } ) {
textInputs[ isReverse ? textInputs.length - 1 : 0 ] || ref.current;

if ( ! isInsideRootBlock( ref.current, target ) ) {
ownerDocument.defaultView.getSelection().removeAllRanges();
ref.current.focus();
return;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,15 @@ export default ( props ) => ( element ) => {
preserveWhiteSpace,
pastePlainText,
} = props.current;
const { ownerDocument } = element;
const { defaultView } = ownerDocument;
const { anchorNode, focusNode } = defaultView.getSelection();
const containsSelection =
element.contains( anchorNode ) && element.contains( focusNode );

// The event listener is attached to the window, so we need to check if
// the target is the element.
if ( event.target !== element ) {
if ( ! containsSelection ) {
return;
}

Expand Down
14 changes: 13 additions & 1 deletion packages/block-editor/src/components/rich-text/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,19 @@ export function RichTextWrapper(
const inputEvents = useRef( new Set() );

function onFocus() {
anchorRef.current?.focus();
let element = anchorRef.current;

if ( ! element ) {
return;
}

// Writing flow might be editable, so we should make sure focus goes to
// the root editable element.
while ( element.parentElement?.isContentEditable ) {
element = element.parentElement;
}

element.focus();
}

const registry = useRegistry();
Expand Down
2 changes: 2 additions & 0 deletions packages/block-editor/src/components/writing-flow/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import useSelectionObserver from './use-selection-observer';
import useClickSelection from './use-click-selection';
import useInput from './use-input';
import useClipboardHandler from './use-clipboard-handler';
import useEventRedirect from './use-event-redirect';
import { store as blockEditorStore } from '../../store';

export function useWritingFlow() {
Expand Down Expand Up @@ -65,6 +66,7 @@ export function useWritingFlow() {
},
[ hasMultiSelection ]
),
useEventRedirect(),
] ),
after,
];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { useRefEffect } from '@wordpress/compose';
*/
import { getBlockClientId, isInSameBlock } from '../../utils/dom';
import { store as blockEditorStore } from '../../store';
import { getSelectionRoot } from './utils';

/**
* Returns true if the element should consider edge navigation upon a keyboard
Expand Down Expand Up @@ -190,8 +191,7 @@ export default function useArrowNav() {
return;
}

const { keyCode, target, shiftKey, ctrlKey, altKey, metaKey } =
event;
const { keyCode, shiftKey, ctrlKey, altKey, metaKey } = event;
const isUp = keyCode === UP;
const isDown = keyCode === DOWN;
const isLeft = keyCode === LEFT;
Expand Down Expand Up @@ -233,6 +233,11 @@ export default function useArrowNav() {
return;
}

const target =
ownerDocument.activeElement === node
? getSelectionRoot( ownerDocument )
: event.target;

// Abort if our current target is not a candidate for navigation
// (e.g. preserve native input behaviors).
if ( ! isNavigationCandidate( target, keyCode, hasModifier ) ) {
Expand Down Expand Up @@ -274,6 +279,7 @@ export default function useArrowNav() {
( altKey ? isHorizontalEdge( target, isReverseDir ) : true ) &&
! keepCaretInsideBlock
) {
node.contentEditable = false;
const closestTabbable = getClosestTabbable(
target,
isReverse,
Expand All @@ -297,6 +303,7 @@ export default function useArrowNav() {
isHorizontalEdge( target, isReverseDir ) &&
! keepCaretInsideBlock
) {
node.contentEditable = false;
const closestTabbable = getClosestTabbable(
target,
isReverseDir,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* WordPress dependencies
*/
import { useRefEffect } from '@wordpress/compose';

/**
* Internal dependencies
*/
import { getSelectionRoot } from './utils';

/**
* Whenever content editable is enabled on writing flow, it will have focus, so
* we need to dispatch some events to the root of the selection to ensure
* compatibility with rich text. In the future, perhaps the rich text event
* handlers should be attached to the window instead.
*
* Alternatively, we could try to find a way to always maintain rich text focus.
*/
export default function useEventRedirect() {
return useRefEffect( ( node ) => {
function onInput( event ) {
if ( event.target !== node || event.__isRedirected ) {
Copy link
Contributor

Choose a reason for hiding this comment

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

When this event.__isRedirected is true?

Copy link
Member Author

Choose a reason for hiding this comment

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

See 20 lines below :)

return;
}

const { ownerDocument } = node;
const { defaultView } = ownerDocument;
const prototype = Object.getPrototypeOf( event );
const constructorName = prototype.constructor.name;
const Constructor = defaultView[ constructorName ];
const root = getSelectionRoot( ownerDocument );

if ( ! root ) {
return;
}

const init = {};

for ( const key in event ) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Why not just spread the object here?

init[ key ] = event[ key ];
}

init.bubbles = false;

const newEvent = new Constructor( event.type, init );
newEvent.__isRedirected = true;
const cancelled = ! root.dispatchEvent( newEvent );

if ( cancelled ) {
event.preventDefault();
}
}

const events = [
'beforeinput',
'input',
'compositionstart',
'compositionend',
'compositionupdate',
'keydown',
];

events.forEach( ( eventType ) => {
node.addEventListener( eventType, onInput );
} );

return () => {
events.forEach( ( eventType ) => {
node.removeEventListener( eventType, onInput );
} );
};
}, [] );
}
37 changes: 36 additions & 1 deletion packages/block-editor/src/components/writing-flow/use-input.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
* Internal dependencies
*/
import { store as blockEditorStore } from '../../store';
import { getSelectionRoot } from './utils';

/**
* Handles input for selections across blocks.
Expand Down Expand Up @@ -49,7 +50,24 @@ export default function useInput() {
// DOM. This will cause React errors (and the DOM should only be
// altered in a controlled fashion).
if ( node.contentEditable === 'true' ) {
event.preventDefault();
const selection = node.ownerDocument.defaultView.getSelection();
const range = selection.rangeCount
? selection.getRangeAt( 0 )
: null;
const root = getSelectionRoot( node.ownerDocument );

// If selection is contained within a nested editable, allow
// input. We need to ensure that selection is maintained.
if ( root ) {
node.contentEditable = false;
root.focus();
selection.removeAllRanges();
if ( range ) {
selection.addRange( range );
}
} else {
event.preventDefault();
}
}
}

Expand All @@ -59,6 +77,23 @@ export default function useInput() {
}

if ( ! hasMultiSelection() ) {
const { ownerDocument } = node;
if ( node === ownerDocument.activeElement ) {
if ( event.key === 'End' || event.key === 'Home' ) {
const selectionRoot = getSelectionRoot( ownerDocument );
const selection =
ownerDocument.defaultView.getSelection();
selection.selectAllChildren( selectionRoot );
const method =
event.key === 'End'
? 'collapseToEnd'
: 'collapseToStart';
selection[ method ]();
event.preventDefault();
return;
}
}

if ( event.keyCode === ENTER ) {
if ( event.shiftKey || __unstableIsFullySelected() ) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Not from here, but curious how Enter and shift are used.. Any chance you remember?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, this is Enter behaviour for partial selection. So select two paragraphs partially and press Enter, this handles split for them.

return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { useRefEffect } from '@wordpress/compose';
* Internal dependencies
*/
import { store as blockEditorStore } from '../../store';
import { getSelectionRoot } from './utils';

export default function useSelectAll() {
const { getBlockOrder, getSelectedBlockClientIds, getBlockRootClientId } =
Expand All @@ -23,12 +24,27 @@ export default function useSelectAll() {
return;
}

const selectionRoot = getSelectionRoot( node.ownerDocument );
const selectedClientIds = getSelectedBlockClientIds();

// Abort if there is selection, but it is not within a block.
if ( selectionRoot && ! selectedClientIds.length ) {
return;
}

if (
selectionRoot &&
selectedClientIds.length < 2 &&
! isEntirelySelected( event.target )
! isEntirelySelected( selectionRoot )
) {
if ( node === node.ownerDocument.activeElement ) {
event.preventDefault();
node.ownerDocument.defaultView
.getSelection()
.selectAllChildren( selectionRoot );
return;
}

return;
}

Expand All @@ -45,6 +61,7 @@ export default function useSelectAll() {
node.ownerDocument.defaultView
.getSelection()
.removeAllRanges();
node.contentEditable = 'false';
selectBlock( rootClientId );
}
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,12 @@ function getRichTextElement( node ) {
export default function useSelectionObserver() {
const { multiSelect, selectBlock, selectionChange } =
useDispatch( blockEditorStore );
const { getBlockParents, getBlockSelectionStart, isMultiSelecting } =
useSelect( blockEditorStore );
const {
getBlockParents,
getBlockSelectionStart,
isMultiSelecting,
getSelectedBlockClientId,
} = useSelect( blockEditorStore );
return useRefEffect(
( node ) => {
const { ownerDocument } = node;
Expand Down Expand Up @@ -182,10 +186,17 @@ export default function useSelectionObserver() {
return;
}

setContentEditableWrapper(
node,
!! ( startClientId && endClientId )
);

const isSingularSelection = startClientId === endClientId;
if ( isSingularSelection ) {
if ( ! isMultiSelecting() ) {
selectBlock( startClientId );
if ( getSelectedBlockClientId() !== startClientId ) {
selectBlock( startClientId );
}
} else {
multiSelect( startClientId, startClientId );
}
Expand Down
30 changes: 30 additions & 0 deletions packages/block-editor/src/components/writing-flow/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -116,3 +116,33 @@ function toPlainText( html ) {
// Merge any consecutive line breaks
return plainText.replace( /\n\n+/g, '\n\n' );
}

/**
* Gets the current content editable root element based on the selection.
* @param {Document} ownerDocument
* @return {Element|undefined} The content editable root element.
*/
export function getSelectionRoot( ownerDocument ) {
const { defaultView } = ownerDocument;
const { anchorNode, focusNode } = defaultView.getSelection();

if ( ! anchorNode || ! focusNode ) {
return;
}

const anchorElement = (
anchorNode.nodeType === anchorNode.ELEMENT_NODE
ellatrix marked this conversation as resolved.
Show resolved Hide resolved
? anchorNode
: anchorNode.parentElement
).closest( '[contenteditable]' );

if ( ! anchorElement ) {
return;
}

if ( ! anchorElement.contains( focusNode ) ) {
return;
}

return anchorElement;
}
12 changes: 7 additions & 5 deletions packages/dom/src/dom/place-caret-at-edge.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,14 @@ export default function placeCaretAtEdge( container, isReverse, x ) {
return;
}

const { ownerDocument } = container;
const { defaultView } = ownerDocument;
assertIsDefined( defaultView, 'defaultView' );
const selection = defaultView.getSelection();
assertIsDefined( selection, 'selection' );

if ( ! container.isContentEditable ) {
selection.removeAllRanges();
return;
}

Expand All @@ -79,11 +86,6 @@ export default function placeCaretAtEdge( container, isReverse, x ) {
return;
}

const { ownerDocument } = container;
const { defaultView } = ownerDocument;
assertIsDefined( defaultView, 'defaultView' );
const selection = defaultView.getSelection();
assertIsDefined( selection, 'selection' );
selection.removeAllRanges();
selection.addRange( range );
}
Loading
Loading