Skip to content

Commit

Permalink
Add selection variables to web implementation (#208)
Browse files Browse the repository at this point in the history
  • Loading branch information
Skalakid authored Mar 7, 2024
1 parent 899a12d commit e214feb
Show file tree
Hide file tree
Showing 4 changed files with 150 additions and 114 deletions.
13 changes: 13 additions & 0 deletions example/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ function getRandomColor() {
export default function App() {
const [value, setValue] = React.useState(DEFAULT_TEXT);
const [markdownStyle, setMarkdownStyle] = React.useState({});
const [selection, setSelection] = React.useState({start: 0, end: 0});

// TODO: use MarkdownTextInput ref instead of TextInput ref
const ref = React.useRef<TextInput>(null);
Expand Down Expand Up @@ -98,6 +99,8 @@ export default function App() {
ref={ref}
markdownStyle={markdownStyle}
placeholder="Type here..."
onSelectionChange={(e) => setSelection(e.nativeEvent.selection)}
selection={selection}
/>
{/* <Text>TextInput singleline</Text>
<TextInput
Expand Down Expand Up @@ -154,6 +157,16 @@ export default function App() {
})
}
/>
<Button
title="Change selection"
onPress={() => {
if (!ref.current) {
return;
}
ref.current.focus();
setSelection({start: 0, end: 20});
}}
/>
</View>
);
}
Expand Down
120 changes: 73 additions & 47 deletions src/MarkdownTextInput.web.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ const MarkdownTextInput = React.forwardRef<TextInput, MarkdownTextInputProps>(
placeholderTextColor = `rgba(0,0,0,0.2)`,
selectTextOnFocus,
spellCheck,
selection,
style = {},
value,
autoFocus = false,
Expand All @@ -159,7 +160,8 @@ const MarkdownTextInput = React.forwardRef<TextInput, MarkdownTextInputProps>(
const compositionRef = useRef<boolean>(false);
const divRef = useRef<HTMLDivElement | null>(null);
const currentlyFocusedField = useRef<HTMLDivElement | null>(null);
const contentSelection = useRef<Selection | null>(null);
const valueLength = value ? value.length : 0;
const contentSelection = useRef<Selection>({start: valueLength, end: valueLength});
const className = `react-native-live-markdown-input-${multiline ? 'multiline' : 'singleline'}`;
const history = useRef<InputHistory>();
if (!history.current) {
Expand All @@ -171,15 +173,18 @@ const MarkdownTextInput = React.forwardRef<TextInput, MarkdownTextInputProps>(
// Empty placeholder would collapse the div, so we need to use zero-width space to prevent it
const heightSafePlaceholder = useMemo(() => getPlaceholderValue(placeholder), [placeholder]);

const updateSelection = useCallback(() => {
if (!divRef.current) {
return;
const setEventProps = useCallback((e: NativeSyntheticEvent<any>) => {
if (divRef.current) {
const text = normalizeValue(divRef.current.innerText || '');
if (e.target) {
// TODO: change the logic here so every event have value property
(e.target as unknown as HTMLInputElement).value = text;
}
if (e.nativeEvent && e.nativeEvent.text) {
e.nativeEvent.text = text;
}
}
const selection = CursorUtils.getCurrentCursorPosition(divRef.current);
const markdownHTMLInput = divRef.current as HTMLInputElement;
markdownHTMLInput.selectionStart = selection.start;
markdownHTMLInput.selectionEnd = selection.end;
contentSelection.current = selection;
return e;
}, []);

const parseText = useCallback(
Expand All @@ -192,8 +197,6 @@ const MarkdownTextInput = React.forwardRef<TextInput, MarkdownTextInputProps>(
history.current.debouncedAdd(parsedText.text, parsedText.cursorPosition);
}

updateSelection();

return parsedText;
},
[multiline],
Expand Down Expand Up @@ -246,18 +249,6 @@ const MarkdownTextInput = React.forwardRef<TextInput, MarkdownTextInputProps>(
return value;
}, [value]);

const setEventProps = useCallback((e: NativeSyntheticEvent<any>) => {
if (divRef.current) {
const text = normalizeValue(divRef.current.innerText || '');
if (typeof e.target !== 'number') {
// TODO: change the logic here so every event have value property
(e.target as unknown as HTMLInputElement).value = text;
}
e.nativeEvent.text = text;
}
return e;
}, []);

// Placeholder text color logic
const updateTextColor = useCallback((node: HTMLDivElement, text: string) => {
// eslint-disable-next-line no-param-reassign -- we need to change the style of the node, so we need to modify it
Expand Down Expand Up @@ -304,6 +295,41 @@ const MarkdownTextInput = React.forwardRef<TextInput, MarkdownTextInputProps>(
[multiline, onChange, onChangeText, setEventProps, processedMarkdownStyle],
);

const handleSelectionChange: ReactEventHandler<HTMLDivElement> = useCallback(
(event) => {
const e = event as unknown as NativeSyntheticEvent<TextInputSelectionChangeEventData>;
setEventProps(e);
if (onSelectionChange && contentSelection.current) {
e.nativeEvent.selection = contentSelection.current;
onSelectionChange(e);
}
},
[onSelectionChange, setEventProps],
);

const updateRefSelectionVariables = useCallback((newSelection: Selection) => {
const {start, end} = newSelection;
const markdownHTMLInput = divRef.current as HTMLInputElement;
markdownHTMLInput.selectionStart = start;
markdownHTMLInput.selectionEnd = end;
}, []);

const updateSelection = useCallback((e: SyntheticEvent<HTMLDivElement> | null = null) => {
if (!divRef.current) {
return;
}
const newSelection = CursorUtils.getCurrentCursorPosition(divRef.current);

if (newSelection && (contentSelection.current.start !== newSelection.start || contentSelection.current.end !== newSelection.end)) {
updateRefSelectionVariables(newSelection);
contentSelection.current = newSelection;

if (e) {
handleSelectionChange(e);
}
}
}, []);

const handleKeyPress = useCallback(
(e: KeyboardEvent<HTMLDivElement>) => {
if (!divRef.current) {
Expand Down Expand Up @@ -338,6 +364,8 @@ const MarkdownTextInput = React.forwardRef<TextInput, MarkdownTextInputProps>(
onKeyPress(event);
}

updateSelection(event as unknown as SyntheticEvent<HTMLDivElement, Event>);

if (
e.key === 'Enter' &&
!e.shiftKey &&
Expand Down Expand Up @@ -366,27 +394,14 @@ const MarkdownTextInput = React.forwardRef<TextInput, MarkdownTextInputProps>(
[onKeyPress],
);

const handleSelectionChange: ReactEventHandler<HTMLDivElement> = useCallback(
(event) => {
const e = event as unknown as NativeSyntheticEvent<TextInputSelectionChangeEventData>;
setEventProps(e);
updateSelection();
if (onSelectionChange && contentSelection.current) {
e.nativeEvent.selection = contentSelection.current;
onSelectionChange(e);
}
},
[onSelectionChange, setEventProps],
);

const handleFocus: FocusEventHandler<HTMLDivElement> = useCallback(
(event) => {
const e = event as unknown as NativeSyntheticEvent<TextInputFocusEventData>;
const hostNode = e.target as unknown as HTMLDivElement;
currentlyFocusedField.current = hostNode;
setEventProps(e);
if (divRef.current && contentSelection.current) {
CursorUtils.setCursorPosition(divRef.current, contentSelection.current.start || divRef.current.innerText.length, !multiline);
CursorUtils.setCursorPosition(divRef.current, contentSelection.current.end || contentSelection.current.start);
}

if (onFocus) {
Expand Down Expand Up @@ -418,6 +433,7 @@ const MarkdownTextInput = React.forwardRef<TextInput, MarkdownTextInputProps>(
const handleBlur: FocusEventHandler<HTMLDivElement> = useCallback(
(event) => {
const e = event as unknown as NativeSyntheticEvent<TextInputFocusEventData>;
CursorUtils.removeSelection();
currentlyFocusedField.current = null;
if (onBlur) {
setEventProps(e);
Expand All @@ -429,7 +445,7 @@ const MarkdownTextInput = React.forwardRef<TextInput, MarkdownTextInputProps>(

const handleClick = useCallback(
(e: MouseEvent<HTMLDivElement, globalThis.MouseEvent>) => {
updateSelection();
updateSelection(e);
if (!onClick || !divRef.current) {
return;
}
Expand All @@ -439,6 +455,10 @@ const MarkdownTextInput = React.forwardRef<TextInput, MarkdownTextInputProps>(
[onClick],
);

const startComposition = useCallback(() => {
compositionRef.current = true;
}, []);

const setRef = (currentRef: HTMLDivElement | null) => {
const r = currentRef;
if (r) {
Expand Down Expand Up @@ -514,16 +534,23 @@ const MarkdownTextInput = React.forwardRef<TextInput, MarkdownTextInputProps>(
}, []);

useEffect(() => {
// focus the input on mount if autoFocus is set
if (!(divRef.current && autoFocus)) {
if (!divRef.current) {
return;
}
divRef.current.focus();
// focus the input on mount if autoFocus is set
if (autoFocus) {
divRef.current.focus();
}
updateRefSelectionVariables(contentSelection.current);
}, []);

const startComposition = useCallback(() => {
compositionRef.current = true;
}, []);
useEffect(() => {
if (!divRef.current || !selection || (selection.start === contentSelection.current.start && selection.end === contentSelection.current.end)) {
return;
}
CursorUtils.setCursorPosition(divRef.current, selection.start, selection.end);
updateSelection();
}, [selection]);

return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
Expand All @@ -543,7 +570,6 @@ const MarkdownTextInput = React.forwardRef<TextInput, MarkdownTextInputProps>(
onCompositionStart={startComposition}
onKeyUp={updateSelection}
onInput={handleOnChangeText}
onSelect={handleSelectionChange}
onClick={handleClick}
onFocus={handleFocus}
onBlur={handleBlur}
Expand All @@ -564,8 +590,8 @@ const styles = StyleSheet.create({
// @ts-expect-error it works on web
boxSizing: 'border-box',
whiteSpace: 'pre-wrap',
overflowY: 'scroll',
overflowX: 'scroll',
overflowY: 'auto',
overflowX: 'auto',
overflowWrap: 'break-word',
},
disabledInputStyles: {
Expand Down
109 changes: 55 additions & 54 deletions src/web/cursorUtils.ts
Original file line number Diff line number Diff line change
@@ -1,46 +1,53 @@
function createRange(node: HTMLElement, targetPosition: number, ignoreNewLines = false) {
function findTextNodes(textNodes: Text[], node: ChildNode) {
if (node.nodeType === Node.TEXT_NODE) {
textNodes.push(node as Text);
} else {
for (let i = 0, length = node.childNodes.length; i < length; ++i) {
const childNode = node.childNodes[i];
if (childNode) {
findTextNodes(textNodes, childNode);
}
}
}
}

function setCursorPosition(target: HTMLElement, start: number, end: number | null = null) {
const range = document.createRange();
range.selectNode(node);
range.selectNodeContents(target);

let pos = 0;
const stack: Node[] = [node];
while (stack.length > 0) {
const current = stack.pop();
if (!current) {
break;
}
if (current.nodeType === Node.TEXT_NODE || current.nodeName === 'BR') {
const textContentLength = current.textContent ? current.textContent.length : 0;
const len = current.nodeName === 'BR' ? 1 : textContentLength;
if (pos + len >= targetPosition) {
if (current.nodeName === 'BR') {
range.setStartAfter(current);
(current as HTMLElement).scrollIntoView();
} else {
range.setStart(current, targetPosition - pos);
const textNodes: Text[] = [];
findTextNodes(textNodes, target);

let charCount = 0;
let startNode: Text | null = null;
let endNode: Text | null = null;
const n = textNodes.length;
for (let i = 0; i < n; ++i) {
const textNode = textNodes[i];
if (textNode) {
const nextCharCount = charCount + textNode.length;

if (!startNode && start >= charCount && (start <= nextCharCount || (start === nextCharCount && i < n - 1))) {
startNode = textNode;
range.setStart(textNode, start - charCount);
if (!end) {
break;
}
return range;
}
pos += len;
} else if (current.childNodes && current.childNodes.length > 0) {
for (let i = current.childNodes.length - 1; i >= 0; i--) {
const currentNode = current.childNodes[i];
if (currentNode && (!ignoreNewLines || (ignoreNewLines && currentNode.nodeName !== 'BR'))) {
stack.push(currentNode);
}
if (end && !endNode && end >= charCount && (end <= nextCharCount || (end === nextCharCount && i < n - 1))) {
endNode = textNode;
range.setEnd(textNode, end - charCount);
}
charCount = nextCharCount;
}
}

range.setStart(node, node.childNodes.length);
return range;
}
if (!end) {
range.collapse(true);
}

function setCursorPosition(target: HTMLElement, targetPosition: number, ignoreNewLines = false) {
const range = createRange(target, targetPosition, ignoreNewLines);
const selection = window.getSelection();
if (selection) {
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
}
Expand All @@ -57,31 +64,25 @@ function moveCursorToEnd(target: HTMLElement) {
}
}

function getIndexedPosition(target: HTMLElement, range: Range, isStart: boolean) {
const marker = document.createTextNode('\0');
const rangeClone = range.cloneRange();

rangeClone.collapse(isStart);

rangeClone.insertNode(marker);
const position = target.innerText.indexOf('\0');
if (marker.parentNode) {
marker.parentNode.removeChild(marker);
}

return position;
}

function getCurrentCursorPosition(target: HTMLElement) {
const selection = document.getSelection();
if (!selection || selection.rangeCount === 0) {
return {start: target.innerText.length, end: target.innerText.length};
const selection = window.getSelection();
if (!selection || (selection && selection.rangeCount === 0)) {
return null;
}

const range = selection.getRangeAt(0);
const start = getIndexedPosition(target, range, true);
const end = getIndexedPosition(target, range, false);
const preSelectionRange = range.cloneRange();
preSelectionRange.selectNodeContents(target);
preSelectionRange.setEnd(range.startContainer, range.startOffset);
const start = preSelectionRange.toString().length;
const end = start + range.toString().length;
return {start, end};
}

export {getCurrentCursorPosition, moveCursorToEnd, setCursorPosition};
function removeSelection() {
const selection = window.getSelection();
if (selection) {
selection.removeAllRanges();
}
}

export {getCurrentCursorPosition, moveCursorToEnd, setCursorPosition, removeSelection};
Loading

0 comments on commit e214feb

Please sign in to comment.