diff --git a/README.md b/README.md index dba34177..98a01783 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ A [React](https://github.com/facebook/react) component for editing or viewing JS - [Active hyperlinks](#active-hyperlinks) - [Custom Collection nodes](#custom-collection-nodes) - [Custom Text](#custom-text) +- [Keyboard customisation](#keyboard-customisation) - [Undo functionality](#undo-functionality) - [Exported helpers](#exported-helpers) - [Functions \& Components](#functions--components) @@ -148,6 +149,7 @@ The only *required* value is `data` (although you will need to provide a `setDat | `jsonParse` | `(input: string) => JsonData` | `JSON.parse` | When editing a block of JSON directly, you may wish to allow some "looser" input -- e.g. 'single quotes', trailing commas, or unquoted field names. In this case, you can provide a third-party JSON parsing method. I recommend [JSON5](https://json5.org/), which is what is used in the [Demo](https://carlosnz.github.io/json-edit-react/) | | `jsonStringify` | `(data: JsonData) => string` | `(data) => JSON.stringify(data, null, 2)` | Similarly, you can override the default presentation of the JSON string when starting editing JSON. You can supply different formatting parameters to the native `JSON.stringify()`, or provide a third-party option, like the aforementioned JSON5. | | `errorMessageTimeout` | `number` | `2500` | Time (in milliseconds) to display the error message in the UI. | | +| `keyboardControls` | `KeyboardControls` | As explained [above](#usage) | Override some or all of the keyboard controls. See [Keyboard customisation](#keyboard-customisation) for details. | | ## Managing state @@ -655,6 +657,44 @@ customText = { } ``` +## Keyboard customisation + +The default keyboard controls are [outlined above](#usage), but it's possible to customise/override these. Just pass in a `keyboardControls` prop with the actions you wish to override defined. The default config object is: +```ts +{ + confirm: 'Enter', // default for all Value nodes, and key entry + cancel: 'Escape', + objectConfirm: { key: 'Enter', modifier: ['Meta', 'Shift', 'Control'] }, + objectLineBreak: 'Enter', + stringConfirm: 'Enter', + stringLineBreak: { key: 'Enter', modifier: 'Shift' }, + numberConfirm: 'Enter', + numberUp: 'ArrowUp', + numberDown: 'ArrowDown', + booleanConfirm: 'Enter', + clipboardModifier: ['Meta', 'Control'], + collapseModifier: 'Alt', +} +``` + +If (for example), you just wish to change the general "confirmation" action to "Cmd-Enter" (on Mac), or "Ctrl-Enter", you'd just pass in: +```ts + keyboardControls = { + confirm: { + key: "Enter", + modifier: [ "Meta", "Control" ] + } + } +``` + +**Considerations**: + +- Key names come from [this list](https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values) +- Accepted modifiers are "Meta", "Control", "Alt", "Shift" +- On Mac, "Meta" refers to the "Cmd" key, and "Alt" refers to "Option" +- If multiple modifiers are specified (in an array), *any* of them will be accepted (multi-modifier commands not currently supported) +- You only need to specify values for `stringConfirm`, `numberConfirm`, and `booleanConfirm` if they should *differ* from your `confirm` value. +- You won't be able to override system or browser behaviours: for example, on Mac "Ctrl-click" will perform a right-click, so using it as a click modifier won't work (hence we also accept "Meta"/"Cmd" as the default `clipboardModifier`). ## Undo functionality @@ -691,6 +731,7 @@ A few helper functions, components and types that might be useful in your own im - `ValueNodeProps`: all props passed internally to "value" nodes (i.e. *not* objects/arrays) - `CustomNodeProps`: all props passed internally to [Custom nodes](#custom-nodes); basically the same as `CollectionNodeProps` with an extra `customNodeProps` field for passing props unique to your component` - `DataType`: `"string"` | `"number"` | `"boolean"` | `"null"` | `"object"` | `"array"` +- `KeyboardControls`: structure for [keyboard customisation](#keyboard-customisation) prop ## Issues, bugs, suggestions? @@ -709,6 +750,7 @@ This component is heavily inspired by [react-json-view](https://github.com/mac-s ## Changelog +- **1.18.0**: Ability to [customise keyboard controls](#keyboard-customisation) - **1.17.0**: `defaultValue` function takes the new `key` as second parameter - **1.16.0**: Extend the "click" zone for collapsing nodes to the header bar and left margin (not just the collapse icon) - **1.15.12**: diff --git a/demo/package.json b/demo/package.json index e763973f..c254163d 100644 --- a/demo/package.json +++ b/demo/package.json @@ -1,6 +1,6 @@ { "name": "json-edit-react-demo", - "version": "0.1.0", + "version": "1.17.3", "private": true, "homepage": "https://carlosnz.github.io/json-edit-react", "dependencies": { @@ -18,7 +18,7 @@ "ajv": "^8.16.0", "firebase": "^10.13.0", "framer-motion": "^11.0.3", - "json-edit-react": "^1.17.3", + "json-edit-react": "^1.18.0-beta1", "json5": "^2.2.3", "react": "^18.2.0", "react-datepicker": "^5.0.0", diff --git a/demo/src/App.tsx b/demo/src/App.tsx index f8e4add2..fd14189c 100644 --- a/demo/src/App.tsx +++ b/demo/src/App.tsx @@ -385,6 +385,16 @@ function App() { // ]} onChange={dataDefinition?.onChange ?? undefined} jsonParse={JSON5.parse} + // keyboardControls={{ + // cancel: 'Tab', + // confirm: { key: 'Enter', modifier: 'Meta' }, + // objectConfirm: { key: 'Enter', modifier: 'Shift' }, + // stringLineBreak: { key: 'Enter' }, + // stringConfirm: { key: 'Enter', modifier: 'Meta' }, + // clipboardModifier: ['Alt', 'Shift'], + // collapseModifier: 'Control', + // booleanConfirm: 'Enter', + // }} /> diff --git a/demo/yarn.lock b/demo/yarn.lock index 015efa08..a436a298 100644 --- a/demo/yarn.lock +++ b/demo/yarn.lock @@ -8109,10 +8109,10 @@ json-buffer@3.0.1: resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13" integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ== -json-edit-react@^1.17.3: - version "1.17.3" - resolved "https://registry.yarnpkg.com/json-edit-react/-/json-edit-react-1.17.3.tgz#3300de1230b8f3e5bf51fd4a471c9e3ebc6c9df3" - integrity sha512-XehDsD3oRV1Uwb9ErX0R/DkZWhkpYmtJWsvzU4p9uL1ZvlBKtOMrF50eEh4gLwlDeM2mXD0xGyLsWCQYwtrY/A== +json-edit-react@^1.18.0-beta1: + version "1.18.0-beta1" + resolved "https://registry.yarnpkg.com/json-edit-react/-/json-edit-react-1.18.0-beta1.tgz#a9ba51e0eff458a1221e907d20ac7b38a5877ff4" + integrity sha512-J+ug/e1AnsgN8uDjFppGZtwWszMZL/57u3J65qz3ccsXkcL/V2slSfS5MeswV9LpYDlsJjGtILw1pGDkAs509g== dependencies: object-property-assigner "^1.3.0" object-property-extractor "^1.0.11" diff --git a/package.json b/package.json index ba392529..9e2b8cee 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "json-edit-react", - "version": "1.17.3", + "version": "1.18.0-beta1", "description": "React component for editing or viewing JSON/object data", "main": "build/index.cjs.js", "module": "build/index.esm.js", diff --git a/src/ButtonPanels.tsx b/src/ButtonPanels.tsx index 5cb79892..f687ba23 100644 --- a/src/ButtonPanels.tsx +++ b/src/ButtonPanels.tsx @@ -9,7 +9,9 @@ import { type CopyType, type NodeData, type CustomButtonDefinition, + type KeyboardControlsFull, } from './types' +import { getModifier } from './helpers' interface EditButtonProps { startEdit?: () => void @@ -20,6 +22,11 @@ interface EditButtonProps { nodeData: NodeData translate: TranslateFunction customButtons: CustomButtonDefinition[] + keyboardControls: KeyboardControlsFull + handleKeyboard: ( + e: React.KeyboardEvent, + eventMap: Partial void>> + ) => void } export const EditButtons: React.FC = ({ @@ -31,6 +38,8 @@ export const EditButtons: React.FC = ({ customButtons, nodeData, translate, + keyboardControls, + handleKeyboard, }) => { const { getStyles } = useTheme() const NEW_KEY_PROMPT = translate('KEY_NEW', nodeData) @@ -40,14 +49,19 @@ export const EditButtons: React.FC = ({ const { key, path, value: data } = nodeData const handleKeyPress = (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && handleAdd) { - setIsAdding(false) - handleAdd(newKey) - setNewKey(NEW_KEY_PROMPT) - } else if (e.key === 'Escape') { - setIsAdding(false) - setNewKey(NEW_KEY_PROMPT) - } + handleKeyboard(e, { + stringConfirm: () => { + if (handleAdd) { + setIsAdding(false) + handleAdd(newKey) + setNewKey(NEW_KEY_PROMPT) + } + }, + cancel: () => { + setIsAdding(false) + setNewKey(NEW_KEY_PROMPT) + }, + }) } const handleCopy = (e: React.MouseEvent) => { @@ -56,15 +70,14 @@ export const EditButtons: React.FC = ({ let value let stringValue = '' if (enableClipboard) { - switch (e.ctrlKey || e.metaKey) { - case true: - value = stringifyPath(path) - stringValue = value - copyType = 'path' - break - default: - value = data - stringValue = type ? JSON.stringify(data, null, 2) : String(value) + const modifier = getModifier(e) + if (modifier && keyboardControls.clipboardModifier.includes(modifier)) { + value = stringifyPath(path) + stringValue = value + copyType = 'path' + } else { + value = data + stringValue = type ? JSON.stringify(data, null, 2) : String(value) } void navigator.clipboard.writeText(stringValue) } diff --git a/src/CollectionNode.tsx b/src/CollectionNode.tsx index 2c4c9809..6413f4a7 100644 --- a/src/CollectionNode.tsx +++ b/src/CollectionNode.tsx @@ -5,7 +5,7 @@ import { EditButtons, InputButtons } from './ButtonPanels' import { getCustomNode } from './CustomNode' import { type CollectionNodeProps, type NodeData, type CollectionData } from './types' import { Icon } from './Icons' -import { filterNode, isCollection } from './helpers' +import { filterNode, getModifier, isCollection } from './helpers' import { AutogrowTextArea } from './AutogrowTextArea' import { useTheme } from './theme' import { useTreeState } from './TreeStateProvider' @@ -44,6 +44,8 @@ export const CollectionNode: React.FC = (props) => { customNodeDefinitions, jsonParse, jsonStringify, + keyboardControls, + handleKeyboard, } = props const [stringifiedValue, setStringifiedValue] = useState(jsonStringify(data)) @@ -126,13 +128,15 @@ export const CollectionNode: React.FC = (props) => { const brackets = collectionType === 'array' ? { open: '[', close: ']' } : { open: '{', close: '}' } - const handleKeyPress = (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && (e.metaKey || e.shiftKey || e.ctrlKey)) handleEdit() - else if (e.key === 'Escape') handleCancel() - } + const handleKeyPressEdit = (e: React.KeyboardEvent) => + handleKeyboard(e, { + objectConfirm: handleEdit, + cancel: handleCancel, + }) const handleCollapse = (e: React.MouseEvent) => { - if (e.getModifierState('Alt')) { + const modifier = getModifier(e) + if (modifier && keyboardControls.collapseModifier.includes(modifier)) { hasBeenOpened.current = true setCollapseState({ collapsed: !collapsed, path }) return @@ -163,11 +167,6 @@ export const CollectionNode: React.FC = (props) => { } } - const handleKeyPressKeyEdit = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') handleEditKey((e.target as HTMLInputElement).value) - else if (e.key === 'Escape') handleCancel() - } - const handleAdd = (key: string) => { animateCollapse(false) const newValue = getDefaultNewValue(nodeData, key) @@ -276,7 +275,7 @@ export const CollectionNode: React.FC = (props) => { value={stringifiedValue} setValue={setStringifiedValue} isEditing={isEditing} - handleKeyPress={handleKeyPress} + handleKeyPress={handleKeyPressEdit} styles={getStyles('input', nodeData)} />
@@ -302,7 +301,7 @@ export const CollectionNode: React.FC = (props) => { setValue: async (val: unknown) => await onEdit(val, path), handleEdit, handleCancel, - handleKeyPress, + handleKeyPress: handleKeyPressEdit, isEditing, setIsEditing: () => setCurrentlyEditingElement(pathString), getStyles, @@ -325,7 +324,12 @@ export const CollectionNode: React.FC = (props) => { defaultValue={name} autoFocus onFocus={(e) => e.target.select()} - onKeyDown={handleKeyPressKeyEdit} + onKeyDown={(e) => + handleKeyboard(e, { + stringConfirm: () => handleEditKey((e.target as HTMLInputElement).value), + cancel: handleCancel, + }) + } style={{ width: `${String(name).length / 1.5 + 0.5}em` }} /> ) : ( @@ -364,6 +368,8 @@ export const CollectionNode: React.FC = (props) => { nodeData={nodeData} translate={translate} customButtons={props.customButtons} + keyboardControls={keyboardControls} + handleKeyboard={handleKeyboard} /> ) diff --git a/src/JsonEditor.tsx b/src/JsonEditor.tsx index 313dbd63..2cb283bf 100644 --- a/src/JsonEditor.tsx +++ b/src/JsonEditor.tsx @@ -2,7 +2,13 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react' import assign, { type Input } from 'object-property-assigner' import extract from 'object-property-extractor' import { CollectionNode } from './CollectionNode' -import { isCollection, matchNode, matchNodeKey } from './helpers' +import { + getFullKeyboardControlMap, + handleKeyPress, + isCollection, + matchNode, + matchNodeKey, +} from './helpers' import { type CollectionData, type JsonEditorProps, @@ -15,6 +21,7 @@ import { type UpdateFunction, type UpdateFunctionProps, type JsonData, + type KeyboardControls, } from './types' import { useTheme, ThemeProvider } from './theme' import { TreeStateProvider } from './TreeStateProvider' @@ -64,6 +71,7 @@ const Editor: React.FC = ({ jsonParse = JSON.parse, jsonStringify = (data: JsonData) => JSON.stringify(data, null, 2), errorMessageTimeout = 2500, + keyboardControls = {}, }) => { const { getStyles } = useTheme() const collapseFilter = useCallback(getFilterFunction(collapse), [collapse]) @@ -248,6 +256,17 @@ const Editor: React.FC = ({ const restrictDragFilter = useMemo(() => getFilterFunction(restrictDrag), [restrictDrag]) const searchFilter = useMemo(() => getSearchFilter(searchFilterInput), [searchFilterInput]) + const fullKeyboardControls = useMemo( + () => getFullKeyboardControlMap(keyboardControls), + [keyboardControls] + ) + + const handleKeyboardCallback = useCallback( + (e: React.KeyboardEvent, eventMap: Partial void>>) => + handleKeyPress(fullKeyboardControls, eventMap, e), + [keyboardControls] + ) + const otherProps = { name: rootName, nodeData, @@ -283,6 +302,8 @@ const Editor: React.FC = ({ jsonParse, jsonStringify, errorMessageTimeout, + handleKeyboard: handleKeyboardCallback, + keyboardControls: fullKeyboardControls, } const mainContainerStyles = { ...getStyles('container', nodeData), minWidth, maxWidth } diff --git a/src/ValueNodeWrapper.tsx b/src/ValueNodeWrapper.tsx index 21f0e170..a58df5ee 100644 --- a/src/ValueNodeWrapper.tsx +++ b/src/ValueNodeWrapper.tsx @@ -42,6 +42,8 @@ export const ValueNodeWrapper: React.FC = (props) => { indent, translate, customNodeDefinitions, + handleKeyboard, + keyboardControls, } = props const { getStyles } = useTheme() const { setCurrentlyEditingElement, setCollapseState } = useTreeState() @@ -178,11 +180,6 @@ export const ValueNodeWrapper: React.FC = (props) => { }) } - const handleKeyPress = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') handleEditKey((e.target as HTMLInputElement).value) - else if (e.key === 'Escape') handleCancel() - } - const handleCancel = () => { setCurrentlyEditingElement(null) setValue(data) @@ -217,6 +214,7 @@ export const ValueNodeWrapper: React.FC = (props) => { showStringQuotes, nodeData, translate, + handleKeyboard, } const ValueComponent = showCustomNode ? ( @@ -227,10 +225,9 @@ export const ValueNodeWrapper: React.FC = (props) => { setValue={updateValue} handleEdit={handleEdit} handleCancel={handleCancel} - handleKeyPress={(e: React.KeyboardEvent) => { - if (e.key === 'Enter') handleEdit() - else if (e.key === 'Escape') handleCancel() - }} + handleKeyPress={(e: React.KeyboardEvent) => + handleKeyboard(e, { stringConfirm: handleEdit, cancel: handleCancel }) + } isEditing={isEditing} setIsEditing={() => setCurrentlyEditingElement(pathString)} getStyles={getStyles} @@ -287,7 +284,12 @@ export const ValueNodeWrapper: React.FC = (props) => { defaultValue={name} autoFocus onFocus={(e) => e.target.select()} - onKeyDown={handleKeyPress} + onKeyDown={(e: React.KeyboardEvent) => + handleKeyboard(e, { + stringConfirm: () => handleEditKey((e.target as HTMLInputElement).value), + cancel: handleCancel, + }) + } style={{ width: `${String(name).length / 1.5 + 0.5}em` }} /> )} @@ -304,6 +306,8 @@ export const ValueNodeWrapper: React.FC = (props) => { translate={translate} customButtons={props.customButtons} nodeData={nodeData} + handleKeyboard={handleKeyboard} + keyboardControls={keyboardControls} /> ) )} diff --git a/src/ValueNodes.tsx b/src/ValueNodes.tsx index c4603c4c..9338f61c 100644 --- a/src/ValueNodes.tsx +++ b/src/ValueNodes.tsx @@ -17,12 +17,10 @@ export const StringValue: React.FC = ({ stringTruncate, showStringQuotes, nodeData, + handleKeyboard, }) => { const { getStyles } = useTheme() - const handleKeyPress = (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && !e.shiftKey) handleEdit() - else if (e.key === 'Escape') handleCancel() - } + const pathString = toPathString(path) const quoteChar = showStringQuotes ? '"' : '' @@ -34,7 +32,29 @@ export const StringValue: React.FC = ({ value={value} setValue={setValue as React.Dispatch>} isEditing={isEditing} - handleKeyPress={handleKeyPress} + handleKeyPress={(e) => { + handleKeyboard(e, { + stringConfirm: handleEdit, + cancel: handleCancel, + stringLineBreak: () => { + const textArea = document.getElementById( + `${pathString}_textarea` + ) as HTMLTextAreaElement + if (textArea) { + // Simulates standard text-area line break behaviour. Only + // required when control key is not "standard" text-area + // behaviour ("Shift-Enter" or "Enter") + const startPos: number = textArea?.selectionStart ?? Infinity + const endPos: number = textArea?.selectionEnd ?? Infinity + const strStart = value.slice(0, startPos) + const strEnd = value.slice(endPos) + ;(e.target as HTMLInputElement).value = strStart + '\n' + strEnd + textArea.setSelectionRange(startPos + 1, startPos + 1) + setValue(strStart + '\n' + strEnd) + } + }, + }) + }} styles={getStyles('input', nodeData)} /> ) : ( @@ -63,25 +83,9 @@ export const NumberValue: React.FC = ({ handleEdit, handleCancel, nodeData, + handleKeyboard, }) => { const { getStyles } = useTheme() - const handleKeyPress = (e: React.KeyboardEvent) => { - switch (e.key) { - case 'Enter': - handleEdit() - break - case 'Escape': - handleCancel() - break - case 'ArrowUp': - e.preventDefault() - setValue(Number(value) + 1) - break - case 'ArrowDown': - e.preventDefault() - setValue(Number(value) - 1) - } - } const validateNumber = (input: string) => { return input.replace(/[^0-9.-]/g, '') @@ -96,7 +100,14 @@ export const NumberValue: React.FC = ({ onChange={(e) => setValue(validateNumber(e.target.value))} autoFocus onFocus={(e) => e.target.select()} - onKeyDown={handleKeyPress} + onKeyDown={(e) => + handleKeyboard(e, { + numberConfirm: handleEdit, + cancel: handleCancel, + numberUp: () => setValue(Number(value) + 1), + numberDown: () => setValue(Number(value) - 1), + }) + } style={{ width: `${String(value).length / 1.5 + 2}em`, ...getStyles('input', nodeData) }} /> ) : ( @@ -119,14 +130,10 @@ export const BooleanValue: React.FC = ({ handleEdit, handleCancel, nodeData, + handleKeyboard, }) => { const { getStyles } = useTheme() - const handleKeyPress = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') handleEdit() - else if (e.key === 'Escape') handleCancel() - } - return isEditing ? ( = ({ name={toPathString(path)} checked={value} onChange={() => setValue(!value)} - onKeyDown={handleKeyPress} + onKeyDown={(e) => + handleKeyboard(e, { + booleanConfirm: handleEdit, + cancel: handleCancel, + }) + } autoFocus /> ) : ( @@ -155,6 +167,7 @@ export const NullValue: React.FC = ({ handleEdit, handleCancel, nodeData, + handleKeyboard, }) => { const { getStyles } = useTheme() @@ -163,11 +176,8 @@ export const NullValue: React.FC = ({ return () => document.removeEventListener('keydown', listenForSubmit) }, [isEditing]) - const listenForSubmit = (event: any) => { - if (event.key === 'Enter') { - handleEdit() - } else if (event.key === 'Escape') handleCancel() - } + const listenForSubmit = (e: unknown) => + handleKeyboard(e as React.KeyboardEvent, { confirm: handleEdit, cancel: handleCancel }) return (
| unknown[] => value !== null && typeof value === 'object' @@ -117,3 +124,118 @@ export const toPathString = (path: Array) => // non-printable char .map((part) => (part === '' ? String.fromCharCode(0) : part)) .join('.') + +/** + * KEYBOARD INTERACTION + */ + +// A general keyboard handler. Matches keyboard events against the predefined +// keyboard controls (defaults, or user-defined), and maps them to specific +// actions, provided via the "eventMap" +export const handleKeyPress = ( + controls: KeyboardControlsFull, + eventMap: Partial void>>, + e: React.KeyboardEvent +) => { + const definitions = Object.entries(eventMap) + + for (const [definition, action] of definitions) { + if (eventMatch(e, controls[definition as keyof KeyboardControlsFull], definition)) { + e.preventDefault() + action() + break + } + } +} + +// Returns the currently pressed modifier key. Only returns one, so the first +// match in the list is returned +export const getModifier = ( + e: React.KeyboardEvent | React.MouseEvent +): React.ModifierKey | undefined => { + if (e.shiftKey) return 'Shift' + if (e.metaKey) return 'Meta' + if (e.ctrlKey) return 'Control' + if (e.altKey) return 'Alt' + return undefined +} + +// Determines whether a keyboard event matches a predefined value +const eventMatch = ( + e: React.KeyboardEvent, + keyEvent: KeyEvent | React.ModifierKey[], + definition: string +) => { + const eventKey = e.key + const eventModifier = getModifier(e) + if (Array.isArray(keyEvent)) return eventModifier ? keyEvent.includes(eventModifier) : false + const { key, modifier } = keyEvent + + if ( + // If the stringLineBreak control is the default (Shift-Enter), don't do + // anything, just let normal text-area behaviour occur. This allows normal + // "Undo" behaviour for the text area to continue as normal + definition === 'stringLineBreak' && + eventKey === 'Enter' && + eventModifier === 'Shift' && + key === 'Enter' && + modifier?.includes('Shift') + ) + return false + + return ( + eventKey === key && + (modifier === eventModifier || + (Array.isArray(modifier) && modifier.includes(eventModifier as React.ModifierKey))) + ) +} + +const ENTER = { key: 'Enter' } + +const defaultKeyboardControls: KeyboardControlsFull = { + confirm: ENTER, // default for all Value nodes, and key entry + cancel: { key: 'Escape' }, + objectConfirm: { ...ENTER, modifier: ['Meta', 'Shift', 'Control'] }, + objectLineBreak: ENTER, + stringConfirm: ENTER, + stringLineBreak: { ...ENTER, modifier: ['Shift'] }, + numberConfirm: ENTER, + numberUp: { key: 'ArrowUp' }, + numberDown: { key: 'ArrowDown' }, + booleanConfirm: ENTER, + clipboardModifier: ['Meta', 'Control'], + collapseModifier: ['Alt'], +} + +export const getFullKeyboardControlMap = (userControls: KeyboardControls): KeyboardControlsFull => { + const controls = { ...defaultKeyboardControls } + for (const key of Object.keys(defaultKeyboardControls)) { + const typedKey = key as keyof KeyboardControls + if (userControls[typedKey]) { + const value = userControls[typedKey] + + const definition = (() => { + if (['clipboardModifier', 'collapseModifier'].includes(key)) + return Array.isArray(value) ? value : [value] + if (typeof value === 'string') return { key: value } + return value + })() as KeyEvent & React.ModifierKey[] + + controls[typedKey] = definition + + // Set value node defaults to generic "confirm" if not specifically + // defined. + const fallbackKeys: Array = [ + 'stringConfirm', + 'numberConfirm', + 'booleanConfirm', + ] + fallbackKeys.forEach((key) => { + if (!userControls[key] && userControls.confirm) + controls[key] = controls.confirm as KeyEvent & React.ModifierKey[] + }) + } + } + + return controls +} diff --git a/src/index.ts b/src/index.ts index 9d22d7ca..5e8e3969 100644 --- a/src/index.ts +++ b/src/index.ts @@ -31,5 +31,6 @@ export { type ThemeStyles, type NodeData, type JsonData, + type KeyboardControls, } from './types' export { type LocalisedStrings, type TranslateFunction } from './localisation' diff --git a/src/types.ts b/src/types.ts index 0f33f79e..2b39c1d3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -45,6 +45,7 @@ export interface JsonEditorProps { jsonParse?: (input: string) => JsonData jsonStringify?: (input: JsonData) => string errorMessageTimeout?: number // ms + keyboardControls?: KeyboardControls } const ValueDataTypes = ['string', 'number', 'boolean', 'null'] as const @@ -150,6 +151,33 @@ export type InternalMoveFunction = ( position: Position ) => Promise +export interface KeyEvent { + key: string + modifier?: React.ModifierKey | React.ModifierKey[] +} +export interface KeyboardControls { + confirm?: KeyEvent | string // value node defaults, key entry + cancel?: KeyEvent | string // all "Cancel" operations + objectConfirm?: KeyEvent | string + objectLineBreak?: KeyEvent | string + stringConfirm?: KeyEvent | string + stringLineBreak?: KeyEvent | string // for Value nodes + booleanConfirm?: KeyEvent | string + numberConfirm?: KeyEvent | string + numberUp?: KeyEvent | string + numberDown?: KeyEvent | string + clipboardModifier?: React.ModifierKey | React.ModifierKey[] + collapseModifier?: React.ModifierKey | React.ModifierKey[] +} + +export type KeyboardControlsFull = Omit< + Required<{ [Property in keyof KeyboardControls]: KeyEvent }>, + 'clipboardModifier' | 'collapseModifier' +> & { + clipboardModifier: React.ModifierKey[] + collapseModifier: React.ModifierKey[] +} + /** * NODES */ @@ -189,6 +217,11 @@ interface BaseNodeProps { customNodeDefinitions: CustomNodeDefinition[] customButtons: CustomButtonDefinition[] errorMessageTimeout: number + keyboardControls: KeyboardControlsFull + handleKeyboard: ( + e: React.KeyboardEvent, + eventMap: Partial void>> + ) => void } export interface CollectionNodeProps extends BaseNodeProps { @@ -263,6 +296,10 @@ export interface InputProps { showStringQuotes: boolean nodeData: NodeData translate: TranslateFunction + handleKeyboard: ( + e: React.KeyboardEvent, + eventMap: Partial void>> + ) => void } /**