Skip to content

Commit d221acd

Browse files
committed
Squashed commit of the following:
commit 497f451 Author: Carl Smith <5456533+CarlosNZ@users.noreply.github.com> Date: Wed Jul 24 15:50:54 2024 +1200 Tweaks commit 747a5ec Author: Carl Smith <5456533+CarlosNZ@users.noreply.github.com> Date: Wed Jul 24 15:46:52 2024 +1200 Tidy up commit 104b2b6 Author: Carl Smith <5456533+CarlosNZ@users.noreply.github.com> Date: Wed Jul 24 10:56:37 2024 +1200 Consolidate into working hook commit b989b20 Merge: bad373b 138ae5d Author: Carl Smith <5456533+CarlosNZ@users.noreply.github.com> Date: Wed Jul 24 08:45:34 2024 +1200 Merge branch 'main' into 96B-refactor-animation commit bad373b Author: Carl Smith <5456533+CarlosNZ@users.noreply.github.com> Date: Tue Jul 23 23:10:05 2024 +1200 WIP commit 2eb4311 Author: Carl Smith <5456533+CarlosNZ@users.noreply.github.com> Date: Tue Jul 23 22:18:05 2024 +1200 WIP
1 parent 138ae5d commit d221acd

File tree

8 files changed

+152
-43
lines changed

8 files changed

