import * as React from 'react'; type Padding = T | { top?: T; right?: T; bottom?: T; left?: T }; type Props = React.HTMLAttributes & { // Props for the component highlight: (value: string) => string | React.ReactNode; ignoreTabKey?: boolean; insertSpaces?: boolean; onValueChange: (value: string) => void; padding?: Padding; style?: React.CSSProperties; tabSize?: number; value: string; // Props for the textarea autoFocus?: boolean; disabled?: boolean; form?: string; maxLength?: number; minLength?: number; name?: string; onBlur?: React.FocusEventHandler; onClick?: React.MouseEventHandler; onFocus?: React.FocusEventHandler; onKeyDown?: React.KeyboardEventHandler; onKeyUp?: React.KeyboardEventHandler; placeholder?: string; readOnly?: boolean; required?: boolean; textareaClassName?: string; textareaId?: string; // Props for the hightlighted code’s pre element preClassName?: string; }; type Record = { value: string; selectionStart: number; selectionEnd: number; }; type History = { stack: (Record & { timestamp: number })[]; offset: number; }; const KEYCODE_Y = 89; const KEYCODE_Z = 90; const KEYCODE_M = 77; const KEYCODE_PARENS = 57; const KEYCODE_BRACKETS = 219; const KEYCODE_QUOTE = 222; const KEYCODE_BACK_QUOTE = 192; const HISTORY_LIMIT = 100; const HISTORY_TIME_GAP = 3000; const isWindows = typeof window !== 'undefined' && 'navigator' in window && /Win/i.test(navigator.platform); const isMacLike = typeof window !== 'undefined' && 'navigator' in window && /(Mac|iPhone|iPod|iPad)/i.test(navigator.platform); const className = 'npm__react-simple-code-editor__textarea'; const cssText = /* CSS */ ` /** * Reset the text fill color so that placeholder is visible */ .${className}:empty { -webkit-text-fill-color: inherit !important; } /** * Hack to apply on some CSS on IE10 and IE11 */ @media all and (-ms-high-contrast: none), (-ms-high-contrast: active) { /** * IE doesn't support '-webkit-text-fill-color' * So we use 'color: transparent' to make the text transparent on IE * Unlike other browsers, it doesn't affect caret color in IE */ .${className} { color: transparent !important; } .${className}::selection { background-color: #accef7 !important; color: transparent !important; } } `; const Editor = React.forwardRef(function Editor( props: Props, ref: React.Ref ) { const { autoFocus, disabled, form, highlight, ignoreTabKey = false, insertSpaces = true, maxLength, minLength, name, onBlur, onClick, onFocus, onKeyDown, onKeyUp, onValueChange, padding = 0, placeholder, preClassName, readOnly, required, style, tabSize = 2, textareaClassName, textareaId, value, ...rest } = props; const historyRef = React.useRef({ stack: [], offset: -1, }); const inputRef = React.useRef(null); const [capture, setCapture] = React.useState(true); const contentStyle = { paddingTop: typeof padding === 'object' ? padding.top : padding, paddingRight: typeof padding === 'object' ? padding.right : padding, paddingBottom: typeof padding === 'object' ? padding.bottom : padding, paddingLeft: typeof padding === 'object' ? padding.left : padding, }; const highlighted = highlight(value); const getLines = (text: string, position: number) => text.substring(0, position).split('\n'); const recordChange = React.useCallback( (record: Record, overwrite: boolean = false) => { const { stack, offset } = historyRef.current; if (stack.length && offset > -1) { // When something updates, drop the redo operations historyRef.current.stack = stack.slice(0, offset + 1); // Limit the number of operations to 100 const count = historyRef.current.stack.length; if (count > HISTORY_LIMIT) { const extras = count - HISTORY_LIMIT; historyRef.current.stack = stack.slice(extras, count); historyRef.current.offset = Math.max( historyRef.current.offset - extras, 0 ); } } const timestamp = Date.now(); if (overwrite) { const last = historyRef.current.stack[historyRef.current.offset]; if (last && timestamp - last.timestamp < HISTORY_TIME_GAP) { // A previous entry exists and was in short interval // Match the last word in the line const re = /[^a-z0-9]([a-z0-9]+)$/i; // Get the previous line const previous = getLines(last.value, last.selectionStart) .pop() ?.match(re); // Get the current line const current = getLines(record.value, record.selectionStart) .pop() ?.match(re); if (previous?.[1] && current?.[1]?.startsWith(previous[1])) { // The last word of the previous line and current line match // Overwrite previous entry so that undo will remove whole word historyRef.current.stack[historyRef.current.offset] = { ...record, timestamp, }; return; } } } // Add the new operation to the stack historyRef.current.stack.push({ ...record, timestamp }); historyRef.current.offset++; }, [] ); const recordCurrentState = React.useCallback(() => { const input = inputRef.current; if (!input) return; // Save current state of the input const { value, selectionStart, selectionEnd } = input; recordChange({ value, selectionStart, selectionEnd, }); }, [recordChange]); const updateInput = (record: Record) => { const input = inputRef.current; if (!input) return; // Update values and selection state input.value = record.value; input.selectionStart = record.selectionStart; input.selectionEnd = record.selectionEnd; onValueChange?.(record.value); }; const applyEdits = (record: Record) => { // Save last selection state const input = inputRef.current; const last = historyRef.current.stack[historyRef.current.offset]; if (last && input) { historyRef.current.stack[historyRef.current.offset] = { ...last, selectionStart: input.selectionStart, selectionEnd: input.selectionEnd, }; } // Save the changes recordChange(record); updateInput(record); }; const undoEdit = () => { const { stack, offset } = historyRef.current; // Get the previous edit const record = stack[offset - 1]; if (record) { // Apply the changes and update the offset updateInput(record); historyRef.current.offset = Math.max(offset - 1, 0); } }; const redoEdit = () => { const { stack, offset } = historyRef.current; // Get the next edit const record = stack[offset + 1]; if (record) { // Apply the changes and update the offset updateInput(record); historyRef.current.offset = Math.min(offset + 1, stack.length - 1); } }; const handleKeyDown = (e: React.KeyboardEvent) => { if (onKeyDown) { onKeyDown(e); if (e.defaultPrevented) { return; } } if (e.key === 'Escape') { e.currentTarget.blur(); } const { value, selectionStart, selectionEnd } = e.currentTarget; const tabCharacter = (insertSpaces ? ' ' : '\t').repeat(tabSize); if (e.key === 'Tab' && !ignoreTabKey && capture) { // Prevent focus change e.preventDefault(); if (e.shiftKey) { // Unindent selected lines const linesBeforeCaret = getLines(value, selectionStart); const startLine = linesBeforeCaret.length - 1; const endLine = getLines(value, selectionEnd).length - 1; const nextValue = value .split('\n') .map((line, i) => { if ( i >= startLine && i <= endLine && line.startsWith(tabCharacter) ) { return line.substring(tabCharacter.length); } return line; }) .join('\n'); if (value !== nextValue) { const startLineText = linesBeforeCaret[startLine]; applyEdits({ value: nextValue, // Move the start cursor if first line in selection was modified // It was modified only if it started with a tab selectionStart: startLineText?.startsWith(tabCharacter) ? selectionStart - tabCharacter.length : selectionStart, // Move the end cursor by total number of characters removed selectionEnd: selectionEnd - (value.length - nextValue.length), }); } } else if (selectionStart !== selectionEnd) { // Indent selected lines const linesBeforeCaret = getLines(value, selectionStart); const startLine = linesBeforeCaret.length - 1; const endLine = getLines(value, selectionEnd).length - 1; const startLineText = linesBeforeCaret[startLine]; applyEdits({ value: value .split('\n') .map((line, i) => { if (i >= startLine && i <= endLine) { return tabCharacter + line; } return line; }) .join('\n'), // Move the start cursor by number of characters added in first line of selection // Don't move it if it there was no text before cursor selectionStart: startLineText && /\S/.test(startLineText) ? selectionStart + tabCharacter.length : selectionStart, // Move the end cursor by total number of characters added selectionEnd: selectionEnd + tabCharacter.length * (endLine - startLine + 1), }); } else { const updatedSelection = selectionStart + tabCharacter.length; applyEdits({ // Insert tab character at caret value: value.substring(0, selectionStart) + tabCharacter + value.substring(selectionEnd), // Update caret position selectionStart: updatedSelection, selectionEnd: updatedSelection, }); } } else if (e.key === 'Backspace') { const hasSelection = selectionStart !== selectionEnd; const textBeforeCaret = value.substring(0, selectionStart); if (textBeforeCaret.endsWith(tabCharacter) && !hasSelection) { // Prevent default delete behaviour e.preventDefault(); const updatedSelection = selectionStart - tabCharacter.length; applyEdits({ // Remove tab character at caret value: value.substring(0, selectionStart - tabCharacter.length) + value.substring(selectionEnd), // Update caret position selectionStart: updatedSelection, selectionEnd: updatedSelection, }); } } else if (e.key === 'Enter') { // Ignore selections if (selectionStart === selectionEnd) { // Get the current line const line = getLines(value, selectionStart).pop(); const matches = line?.match(/^\s+/); if (matches?.[0]) { e.preventDefault(); // Preserve indentation on inserting a new line const indent = '\n' + matches[0]; const updatedSelection = selectionStart + indent.length; applyEdits({ // Insert indentation character at caret value: value.substring(0, selectionStart) + indent + value.substring(selectionEnd), // Update caret position selectionStart: updatedSelection, selectionEnd: updatedSelection, }); } } } else if ( e.keyCode === KEYCODE_PARENS || e.keyCode === KEYCODE_BRACKETS || e.keyCode === KEYCODE_QUOTE || e.keyCode === KEYCODE_BACK_QUOTE ) { let chars; if (e.keyCode === KEYCODE_PARENS && e.shiftKey) { chars = ['(', ')']; } else if (e.keyCode === KEYCODE_BRACKETS) { if (e.shiftKey) { chars = ['{', '}']; } else { chars = ['[', ']']; } } else if (e.keyCode === KEYCODE_QUOTE) { if (e.shiftKey) { chars = ['"', '"']; } else { chars = ["'", "'"]; } } else if (e.keyCode === KEYCODE_BACK_QUOTE && !e.shiftKey) { chars = ['`', '`']; } // If text is selected, wrap them in the characters if (selectionStart !== selectionEnd && chars) { e.preventDefault(); applyEdits({ value: value.substring(0, selectionStart) + chars[0] + value.substring(selectionStart, selectionEnd) + chars[1] + value.substring(selectionEnd), // Update caret position selectionStart, selectionEnd: selectionEnd + 2, }); } } else if ( (isMacLike ? // Trigger undo with ⌘+Z on Mac e.metaKey && e.keyCode === KEYCODE_Z : // Trigger undo with Ctrl+Z on other platforms e.ctrlKey && e.keyCode === KEYCODE_Z) && !e.shiftKey && !e.altKey ) { e.preventDefault(); undoEdit(); } else if ( (isMacLike ? // Trigger redo with ⌘+Shift+Z on Mac e.metaKey && e.keyCode === KEYCODE_Z && e.shiftKey : isWindows ? // Trigger redo with Ctrl+Y on Windows e.ctrlKey && e.keyCode === KEYCODE_Y : // Trigger redo with Ctrl+Shift+Z on other platforms e.ctrlKey && e.keyCode === KEYCODE_Z && e.shiftKey) && !e.altKey ) { e.preventDefault(); redoEdit(); } else if ( e.keyCode === KEYCODE_M && e.ctrlKey && (isMacLike ? e.shiftKey : true) ) { e.preventDefault(); // Toggle capturing tab key so users can focus away setCapture((prev) => !prev); } }; const handleChange = (e: React.ChangeEvent) => { const { value, selectionStart, selectionEnd } = e.currentTarget; recordChange( { value, selectionStart, selectionEnd, }, true ); onValueChange(value); }; React.useEffect(() => { recordCurrentState(); }, [recordCurrentState]); React.useImperativeHandle( ref, () => { return { get session() { return { history: historyRef.current, }; }, set session(session: { history: History }) { historyRef.current = session.history; }, }; }, [] ); return (