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
15 changes: 15 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,10 @@ export default function App() {
ref={ref}
markdownStyle={markdownStyle}
placeholder="Type here..."
onSelectionChange={(e) => {
setSelection(e.nativeEvent.selection);
}}
Skalakid marked this conversation as resolved.
Show resolved Hide resolved
selection={selection}
/>
{/* <Text>TextInput singleline</Text>
<TextInput
Expand Down Expand Up @@ -154,6 +159,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
110 changes: 68 additions & 42 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 @@ -158,7 +159,8 @@ const MarkdownTextInput = React.forwardRef<TextInput, MarkdownTextInputProps>(
) => {
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 @@ -170,15 +172,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 && typeof e.target !== 'number') {
Skalakid marked this conversation as resolved.
Show resolved Hide resolved
// TODO: change the logic here so every event have value property
(e.target as unknown as HTMLInputElement).value = text;
Skalakid marked this conversation as resolved.
Show resolved Hide resolved
}
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 @@ -191,8 +196,6 @@ const MarkdownTextInput = React.forwardRef<TextInput, MarkdownTextInputProps>(
history.current.debouncedAdd(parsedText.text, parsedText.cursorPosition);
}

updateSelection();

return parsedText;
},
[multiline],
Expand Down Expand Up @@ -245,18 +248,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 @@ -297,6 +288,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((start: number, end: number) => {
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 currentSelection = CursorUtils.getCurrentCursorPosition(divRef.current);

if (currentSelection && (contentSelection.current.start !== currentSelection.start || contentSelection.current.end !== currentSelection.end)) {
if (contentSelection.current.start >= 0 && contentSelection.current.end >= 0) {
updateRefSelectionVariables(contentSelection.current.start, contentSelection.current.end);
contentSelection.current = currentSelection;
}
if (e) {
handleSelectionChange(e);
}
}
Skalakid marked this conversation as resolved.
Show resolved Hide resolved
}, []);

const handleKeyPress = useCallback(
(e: KeyboardEvent<HTMLDivElement>) => {
if (!divRef.current) {
Expand Down Expand Up @@ -331,6 +357,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 @@ -359,27 +387,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 @@ -411,6 +426,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 @@ -422,7 +438,7 @@ const MarkdownTextInput = React.forwardRef<TextInput, MarkdownTextInputProps>(

const handleClick = useCallback(
(e: MouseEvent<HTMLDivElement, globalThis.MouseEvent>) => {
updateSelection();
updateSelection(e);
if (!onClick || !divRef.current) {
return;
}
Expand Down Expand Up @@ -507,13 +523,24 @@ 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.start, contentSelection.current.end);
Skalakid marked this conversation as resolved.
Show resolved Hide resolved
}, []);

useEffect(() => {
if (!divRef.current || !selection || !(selection.start !== contentSelection.current.start || selection.end !== contentSelection.current.end)) {
Skalakid marked this conversation as resolved.
Show resolved Hide resolved
return;
}
CursorUtils.setCursorPosition(divRef.current, selection.start, selection.end);
updateSelection();
}, [selection]);

return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div
Expand All @@ -531,7 +558,6 @@ const MarkdownTextInput = React.forwardRef<TextInput, MarkdownTextInputProps>(
onKeyDown={handleKeyPress}
onKeyUp={updateSelection}
onInput={handleOnChangeText}
onSelect={handleSelectionChange}
onClick={handleClick}
onFocus={handleFocus}
onBlur={handleBlur}
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 === 3) {
textNodes.push(node as Text);
Skalakid marked this conversation as resolved.
Show resolved Hide resolved
} else {
for (let i = 0, len = node.childNodes.length; i < len; ++i) {
const childNode = node.childNodes[i];
Skalakid marked this conversation as resolved.
Show resolved Hide resolved
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);
function getCurrentCursorPosition(target: HTMLElement) {
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
Skalakid marked this conversation as resolved.
Show resolved Hide resolved
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};
}

return position;
return null;
}

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

const range = selection.getRangeAt(0);
const start = getIndexedPosition(target, range, true);
const end = getIndexedPosition(target, range, false);
return {start, end};
}

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