schlechtenburg/packages/rich-text/lib/component/index.ts
2024-10-08 09:15:26 +02:00

215 lines
5.6 KiB
TypeScript

/**
* 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 _valueRef = useRef( value );
const recordRef = useRef();
function setRecordFromProps() {
_valueRef.current = value;
recordRef.current = value;
if ( ! ( value instanceof RichTextData ) ) {
recordRef.current = value
? RichTextData.fromHTMLString( value, { preserveWhiteSpace } )
: RichTextData.empty();
}
// To do: make rich text internally work with RichTextData.
recordRef.current = {
text: recordRef.current.text,
formats: recordRef.current.formats,
replacements: recordRef.current.replacements,
};
if ( disableFormats ) {
recordRef.current.formats = Array( value.length );
recordRef.current.replacements = Array( value.length );
}
if ( __unstableAfterParse ) {
recordRef.current.formats = __unstableAfterParse(
recordRef.current
);
}
recordRef.current.start = selectionStart;
recordRef.current.end = selectionEnd;
}
const hadSelectionUpdateRef = useRef( false );
if ( ! recordRef.current ) {
hadSelectionUpdateRef.current = isSelected;
setRecordFromProps();
} else if (
selectionStart !== recordRef.current.start ||
selectionEnd !== recordRef.current.end
) {
hadSelectionUpdateRef.current = isSelected;
recordRef.current = {
...recordRef.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 ) {
recordRef.current = newRecord;
applyRecord( newRecord );
if ( disableFormats ) {
_valueRef.current = newRecord.text;
} else {
const newFormats = __unstableBeforeSerialize
? __unstableBeforeSerialize( newRecord )
: newRecord.formats;
newRecord = { ...newRecord, formats: newFormats };
if ( typeof value === 'string' ) {
_valueRef.current = toHTMLString( {
value: newRecord,
preserveWhiteSpace,
} );
} else {
_valueRef.current = new RichTextData( newRecord );
}
}
const { start, end, formats, text } = recordRef.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( _valueRef.current, {
__unstableFormats: formats,
__unstableText: text,
} );
} );
forceRender();
}
function applyFromProps() {
setRecordFromProps();
applyRecord( recordRef.current );
}
const didMountRef = useRef( false );
// Value updates must happen synchonously to avoid overwriting newer values.
useLayoutEffect( () => {
if ( didMountRef.current && value !== _valueRef.current ) {
applyFromProps();
forceRender();
}
}, [ value ] );
// Value updates must happen synchonously to avoid overwriting newer values.
useLayoutEffect( () => {
if ( ! hadSelectionUpdateRef.current ) {
return;
}
if ( ref.current.ownerDocument.activeElement !== ref.current ) {
ref.current.focus();
}
applyRecord( recordRef.current );
hadSelectionUpdateRef.current = false;
}, [ hadSelectionUpdateRef.current ] );
const mergedRefs = useMergeRefs( [
ref,
useDefaultStyle(),
useBoundaryStyle( { record: recordRef } ),
useEventListeners( {
record: recordRef,
handleChange,
applyRecord,
createRecord,
isSelected,
onSelectionChange,
forceRender,
} ),
useRefEffect( () => {
applyFromProps();
didMountRef.current = true;
}, [ placeholder, ...__unstableDependencies ] ),
] );
return {
value: recordRef.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: () => recordRef.current,
onChange: handleChange,
ref: mergedRefs,
};
}
export default function __experimentalRichText() {}