; indexOrName: string | number | undefined; depth: number }) => void
+
+type RenderComponent = React.FC
| React.Component
+
+// Static-ish configuration + custom render components. Kept referentially stable
+// (memoized in JsonView) so memoized rows don't re-render on volatile changes.
+export interface Config {
+ collapseStringsAfterLength: number
+ collapseStringMode: 'directly' | 'word' | 'address'
+ customizeCollapseStringUI: CustomizeCollapseStringUI | undefined
+
+ enableClipboard: boolean
+ editable: Editable
+
+ displaySize: DisplaySize
+ displayArrayIndex: boolean
+
+ matchesURL: boolean
+ urlRegExp: RegExp
+
+ customizeCopy: (node: any, nodeMeta?: NodeMeta) => any
+
+ CopyComponent?: RenderComponent<{ onClick: (event: React.MouseEvent) => void; className: string }>
+ CopiedComponent?: RenderComponent<{ className: string; style: React.CSSProperties }>
+ EditComponent?: RenderComponent<{ onClick: (event: React.MouseEvent) => void; className: string }>
+ CancelComponent?: RenderComponent<{ onClick: (event: React.MouseEvent) => void; className: string; style: React.CSSProperties }>
+ DoneComponent?: RenderComponent<{ onClick: (event: React.MouseEvent) => void; className: string; style: React.CSSProperties }>
+ CustomOperation?: React.FC<{ node: any }> | React.Component<{ node: any }>
+}
+
+// Volatile: mutators + expand/edit/add/string state. Rows read the flags they
+// need as props (see Row) so this context changing does not re-render every row.
+export interface Handlers {
+ toggle: (row: FlatRow) => void
+ toggleChunk: (row: FlatRow) => void
+
+ editValue: (row: FlatRow, newValue: any) => void
+ deleteRow: (row: FlatRow) => void
+ addProperty: (row: FlatRow, name: string) => void
+ pushArrayItem: (row: FlatRow) => void
+
+ startEdit: (row: FlatRow) => void
+ cancelEdit: () => void
+
+ startDelete: (row: FlatRow) => void
+ cancelDelete: () => void
+
+ startAdd: (row: FlatRow) => void
+ cancelAdd: () => void
+
+ setStringExpanded: (key: string, expanded: boolean) => void
+ stringExpanded: (key: string) => boolean | undefined
+}
+
+export const defaultConfig: Config = {
+ collapseStringsAfterLength: 99,
+ collapseStringMode: 'directly',
+ customizeCollapseStringUI: undefined,
+ enableClipboard: true,
+ editable: false,
+ displaySize: undefined,
+ displayArrayIndex: true,
+ matchesURL: false,
+ urlRegExp: /^$/,
+ customizeCopy: () => {}
+}
+
+export const ConfigContext = createContext(defaultConfig)
+
+export const HandlersContext = createContext({} as Handlers)
diff --git a/src/components/copy-button.tsx b/src/components/copy-button.tsx
index 3b2b041..d0b21ff 100644
--- a/src/components/copy-button.tsx
+++ b/src/components/copy-button.tsx
@@ -3,7 +3,7 @@ import { ReactComponent as CopySVG } from '../svgs/copy.svg'
import { ReactComponent as CopiedSVG } from '../svgs/copied.svg'
import type { NodeMeta } from '../types'
import { writeClipboard } from '../utils'
-import { JsonViewContext } from './json-view'
+import { ConfigContext } from './contexts'
interface Props {
node: any
@@ -11,7 +11,7 @@ interface Props {
}
export default function CopyButton({ node, nodeMeta }: Props) {
- const { customizeCopy, CopyComponent, CopiedComponent } = useContext(JsonViewContext)
+ const { customizeCopy, CopyComponent, CopiedComponent } = useContext(ConfigContext)
const [copied, setCopied] = useState(false)
diff --git a/src/components/json-node.tsx b/src/components/json-node.tsx
deleted file mode 100644
index 073b6ed..0000000
--- a/src/components/json-node.tsx
+++ /dev/null
@@ -1,246 +0,0 @@
-import { useContext, useRef, useState, isValidElement, useMemo, useCallback } from 'react'
-import { JsonViewContext } from './json-view'
-import {
- customCopy,
- customDelete,
- customEdit,
- editableDelete,
- editableEdit,
- isObject,
- isReactComponent,
- safeCall,
- stringifyForCopying,
- resolveEvalFailedNewValue,
- customMatchesURL
-} from '../utils'
-import ObjectNode from './object-node'
-import LongString from './long-string'
-import CopyButton from './copy-button'
-import { ReactComponent as EditSVG } from '../svgs/edit.svg'
-import { ReactComponent as DeleteSVG } from '../svgs/trash.svg'
-import { ReactComponent as DoneSVG } from '../svgs/done.svg'
-import { ReactComponent as CancelSVG } from '../svgs/cancel.svg'
-import { ReactComponent as LinkSVG } from '../svgs/link.svg'
-import type { CustomizeNode, CustomizeOptions } from '../types'
-
-interface Props {
- node: any
- depth: number
- deleteHandle?: (indexOrName: string | number, parentPath: string[]) => void
- editHandle?: (indexOrName: string | number, newValue: any, oldValue: any, parentPath: string[]) => void
- indexOrName?: number | string
- parent?: Record | Array
- parentPath: string[]
-}
-
-export default function JsonNode({ node, depth, deleteHandle: _deleteHandle, indexOrName, parent, editHandle, parentPath }: Props) {
- // prettier-ignore
- const { collapseStringsAfterLength, enableClipboard, editable, src, onDelete, onChange, customizeNode, matchesURL, urlRegExp, EditComponent, DoneComponent, CancelComponent, CustomOperation } = useContext(JsonViewContext)
-
- let customReturn: ReturnType | undefined
- if (typeof customizeNode === 'function') customReturn = safeCall(customizeNode, [{ node, depth, indexOrName }])
-
- if (customReturn) {
- if (isValidElement(customReturn)) return customReturn
- else if (isReactComponent(customReturn)) {
- const CustomComponent = customReturn
- return
- }
- }
-
- if (Array.isArray(node) || isObject(node)) {
- return (
-
- )
- } else {
- const type = typeof node
- const currentPath = typeof indexOrName !== 'undefined' ? [...parentPath, String(indexOrName)] : parentPath
-
- const [editing, setEditing] = useState(false)
- const [deleting, setDeleting] = useState(false)
- const valueRef = useRef(null)
-
- const edit = () => {
- setEditing(true)
- setTimeout(() => {
- window.getSelection()?.selectAllChildren(valueRef.current!)
- valueRef.current?.focus()
- })
- }
-
- const done = useCallback(() => {
- let newValue = valueRef.current!.innerText
-
- try {
- const parsedValue = JSON.parse(newValue)
-
- if (editHandle) editHandle(indexOrName!, parsedValue, node, parentPath)
- } catch (e) {
- const trimmedStringValue = resolveEvalFailedNewValue(type, newValue)
- if (editHandle) editHandle(indexOrName!, trimmedStringValue, node, parentPath)
- }
-
- setEditing(false)
- }, [editHandle, indexOrName, node, parentPath, type])
- const cancel = () => {
- setEditing(false)
- setDeleting(false)
- }
- const deleteHandle = () => {
- setDeleting(false)
- if (_deleteHandle) _deleteHandle(indexOrName!, parentPath)
- if (onDelete)
- onDelete({
- value: node,
- depth,
- src,
- indexOrName: indexOrName!,
- parentType: Array.isArray(parent) ? 'array' : 'object',
- parentPath
- })
- if (onChange)
- onChange({
- depth,
- src,
- indexOrName: indexOrName!,
- parentType: Array.isArray(parent) ? 'array' : 'object',
- type: 'delete',
- parentPath
- })
- }
-
- const handleKeyDown = useCallback(
- (event: React.KeyboardEvent) => {
- if (event.key === 'Enter') {
- event.preventDefault()
- done()
- } else if (event.key === 'Escape') {
- cancel()
- }
- },
- [done]
- )
-
- const isEditing = editing || deleting
- const ctrlClick =
- !isEditing && editableEdit(editable) && customEdit(customReturn as CustomizeOptions | undefined) && editHandle
- ? (event: React.MouseEvent) => {
- if (event.ctrlKey || event.metaKey) edit()
- }
- : undefined
- const Icons = (
- <>
- {isEditing &&
- (typeof DoneComponent === 'function' ? (
-
- ) : (
-
- ))}
- {isEditing &&
- (typeof CancelComponent === 'function' ? (
-
- ) : (
-
- ))}
-
- {!isEditing && enableClipboard && customCopy(customReturn as CustomizeOptions | undefined) && (
-
- )}
- {!isEditing && matchesURL && type === 'string' && urlRegExp.test(node) && customMatchesURL(customReturn as CustomizeOptions | undefined) && (
-
-
-
- )}
-
- {!isEditing &&
- editableEdit(editable) &&
- customEdit(customReturn as CustomizeOptions | undefined) &&
- editHandle &&
- (typeof EditComponent === 'function' ? (
-
- ) : (
-
- ))}
- {!isEditing && editableDelete(editable) && customDelete(customReturn as CustomizeOptions | undefined) && _deleteHandle && (
- setDeleting(true)} />
- )}
- {typeof CustomOperation === 'function' ? : null}
- >
- )
-
- let className = 'json-view--string'
-
- switch (type) {
- case 'number':
- case 'bigint':
- className = 'json-view--number'
- break
- case 'boolean':
- className = 'json-view--boolean'
- break
- case 'object':
- className = 'json-view--null'
- break
- }
-
- if (typeof (customReturn as CustomizeOptions)?.className === 'string') className += ' ' + (customReturn as CustomizeOptions).className
-
- if (deleting) className += ' json-view--deleting'
-
- let displayValue = String(node)
- if (type === 'bigint') displayValue += 'n'
-
- const EditingElement = useMemo(
- () => (
-
- ),
- [displayValue, type, handleKeyDown]
- )
-
- if (type === 'string')
- return (
- <>
- {editing ? (
- EditingElement
- ) : node.length > collapseStringsAfterLength ? (
-
- ) : (
-
- "{displayValue}"
-
- )}
-
- {Icons}
- >
- )
- else {
- return (
- <>
- {editing ? (
- EditingElement
- ) : (
-
- {displayValue}
-
- )}
-
- {Icons}
- >
- )
- }
- }
-}
diff --git a/src/components/json-view.tsx b/src/components/json-view.tsx
index 95f8cf7..080cbd5 100644
--- a/src/components/json-view.tsx
+++ b/src/components/json-view.tsx
@@ -1,91 +1,18 @@
-import { ReactElement, createContext, useCallback, useEffect, useState } from 'react'
-import JsonNode from './json-node'
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { Collapsed, CustomizeCollapseStringUI, CustomizeNode, DisplaySize, Editable, NodeMeta } from '../types'
import { stringifyForCopying } from '../utils'
-
-type OnEdit = (params: {
- newValue: any
- oldValue: any
- depth: number
- src: any
- indexOrName: string | number
- parentType: 'object' | 'array' | null
- parentPath: string[]
-}) => void
-type OnDelete = (params: {
- value: any
- indexOrName: string | number
- depth: number
- src: any
- parentType: 'object' | 'array' | null
- parentPath: string[]
-}) => void
-type OnAdd = (params: { indexOrName: string | number; depth: number; src: any; parentType: 'object' | 'array'; parentPath: string[] }) => void
-type OnChange = (params: {
- indexOrName: string | number
- depth: number
- src: any
- parentType: 'object' | 'array' | null
- type: 'add' | 'edit' | 'delete'
- parentPath: string[]
-}) => void
-type OnCollapse = (params: { isCollapsing: boolean; node: Record | Array; indexOrName: string | number | undefined; depth: number }) => void
+import { flatten } from '../core/flatten'
+import { getByPath, getParentByPath, pathKey } from '../core/path'
+import type { ExpandState } from '../core/expand-state'
+import type { FlatRow } from '../core/row'
+import { Config, ConfigContext, Handlers, HandlersContext, OnAdd, OnChange, OnCollapse, OnDelete, OnEdit } from './contexts'
+import Row from './row'
+import VirtualList from './virtual-list'
export const defaultURLRegExp = /^(((ht|f)tps?):\/\/)?([^!@#$%^&*?.\s-]([^!@#$%^&*?.\s]{0,63}[^!@#$%^&*?.\s])?\.)+[a-z]{2,6}\/?/
-export const JsonViewContext = createContext({
- src: undefined as any,
-
- collapseStringsAfterLength: 99,
- collapseStringMode: 'directly' as 'directly' | 'word' | 'address',
- customizeCollapseStringUI: undefined as CustomizeCollapseStringUI | undefined,
-
- collapseObjectsAfterLength: 20,
- collapsed: false as Collapsed,
- onCollapse: undefined as OnCollapse | undefined,
- enableClipboard: true,
-
- editable: false as Editable,
- onEdit: undefined as OnEdit | undefined,
- onDelete: undefined as OnDelete | undefined,
- onAdd: undefined as OnAdd | undefined,
- onChange: undefined as OnChange | undefined,
-
- forceUpdate: () => {},
-
- customizeNode: undefined as CustomizeNode | undefined,
- customizeCopy: (() => {}) as (node: any, nodeMeta?: NodeMeta) => any,
-
- displaySize: undefined as DisplaySize,
- displayArrayIndex: true,
-
- matchesURL: false,
- urlRegExp: defaultURLRegExp,
-
- ignoreLargeArray: false,
-
- CopyComponent: undefined as
- | React.FC<{ onClick: (event: React.MouseEvent) => void; className: string }>
- | React.Component<{ onClick: (event: React.MouseEvent) => void; className: string }>
- | undefined,
- CopiedComponent: undefined as
- | React.FC<{ className: string; style: React.CSSProperties }>
- | React.Component<{ className: string; style: React.CSSProperties }>
- | undefined,
- EditComponent: undefined as
- | React.FC<{ onClick: (event: React.MouseEvent) => void; className: string }>
- | React.Component<{ onClick: (event: React.MouseEvent) => void; className: string }>
- | undefined,
- CancelComponent: undefined as
- | React.FC<{ onClick: (event: React.MouseEvent) => void; className: string; style: React.CSSProperties }>
- | React.Component<{ onClick: (event: React.MouseEvent) => void; className: string; style: React.CSSProperties }>
- | undefined,
- DoneComponent: undefined as
- | React.FC<{ onClick: (event: React.MouseEvent) => void; className: string; style: React.CSSProperties }>
- | React.Component<{ onClick: (event: React.MouseEvent) => void; className: string; style: React.CSSProperties }>
- | undefined,
- CustomOperation: undefined as React.FC<{ node: any }> | React.Component<{ node: any }> | undefined
-})
+// Re-exported for backward compat (previously a single combined context).
+export { ConfigContext as JsonViewContext } from './contexts'
export interface JsonViewProps {
src: any
@@ -123,24 +50,30 @@ export interface JsonViewProps {
ignoreLargeArray?: boolean
- CopyComponent?:
- | React.FC<{ onClick: (event: React.MouseEvent) => void; className: string }>
- | React.Component<{ onClick: (event: React.MouseEvent) => void; className: string }>
- CopiedComponent?: React.FC<{ className: string; style: React.CSSProperties }> | React.Component<{ className: string; style: React.CSSProperties }>
-
- EditComponent?:
- | React.FC<{ onClick: (event: React.MouseEvent) => void; className: string }>
- | React.Component<{ onClick: (event: React.MouseEvent) => void; className: string }>
-
- CancelComponent?:
- | React.FC<{ onClick: (event: React.MouseEvent) => void; className: string; style: React.CSSProperties }>
- | React.Component<{ onClick: (event: React.MouseEvent) => void; className: string; style: React.CSSProperties }>
-
- DoneComponent?:
- | React.FC<{ onClick: (event: React.MouseEvent) => void; className: string; style: React.CSSProperties }>
- | React.Component<{ onClick: (event: React.MouseEvent) => void; className: string; style: React.CSSProperties }>
+ // Virtualization
+ virtual?: boolean | 'auto'
+ rowVirtualThreshold?: number
+ height?: number | string
+ maxHeight?: number | string
+ estimatedRowHeight?: number
+ overscan?: number
+ // Virtualize inside an existing scroll container instead of the library's own
+ // scroll box: pass a ref to that element. When set, height/maxHeight and the
+ // default 70vh cap are ignored and the referenced element owns scrolling.
+ scrollRef?: React.RefObject
+
+ CopyComponent?: Config['CopyComponent']
+ CopiedComponent?: Config['CopiedComponent']
+ EditComponent?: Config['EditComponent']
+ CancelComponent?: Config['CancelComponent']
+ DoneComponent?: Config['DoneComponent']
+ CustomOperation?: Config['CustomOperation']
+}
- CustomOperation?: React.FC<{ node: any }> | React.Component<{ node: any }>
+interface UIState {
+ editingKey?: string
+ deletingKey?: string
+ addingKey?: string
}
export default function JsonView({
@@ -179,103 +112,241 @@ export default function JsonView({
ignoreLargeArray = false,
+ virtual = 'auto',
+ rowVirtualThreshold = 100,
+ height,
+ maxHeight,
+ estimatedRowHeight = 20,
+ overscan = 8,
+ scrollRef,
+
CopyComponent,
CopiedComponent,
-
EditComponent,
CancelComponent,
DoneComponent,
CustomOperation
}: JsonViewProps) {
- const [_, update] = useState(0)
- const forceUpdate = useCallback(() => update(state => ++state), [])
+ const [version, setVersion] = useState(0)
+ const forceUpdate = useCallback(() => setVersion(v => v + 1), [])
+
const [src, setSrc] = useState(_src)
useEffect(() => setSrc(_src), [_src])
+
+ const [ui, setUI] = useState({})
+
+ const expand = useRef(new Map())
+ const stringExpand = useRef