+152
-43
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ The only *required* value is `data` (although you will need to provide a `setDat
112112
| `enableClipboard` | `boolean\|CopyFunction` | `true` | Whether or not to enable the "Copy to clipboard" button in the UI. If a function is provided, `true` is assumed and this function will be run whenever an item is copied. |
113113
| `indent` | `number` | `3` | Specify the amount of indentation for each level of nesting in the displayed data. |
114114
| `collapse` | `boolean\|number\|FilterFunction` | `false` | Defines which nodes of the JSON tree will be displayed "opened" in the UI on load. If `boolean`, it'll be either all or none. A `number` specifies a nesting depth after which nodes will be closed. For more fine control a function can be provided — see [Filter functions](#filter-functions). |
115-
| `collapseAnimationTime` | `number` | `500` | Time (in milliseconds) for the transition animation when collapsing collection nodes. |
115+
| `collapseAnimationTime` | `number` | `300` | Time (in milliseconds) for the transition animation when collapsing collection nodes. |
116116
| `restrictEdit` | `boolean\|FilterFunction` | `false` | If `true`, no editing is permitted. A function can be provided for more specificity — see [Filter functions](#filter-functions) |
117117
| `restrictDelete` | `boolean\|FilterFunction` | `false` | As with `restrictEdit` but for deletion |
118118
| `restrictAdd` | `boolean\|FilterFunction` | `false` | As with `restrictEdit` but for adding new properties |

demo/src/App.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ function App() {
7676
rootName: dataDefinition.rootName ?? 'data',
7777
indent: 3,
7878
collapseLevel: dataDefinition.collapse ?? 2,
79-
collapseTime: 500,
79+
collapseTime: 300,
8080
showCount: 'When closed',
8181
theme: 'default',
8282
allowEdit: true,

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'

src/CollectionNode.tsx

Lines changed: 16 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import './style.css'
1010
import { AutogrowTextArea } from './AutogrowTextArea'
1111
import { useTheme } from './theme'
1212
import { useTreeState } from './TreeStateProvider'
13-
import { useCommon, useDragNDrop } from './hooks'
13+
import { useCollapseTransition, useCommon, useDragNDrop } from './hooks'
1414

1515
export const CollectionNode: React.FC<CollectionNodeProps> = (props) => {
1616
const { getStyles } = useTheme()
@@ -49,7 +49,12 @@ export const CollectionNode: React.FC<CollectionNodeProps> = (props) => {
4949
const [stringifiedValue, setStringifiedValue] = useState(jsonStringify(data))
5050

5151
const startCollapsed = collapseFilter(incomingNodeData)
52-
const [collapsed, setCollapsed] = useState(startCollapsed)
52+
53+
const { contentRef, isAnimating, maxHeight, collapsed, animateCollapse } = useCollapseTransition(
54+
data,
55+
collapseAnimationTime,
56+
startCollapsed
57+
)
5358

5459
const {
5560
pathString,
@@ -77,24 +82,20 @@ export const CollectionNode: React.FC<CollectionNodeProps> = (props) => {
7782
// the animation transition when opening and closing the accordion
7883
const hasBeenOpened = useRef(!startCollapsed)
7984

80-
// Allows us to delay the overflow visibility of the collapsed element until
81-
// the animation has completed
82-
const [isAnimating, setIsAnimating] = useState(false)
83-
8485
useEffect(() => {
8586
setStringifiedValue(jsonStringify(data))
8687
}, [data])
8788

8889
useEffect(() => {
89-
const isCollapsed = collapseFilter(nodeData) && !derivedValues.isEditing
90-
hasBeenOpened.current = !isCollapsed
91-
setCollapsed(isCollapsed)
90+
const shouldBeCollapsed = collapseFilter(nodeData) && !derivedValues.isEditing
91+
hasBeenOpened.current = !shouldBeCollapsed
92+
animateCollapse(shouldBeCollapsed)
9293
}, [collapseFilter])
9394

9495
useEffect(() => {
9596
if (collapseState !== null && doesPathMatch(path)) {
9697
hasBeenOpened.current = true
97-
setCollapsed(collapseState.collapsed)
98+
animateCollapse(collapseState.collapsed)
9899
}
99100
}, [collapseState])
100101

@@ -126,10 +127,6 @@ export const CollectionNode: React.FC<CollectionNodeProps> = (props) => {
126127
const brackets =
127128
collectionType === 'array' ? { open: '[', close: ']' } : { open: '{', close: '}' }
128129

129-
const transitionTime = getComputedStyle(document.documentElement).getPropertyValue(
130-
'--jer-expand-transition-time'
131-
)
132-
133130
const handleKeyPress = (e: React.KeyboardEvent) => {
134131
if (e.key === 'Enter' && (e.metaKey || e.shiftKey || e.ctrlKey)) handleEdit()
135132
else if (e.key === 'Escape') handleCancel()
@@ -142,11 +139,9 @@ export const CollectionNode: React.FC<CollectionNodeProps> = (props) => {
142139
return
143140
}
144141
if (!(currentlyEditingElement && currentlyEditingElement.includes(pathString))) {
145-
setIsAnimating(true)
146142
hasBeenOpened.current = true
147-
setCollapsed(!collapsed)
148143
setCollapseState(null)
149-
setTimeout(() => setIsAnimating(false), collapseAnimationTime)
144+
animateCollapse(!collapsed)
150145
}
151146
}
152147

@@ -175,7 +170,7 @@ export const CollectionNode: React.FC<CollectionNodeProps> = (props) => {
175170
}
176171

177172
const handleAdd = (key: string) => {
178-
setCollapsed(false)
173+
animateCollapse(false)
179174
const newValue = getDefaultNewValue(nodeData)
180175
if (collectionType === 'array') {
181176
onAdd(newValue, [...path, (data as unknown[]).length]).then((error) => {
@@ -231,12 +226,6 @@ export const CollectionNode: React.FC<CollectionNodeProps> = (props) => {
231226
)
232227
}
233228

234-
// A crude measure to estimate the approximate height of the block, for
235-
// setting the max-height in the collapsible interior.
236-
// The Regexp replacement is to parse escaped line breaks *within* the JSON
237-
// into *actual* line breaks before splitting
238-
const numOfLines = JSON.stringify(data, null, 2).replace(/\\n/g, '\n').split('\n').length
239-
240229
const CollectionChildren = !hasBeenOpened.current ? null : !isEditing ? (
241230
keyValueArray.map(([key, value], index) => {
242231
const childNodeData = {
@@ -365,7 +354,6 @@ export const CollectionNode: React.FC<CollectionNodeProps> = (props) => {
365354
? () => {
366355
hasBeenOpened.current = true
367356
setCurrentlyEditingElement(pathString)
368-
setCollapsed(false)
369357
}
370358
: undefined
371359
}
@@ -437,21 +425,12 @@ export const CollectionNode: React.FC<CollectionNodeProps> = (props) => {
437425
<div
438426
className={'jer-collection-inner'}
439427
style={{
440-
// Don't limit the height when collection or any of its children are
441-
// being edited, so it won't overlap lower elements if the editing
442-
// input gets too large. This won't cause problems, as it can't be
443-
// collapsed while being edited anyway.
444-
maxHeight: isCollapsed
445-
? 0
446-
: !areChildrenBeingEdited(pathString)
447-
? `${numOfLines * 3}em`
448-
: undefined,
449428
overflowY: isCollapsed || isAnimating ? 'hidden' : 'visible',
450-
// Need to use max-height for animation to work, unfortunately
451-
// "height: auto" doesn't 😔
452-
transition: `max-height ${transitionTime}`,
429+
// Prevent collapse if this node or any children are being edited
430+
maxHeight: areChildrenBeingEdited(pathString) ? undefined : maxHeight,
453431
...getStyles('collectionInner', nodeData),
454432
}}
433+
ref={contentRef}
455434
>
456435
{CollectionContents}
457436
<div className={isEditing ? 'jer-collection-error-row' : 'jer-collection-error-row-edit'}>

src/JsonEditor.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ const Editor: React.FC<JsonEditorProps> = ({
3737
enableClipboard = true,
3838
indent = 3,
3939
collapse = false,
40-
collapseAnimationTime = 500,
40+
collapseAnimationTime = 300, // must be equivalent to CSS value
4141
showCollectionCount = true,
4242
restrictEdit = false,
4343
restrictDelete = false,

src/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from './useCommon'
22
export * from './useData'
33
export * from './useDragNDrop'
4+
export * from './useCollapseTransition'

src/hooks/useCollapseTransition.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/**
2+
* Hook to handle the logic for collapsing and expanding collection nodes, and
3+
* holding the current collapsed state of a collection node
4+
*
5+
* The main problem we need to solve is that it's not possible to use a CSS
6+
* transition for `height` when it is set to `auto`, which it needs to be in
7+
* this case as we don't know the state or size of the inner nodes.
8+
*
9+
* We can, however, set a `max-height` and, as long as the maximum is larger
10+
* than the actual height, we can transition `max-height`, a technique which is
11+
* summarised here:
12+
* https://dev.to/sarah_chima/using-css-transitions-on-the-height-property-al0
13+
*
14+
* The difficulty is choosing an appropriate value for the `max-height` -- if
15+
* it's too small, the node contents gets truncated, but if it's too large,
16+
* there is a noticeable "lag" as the invisible "unused" part of the height is
17+
* collapsed. Just setting a really high value works, but the delay is annoying.
18+
*
19+
* So we can try and get the `max-height` from the height of the node itself.
20+
* This is easy once the node has been opened -- just query the element height
21+
* (using a ref). But if the node hasn't been opened, then we have to estimate
22+
* it. I'm doing this with fairly crude method based on the number of text lines
23+
* the full content of the node would take up. This is adequate in almost all
24+
* cases, although I'm open to refining this further.
25+
*
26+
* Basically, the logic is:
27+
*
28+
* On first load:
29+
* - if closed, set max-height to 0
30+
* - if open, set no max-height (undefined) and let it automatically resize
31+
*
32+
* When collapsing an open node:
33+
* - store the current height in "prevHeight"
34+
* - set the max-height to the current height
35+
* - immediately after, set max-height to 0 and transition will occur
36+
*
37+
* When opening a closed node:
38+
* - set max-height to the previously stored height if available; otherwise use
39+
* the crudely calculated estimate.
40+
* - once transition is complete, unset `max-height` (undefined) so it can
41+
* change height automatically based on its changing contents
42+
*/
43+
44+
import { useRef, useState } from 'react'
45+
import { type JsonData } from '../types'
46+
47+
export const useCollapseTransition = (
48+
data: JsonData,
49+
collapseAnimationTime: number,
50+
startCollapsed: boolean
51+
) => {
52+
const [maxHeight, setMaxHeight] = useState<string | number | undefined>(
53+
startCollapsed ? 0 : undefined
54+
)
55+
const [collapsed, setCollapsed] = useState<boolean>(startCollapsed)
56+
57+
// Allows us to wait for animation to complete before setting the overflow
58+
// visibility of the collapsed node, and the max-height
59+
const isAnimating = useRef(false)
60+
const contentRef = useRef<HTMLDivElement>(null)
61+
const prevHeight = useRef<string | number>(0)
62+
const timerId = useRef<number>()
63+
64+
// Method to change the collapse state and manage the animated transition
65+
const animateCollapse = (collapse: boolean) => {
66+
if (collapsed === collapse) return
67+
68+
window.clearTimeout(timerId.current)
69+
isAnimating.current = true
70+
71+
switch (collapse) {
72+
case true: {
73+
// Closing...
74+
const current = contentRef.current?.offsetHeight ?? 0
75+
prevHeight.current = current
76+
setMaxHeight(current)
77+
setTimeout(() => {
78+
setMaxHeight(0)
79+
}, 5)
80+
break
81+
}
82+
case false:
83+
// Opening...
84+
setMaxHeight(prevHeight.current || estimateHeight(data, contentRef))
85+
}
86+
87+
setCollapsed(!collapsed)
88+
timerId.current = window.setTimeout(() => {
89+
isAnimating.current = false
90+
if (!collapse) setMaxHeight(undefined)
91+
}, collapseAnimationTime)
92+
}
93+
94+
return {
95+
contentRef,
96+
isAnimating,
97+
animateCollapse,
98+
maxHeight,
99+
collapsed,
100+
}
101+
}
102+
103+
// A crude measure to estimate the approximate height of the block before it has
104+
// been opened. Essentially, it estimates how many lines of text the full JSON
105+
// would take up, and converts that to a pixel value based on the current
106+
// fontSize
107+
const estimateHeight = (data: JsonData, ref: React.RefObject<HTMLDivElement>) => {
108+
const component = document.getElementsByClassName('jer-editor-container')
109+
const baseFontSize = parseInt(
110+
component.length > 0 ? getComputedStyle(component[0]).getPropertyValue('line-height') : '16px'
111+
)
112+
113+
const width = ref.current?.offsetWidth ?? 0
114+
const charsPerLine = width / (baseFontSize * 0.5)
115+
116+
const lines = JSON.stringify(data, null, 2)
117+
// The Regexp replacement is to parse escaped line breaks
118+
// *within* the JSON into *actual* line breaks before splitting
119+
.replace(/\\n/g, '\n')
120+
.split('\n')
121+
// Account for long lines being wrapped (very crudely)
122+
.map((line) => Math.ceil(line.length / charsPerLine))
123+
124+
const totalLines = lines.reduce((sum, a) => sum + a, 0)
125+
const linesInPx = totalLines * baseFontSize
126+
127+
return Math.min(linesInPx + 30, window.innerHeight - 50)
128+
}

src/style.css

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
--jer-select-arrow: #777;
55
--jer-form-border: 1px solid #ededf0;
66
--jer-form-border-focus: 1px solid #e2e2e2;
7-
--jer-expand-transition-time: 0.5s;
7+
--jer-expand-transition-time: 0.3s;
88
--jer-highlight-color: #b3d8ff;
99
}
1010

@@ -158,6 +158,7 @@ select:focus + .focus {
158158

159159
.jer-collection-inner {
160160
position: relative;
161+
transition: var(--jer-expand-transition-time);
161162
}
162163

163164
.jer-collection-text-edit {

0 commit comments

Comments
 (0)