Skip to content

Commit 9de6b37

Browse files
authored
#157 Custom Text Editor (#161)
1 parent faf7be4 commit 9de6b37

15 files changed

+375
-9
lines changed

README.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ A [React](https://github.com/facebook/react) component for editing or viewing JS
5151
- [Examples](#examples-1)
5252
- [JSON Schema validation](#json-schema-validation)
5353
- [Drag-n-drop](#drag-n-drop)
54+
- [Full object editing](#full-object-editing)
5455
- [Search/Filtering](#searchfiltering)
5556
- [Themes \& Styles](#themes--styles)
5657
- [Fragments](#fragments)
@@ -157,13 +158,14 @@ The only *required* value is `data` (although you will need to provide a `setDat
157158
| `customButtons` | `CustomButtonDefinition[]` | `[]` | You can add your own buttons to the Edit Buttons panel if you'd like to be able to perform a custom operation on the data. See [Custom Buttons](#custom-buttons) |
158159
| `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/) |
159160
| `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. |
161+
| `TextEditor` | `ReactComponent<TextEditorProps>` | | Pass a component to offer a custom text/code editor when editing full JSON object as text. [See details](#full-object-editing) |
160162
| `errorMessageTimeout` | `number` | `2500` | Time (in milliseconds) to display the error message in the UI. | |
161163
| `keyboardControls` | `KeyboardControls` | As explained [above](#usage) | Override some or all of the keyboard controls. See [Keyboard customisation](#keyboard-customisation) for details. | |
162164
| `insertAtTop` | `boolean\| "object \| "array"` | `false` | If `true`, inserts new values at the *top* rather than bottom. Can set the behaviour just for arrays or objects by setting to `"object"` or `"array"` respectively. | |
163165

164166
## Managing state
165167

166-
It is recommended that you manage the `data` state yourself outside this component -- just pass in a `setData` method, which is called internally to update your `data`. However, this is not compulsory -- if you don't provide a `setData` method, the data will be managed internally, which would be fine if you're not doing anything with the data. The alternative is to use the [Update functions](#update-functions) to update your `data` externally, but this is not recommended except in special circumstances as you can run into issues keeping your data in sync with the internal state (which is what is displayed), as well as unnecessary re-renders. Update functions should be ideally be used only for implementing side effects, checking for errors, or mutating the data before setting it with `setData`.
168+
It is recommended that you manage the `data` state yourself outside this component -- just pass in a `setData` method, which is called internally to update your `data`. However, this is not compulsory -- if you don't provide a `setData` method, the data will be managed internally, which would be fine if you're not doing anything with the data. The alternative is to use the [Update functions](#update-functions) to update your `data` externally, but this is not recommended except in special circumstances as you can run into issues keeping your data in sync with the internal state (which is what is displayed), as well as unnecessary re-renders. Update functions should be ideally be used only for implementing side effects (e.g. notifications), validation, or mutating the data before setting it with `setData`.
167169

168170
## Update functions
169171

@@ -396,6 +398,24 @@ The `restrictDrag` property controls which items (if any) can be dragged into ne
396398
- To be draggable, the node must *also* be delete-able (via the `restrictDelete` prop), as dragging a node to a new destination is essentially just deleting it and adding it back elsewhere.
397399
- Similarly, the destination collection must be editable in order to drop it in there. This means that, if you've gone to the trouble of configuring restrictive editing constraints using Filter functions, you can be confident that they can't be circumvented via drag-n-drop.
398400
401+
## Full object editing
402+
403+
The user can edit the entire JSON object (or a sub-node) as raw text (provided you haven't restricted it using a [`restrictEdit` function](#filter-functions)). By default, we just display a native HTML [textarea](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/textarea) element for plain-text editing. However, you can offer a more sophisticated text/code editor by passing the component into the `TextEditor` prop. Your component must provide the following props for json-edit-react to use:
404+
405+
- `value: string` // the current text
406+
- `onChange: (value: string) => void` // should be called on every keystroke to update `value`
407+
- `onKeyDown: (e: React.KeyboardEvent) => void` // should be called on every keystroke to detect "Accept"/"Cancel" keys
408+
409+
You can see an example in the [demo](https://carlosnz.github.io/json-edit-react/) where I have implemented [**CodeMirror**](https://codemirror.net/) when the "Custom Text Editor" option is checked. It changes the native editor (on the left) into the one shown on the right:
410+
411+
<img height="350" alt="Plain text editor" src="image/text_edit-normal.png">
412+
<img height="350" alt="Plain text editor" src="image/text_edit-new.png">
413+
414+
See the codebase for the exact implementation details:
415+
416+
- [Simple component that wraps CodeMirror](https://github.com/CarlosNZ/json-edit-react/blob/157-custom-text-editor/demo/src/CodeEditor.tsx)
417+
- [Prop passed to json-edit-react](https://github.com/CarlosNZ/json-edit-react/blob/6e3d21d20750b4a6519eea1f472be9a2a41b8a7c/demo/src/App.tsx#L441-L454)
418+
399419
## Search/Filtering
400420
401421
The displayed data can be filtered based on search input from a user. The user input should be captured independently (we don't provide a UI here) and passed in with the `searchText` prop. This input is debounced internally (time can be set with the `searchDebounceTime` prop), so no need for that as well. The values that the `searchText` are tested against is specified with the `searchFilter` prop. By default (no `searchFilter` defined), it will match against the data *values* (with case-insensitive partial matching -- i.e. input "Ilb", will match value "Bilbo").

demo/package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"dependencies": {
77
"@chakra-ui/icons": "^2.1.1",
88
"@chakra-ui/react": "^2.8.2",
9+
"@codemirror/lang-json": "^6.0.1",
910
"@emotion/react": "^11.11.3",
1011
"@emotion/styled": "^11.11.0",
1112
"@testing-library/jest-dom": "^6.3.0",
@@ -15,6 +16,11 @@
1516
"@types/node": "^20.11.6",
1617
"@types/react": "^18.2.48",
1718
"@types/react-dom": "^18.2.18",
19+
"@uiw/codemirror-theme-console": "^4.23.7",
20+
"@uiw/codemirror-theme-github": "^4.23.7",
21+
"@uiw/codemirror-theme-monokai": "^4.23.7",
22+
"@uiw/codemirror-theme-quietlight": "^4.23.7",
23+
"@uiw/react-codemirror": "^4.23.7",
1824
"ajv": "^8.16.0",
1925
"firebase": "^10.13.0",
2026
"framer-motion": "^11.0.3",

demo/src/App.tsx

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useEffect, useRef } from 'react'
1+
import React, { useEffect, useRef, lazy, Suspense } from 'react'
22
import { useSearch, useLocation } from 'wouter'
33
import JSON5 from 'json5'
44
import 'react-datepicker/dist/react-datepicker.css'
@@ -45,14 +45,17 @@ import {
4545
NumberIncrementStepper,
4646
NumberDecrementStepper,
4747
useToast,
48+
Tooltip,
4849
} from '@chakra-ui/react'
4950
import logo from './image/logo_400.png'
50-
import { ArrowBackIcon, ArrowForwardIcon } from '@chakra-ui/icons'
51+
import { ArrowBackIcon, ArrowForwardIcon, InfoIcon } from '@chakra-ui/icons'
5152
import { demoDataDefinitions } from './demoData'
5253
import { useDatabase } from './useDatabase'
5354
import './style.css'
5455
import { version } from './version'
5556

57+
const CodeEditor = lazy(() => import('./CodeEditor'))
58+
5659
interface AppState {
5760
rootName: string
5861
indent: number
@@ -69,6 +72,7 @@ interface AppState {
6972
showStringQuotes: boolean
7073
defaultNewValue: string
7174
searchText: string
75+
customTextEditor: boolean
7276
}
7377

7478
const themes = [
@@ -104,6 +108,7 @@ function App() {
104108
showStringQuotes: true,
105109
defaultNewValue: 'New data!',
106110
searchText: '',
111+
customTextEditor: false,
107112
})
108113

109114
const [isSaving, setIsSaving] = useState(false)
@@ -144,6 +149,7 @@ function App() {
144149
allowEdit,
145150
allowDelete,
146151
allowAdd,
152+
customTextEditor,
147153
} = state
148154

149155
const restrictEdit: FilterFunction | boolean = (() => {
@@ -178,6 +184,7 @@ function App() {
178184
searchText: '',
179185
collapseLevel: newDataDefinition.collapse ?? state.collapseLevel,
180186
rootName: newDataDefinition.rootName ?? 'data',
187+
customTextEditor: false,
181188
})
182189

183190
switch (selected) {
@@ -431,6 +438,21 @@ function App() {
431438
// }}
432439
// insertAtBeginning="object"
433440
// rootFontSize={20}
441+
TextEditor={
442+
customTextEditor
443+
? (props) => (
444+
<Suspense
445+
fallback={
446+
<div className="loading" style={{ height: `${getLineHeight(data)}lh` }}>
447+
Loading code editor...
448+
</div>
449+
}
450+
>
451+
<CodeEditor {...props} theme={theme?.displayName ?? ''} />
452+
</Suspense>
453+
)
454+
: undefined
455+
}
434456
/>
435457
</Box>
436458
<VStack w="100%" align="flex-end" gap={4}>
@@ -666,6 +688,19 @@ function App() {
666688
>
667689
Sort Object keys
668690
</Checkbox>
691+
<HStack>
692+
<Checkbox
693+
id="customEditorCheckbox"
694+
isChecked={customTextEditor}
695+
onChange={() => toggleState('customTextEditor')}
696+
disabled={!dataDefinition.customTextEditorAvailable}
697+
>
698+
Custom Text Editor
699+
</Checkbox>
700+
<Tooltip label="When in full JSON object edit">
701+
<InfoIcon color="primaryScheme.500" />
702+
</Tooltip>
703+
</HStack>
669704
</Flex>
670705
<HStack className="inputRow" pt={2}>
671706
<FormLabel className="labelWidth" textAlign="right">
@@ -700,3 +735,5 @@ export default App
700735

701736
export const truncate = (string: string, length = 200) =>
702737
string.length < length ? string : `${string.slice(0, length - 2).trim()}...`
738+
739+
const getLineHeight = (data: JsonData) => JSON.stringify(data, null, 2).split('\n').length

demo/src/CodeEditor.tsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import React from 'react'
2+
import CodeMirror from '@uiw/react-codemirror'
3+
import { json } from '@codemirror/lang-json'
4+
import { TextEditorProps } from './_imports'
5+
import { githubLight, githubDark } from '@uiw/codemirror-theme-github'
6+
import { consoleDark } from '@uiw/codemirror-theme-console/dark'
7+
import { consoleLight } from '@uiw/codemirror-theme-console/light'
8+
import { quietlight } from '@uiw/codemirror-theme-quietlight'
9+
import { monokai } from '@uiw/codemirror-theme-monokai'
10+
11+
const themeMap = {
12+
Default: undefined,
13+
'Github Light': githubLight,
14+
'Github Dark': githubDark,
15+
'White & Black': consoleLight,
16+
'Black & White': consoleDark,
17+
'Candy Wrapper': quietlight,
18+
Psychedelic: monokai,
19+
}
20+
21+
const CodeEditor: React.FC<TextEditorProps & { theme: string }> = ({
22+
value,
23+
onChange,
24+
onKeyDown,
25+
theme,
26+
}) => {
27+
return (
28+
<CodeMirror
29+
theme={themeMap?.[theme]}
30+
value={value}
31+
width="100%"
32+
extensions={[json()]}
33+
onChange={onChange}
34+
onKeyDown={onKeyDown}
35+
/>
36+
)
37+
}
38+
39+
export default CodeEditor

demo/src/_imports.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@
33
*/
44

55
/* Installed package */
6-
export * from 'json-edit-react'
6+
// export * from 'json-edit-react'
77

88
/* Local src */
9-
// export * from './json-edit-react/src'
9+
export * from './json-edit-react/src'
1010

1111
/* Compiled local package */
1212
// export * from './package/build'

demo/src/demoData/dataDefinitions.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ export interface DemoData {
5555
customNodeDefinitions?: CustomNodeDefinition[]
5656
customTextDefinitions?: CustomTextDefinitions
5757
styles?: Partial<ThemeStyles>
58+
customTextEditorAvailable?: boolean
5859
}
5960

6061
export const demoDataDefinitions: Record<string, DemoData> = {
@@ -91,6 +92,8 @@ export const demoDataDefinitions: Record<string, DemoData> = {
9192
collapse: 2,
9293
data: data.intro,
9394
customNodeDefinitions: [dateNodeDefinition],
95+
// restrictEdit: ({ key }) => key === 'number',
96+
customTextEditorAvailable: true,
9497
},
9598
starWars: {
9699
name: '🚀 Star Wars',
@@ -279,6 +282,7 @@ export const demoDataDefinitions: Record<string, DemoData> = {
279282
return 'JSON Schema error'
280283
}
281284
},
285+
customTextEditorAvailable: true,
282286
},
283287
liveData: {
284288
name: '📖 Live Data (from database)',
@@ -440,6 +444,7 @@ export const demoDataDefinitions: Record<string, DemoData> = {
440444
searchFilter: 'key',
441445
searchPlaceholder: 'Search Theme keys',
442446
data: {},
447+
customTextEditorAvailable: true,
443448
},
444449
customNodes: {
445450
name: '🔧 Custom Nodes',
@@ -625,5 +630,6 @@ export const demoDataDefinitions: Record<string, DemoData> = {
625630
styles: {
626631
string: ({ key }) => (key === 'name' ? { fontWeight: 'bold', fontSize: '120%' } : null),
627632
},
633+
customTextEditorAvailable: true,
628634
},
629635
}

demo/src/style.css

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,22 @@ footer {
4444
font-weight: 600;
4545
color: dimgray;
4646
}
47+
48+
/* For CodeMirror */
49+
.cm-theme-light,
50+
.cm-theme {
51+
width: 100%;
52+
}
53+
54+
.cm-content,
55+
.cm-gutters {
56+
font-size: 80%;
57+
}
58+
59+
/* Loading component for CodeMirror */
60+
.loading {
61+
width: 100%;
62+
display: flex;
63+
align-items: center;
64+
justify-content: center;
65+
}

0 commit comments

Comments
 (0)