Skip to content
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
65 changes: 65 additions & 0 deletions packages/editor/src/components/post-text-editor/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import Textarea from 'react-autosize-textarea';
/**
* WordPress dependencies
*/
import { useLayoutEffect, useRef } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { store as coreStore } from '@wordpress/core-data';
import { useDispatch, useSelect } from '@wordpress/data';
Expand All @@ -16,6 +17,7 @@ import { VisuallyHidden } from '@wordpress/ui';
* Internal dependencies
*/
import { store as editorStore } from '../../store';
import { adjustPosition, getDiff } from './utils';

/**
* Displays the Post Text Editor along with content in Visual and Text mode.
Expand All @@ -24,6 +26,9 @@ import { store as editorStore } from '../../store';
*/
export default function PostTextEditor() {
const instanceId = useInstanceId( PostTextEditor );
const textareaRef = useRef();
const previousValueRef = useRef();
const selectionRef = useRef();
const { value, type, id } = useSelect( ( select ) => {
const { getCurrentPostType, getCurrentPostId, getEditedPostContent } =
select( editorStore );
Expand All @@ -35,6 +40,58 @@ export default function PostTextEditor() {
}, [] );
const { editEntityRecord } = useDispatch( coreStore );

useLayoutEffect( () => {
const textarea = textareaRef.current;
const previousValue = previousValueRef.current;
previousValueRef.current = value;

if (
! textarea ||
previousValue === undefined ||
previousValue === value ||
! selectionRef.current
) {
return;
}

const { selectionStart, selectionEnd, selectionDirection } =
selectionRef.current;
const changes = getDiff( previousValue, value );
const adjustedSelectionStart = adjustPosition(
selectionStart,
changes,
previousValue,
value
);
const adjustedSelectionEnd = adjustPosition(
selectionEnd,
changes,
previousValue,
value
);

textarea.setSelectionRange(
adjustedSelectionStart,
adjustedSelectionEnd,
selectionDirection
);
selectionRef.current = {
selectionStart: adjustedSelectionStart,
selectionEnd: adjustedSelectionEnd,
selectionDirection,
};
}, [ value ] );

const updateSelection = ( event ) => {
const { selectionStart, selectionEnd, selectionDirection } =
event.target;
selectionRef.current = {
selectionStart,
selectionEnd,
selectionDirection,
};
};

return (
<>
<VisuallyHidden
Expand All @@ -46,14 +103,22 @@ export default function PostTextEditor() {
<Textarea
autoComplete="off"
dir="auto"
ref={ textareaRef }
value={ value }
onChange={ ( event ) => {
updateSelection( event );
previousValueRef.current = event.target.value;
editEntityRecord( 'postType', type, id, {
content: event.target.value,
blocks: undefined,
selection: undefined,
} );
} }
onFocus={ updateSelection }
// A click or arrow-key caret move does not fire `select` (only
// range selections do), so track those moves via mouseup/keyup.
onMouseUp={ updateSelection }
onKeyUp={ updateSelection }
className="editor-post-text-editor"
id={ `post-content-${ instanceId }` }
placeholder={ __( 'Start writing with text or HTML' ) }
Expand Down
210 changes: 210 additions & 0 deletions packages/editor/src/components/post-text-editor/test/utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
/**
* Internal dependencies
*/
import { getAdjustedCursorPosition } from '../utils';

describe( 'PostTextEditor', () => {
describe( 'getAdjustedCursorPosition', () => {
it( 'keeps the cursor in place when text is inserted after it', () => {
expect(
getAdjustedCursorPosition( 3, 'foo bar', 'foo bar baz' )
).toBe( 3 );
} );

it( 'moves the cursor when text is inserted before it', () => {
expect( getAdjustedCursorPosition( 4, 'abcd', 'abXYcd' ) ).toBe(
6
);
} );

it( 'moves the cursor when text is deleted before it', () => {
expect( getAdjustedCursorPosition( 6, 'abcdef', 'abef' ) ).toBe(
4
);
} );

it( 'moves the cursor to the end of a replacement around it', () => {
expect( getAdjustedCursorPosition( 3, 'abcdef', 'abXYef' ) ).toBe(
4
);
} );

it( 'handles multiple separated changes in one update', () => {
expect(
getAdjustedCursorPosition( 7, 'abcdefghij', 'abXXcdefYYghij' )
).toBe( 11 );
} );

it( 'keeps the cursor in place when large text is inserted after it', () => {
const oldValue = `cursor\n${ 'a\n'.repeat( 6000 ) }`;
const newValue = `${ oldValue }remote\n`;

expect(
getAdjustedCursorPosition( 'cursor'.length, oldValue, newValue )
).toBe( 'cursor'.length );
} );

it( 'moves the cursor when large text is inserted before it', () => {
const oldValue = `cursor\n${ 'a\n'.repeat( 6000 ) }`;
const newValue = `remote\n${ oldValue }`;

expect(
getAdjustedCursorPosition( 'cursor'.length, oldValue, newValue )
).toBe( 'remote\n'.length + 'cursor'.length );
} );

it( 'moves the cursor when large text is deleted before it', () => {
const deletedPrefix = 'remote\n';
const newValue = `cursor\n${ 'a\n'.repeat( 6000 ) }`;
const oldValue = `${ deletedPrefix }${ newValue }`;
const position = deletedPrefix.length + 'cursor'.length;

expect(
getAdjustedCursorPosition( position, oldValue, newValue )
).toBe( 'cursor'.length );
} );

it( 'moves the cursor when large single-line HTML is inserted before it', () => {
const opening = '<!-- wp:paragraph -->\n<p>';
const closing = '</p>\n<!-- /wp:paragraph -->';
const content = 'a'.repeat( 120000 );
const insertedText = 'remote';
const tailText = 'tail';
const oldValue = `${ opening }${ content }${ closing }`;
const newValue = `${ opening }${ insertedText }${ content.slice(
0,
100000
) }${ tailText }${ content.slice( 100000 ) }${ closing }`;
const position = opening.length + 60000;

expect(
getAdjustedCursorPosition( position, oldValue, newValue )
).toBe( position + insertedText.length );
} );

it( 'moves the cursor when large single-line HTML is deleted before it', () => {
const opening = '<!-- wp:paragraph -->\n<p>';
const closing = '</p>\n<!-- /wp:paragraph -->';
const deletedText = 'remote'.repeat( 1000 );
const deletedTailText = 'tail';
const content = 'a'.repeat( 120000 );
const newValue = `${ opening }${ content }${ closing }`;
const oldValue = `${ opening }${ deletedText }${ content.slice(
0,
100000
) }${ deletedTailText }${ content.slice( 100000 ) }${ closing }`;
const position = opening.length + deletedText.length + 60000;

expect(
getAdjustedCursorPosition( position, oldValue, newValue )
).toBe( position - deletedText.length );
} );

it( 'keeps the cursor in an unchanged same-line prefix before a large replacement', () => {
const prefix = `${ 'a'.repeat( 300 ) }\n${ 'b'.repeat( 199 ) }`;
const suffix = 'tail';
const oldValue = `${ prefix }old${ 'x'.repeat(
11000
) }${ suffix }`;
const newValue = `${ prefix }new${ 'y'.repeat(
11000
) }${ suffix }`;
const position = 450;

expect(
getAdjustedCursorPosition( position, oldValue, newValue )
).toBe( position );
} );

it( 'moves the cursor when large same-line text is inserted before it and replaced after it', () => {
const opening = '<!-- wp:paragraph -->\n<p>';
const closing = '</p>\n<!-- /wp:paragraph -->';
const content = 'a'.repeat( 120000 );
const insertedText = 'remote';
const oldValue = `${ opening }${ content }${ closing }`;
const newValue = `${ opening }${ content.slice(
0,
50000
) }${ insertedText }${ content.slice(
50000,
100000
) }b${ content.slice( 100001 ) }${ closing }`;
const position = opening.length + 60000;

expect(
getAdjustedCursorPosition( position, oldValue, newValue )
).toBe( position + insertedText.length );
} );

it( 'moves the cursor when large same-line text is deleted before it and inserted after it', () => {
const opening = '<!-- wp:paragraph -->\n<p>';
const closing = '</p>\n<!-- /wp:paragraph -->';
const content = 'a'.repeat( 120000 );
const deletedText = 'remote'.repeat( 1000 );
const insertedTailText = 'tail';
const commonTextBeforeCursor = 500;
const oldValue = `${ opening }${ content.slice(
0,
50000
) }${ deletedText }${ content.slice( 50000 ) }${ closing }`;
const newValue = `${ opening }${ content.slice(
0,
100000
) }${ insertedTailText }${ content.slice( 100000 ) }${ closing }`;
const position =
opening.length +
50000 +
deletedText.length +
commonTextBeforeCursor;

expect(
getAdjustedCursorPosition( position, oldValue, newValue )
).toBe( position - deletedText.length );
} );

it( 'maps the cursor through a pure insertion in a large single line', () => {
// "HEAD"/"TAIL" wrap the unchanged content, so the new value contains
// the old changed window and it routes through the line-diff fallback.
const opening = '<!-- wp:paragraph -->\n<p>';
const closing = '</p>\n<!-- /wp:paragraph -->';
const content = 'a'.repeat( 120000 );
const oldValue = `${ opening }${ content }${ closing }`;
const newValue = `${ opening }HEAD${ content }TAIL${ closing }`;
const position = opening.length + 60000;

expect(
getAdjustedCursorPosition( position, oldValue, newValue )
).toBe( position + 'HEAD'.length );
} );

it( 'maps the cursor through a pure deletion in a large single line', () => {
const opening = '<!-- wp:paragraph -->\n<p>';
const closing = '</p>\n<!-- /wp:paragraph -->';
const content = 'a'.repeat( 120000 );
const oldValue = `${ opening }HEAD${ content }TAIL${ closing }`;
const newValue = `${ opening }${ content }${ closing }`;
const position = opening.length + 'HEAD'.length + 60000;

expect(
getAdjustedCursorPosition( position, oldValue, newValue )
).toBe( position - 'HEAD'.length );
} );

it( 'handles a large fully-divergent replacement without blocking', () => {
// Neither side contains the other and the changed window exceeds the
// diffChars threshold, so this routes to the line-diff fallback. A
// character diff here would be O(n^2) and freeze the main thread for
// seconds; completing within the test timeout guards that path.
const oldValue = 'a'.repeat( 8000 );
const newValue = 'b'.repeat( 8000 );
const result = getAdjustedCursorPosition(
4000,
oldValue,
newValue
);

expect( result ).toBeGreaterThanOrEqual( 0 );
expect( result ).toBeLessThanOrEqual( newValue.length );
} );
} );
} );
Loading
Loading