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

Add selection variables to web implementation #208

Merged
merged 14 commits into from
Mar 7, 2024
Merged
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});
tomekzaw marked this conversation as resolved.
Show resolved Hide resolved

// 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});
Skalakid marked this conversation as resolved.
Show resolved Hide resolved
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
Loading