diff --git a/app/assets/css/main.css b/app/assets/css/main.css index 763e5fd..ba44f29 100644 --- a/app/assets/css/main.css +++ b/app/assets/css/main.css @@ -1,6 +1,8 @@ @import "tailwindcss"; @plugin "@tailwindcss/forms"; +@source "../../../src/**/*.ts"; + @custom-variant dark (&:where(.dark, .dark *)); /* Floating Vue tooltip base */ @@ -28,4 +30,14 @@ /* Arrow border */ .v-popper--theme-tooltip .v-popper__arrow-outer { @apply border border-slate-200 dark:border-slate-700; +} + +.json-viewer-caret { + width: 0.5rem; + height: 0; + border-top: 5px solid transparent; + border-bottom: 5px solid transparent; + border-left: 6px solid currentColor; + transition: transform 120ms ease; + transform-origin: 25% 50%; } \ No newline at end of file diff --git a/app/components/json/json-viewer.vue b/app/components/json/json-viewer.vue new file mode 100644 index 0000000..9e9b973 --- /dev/null +++ b/app/components/json/json-viewer.vue @@ -0,0 +1,247 @@ + + + \ No newline at end of file diff --git a/app/components/ui/full-height-container.vue b/app/components/ui/full-height-container.vue new file mode 100644 index 0000000..eb51b54 --- /dev/null +++ b/app/components/ui/full-height-container.vue @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/app/components/ui/split-layout.vue b/app/components/ui/split-layout.vue index 01be0bf..32febf9 100644 --- a/app/components/ui/split-layout.vue +++ b/app/components/ui/split-layout.vue @@ -126,6 +126,14 @@ function onPointerMove(e: PointerEvent) { function onPointerUp() { + window.removeEventListener("pointermove", onPointerMove); + window.removeEventListener("pointerup", onPointerUp); + window.removeEventListener("pointercancel", onPointerUp); + + if (!dragging.value) { + return; + } + dragHandleEl!.releasePointerCapture(pointerId!); dragHandleEl = null; @@ -133,10 +141,6 @@ function onPointerUp() { pointerId = null; document.documentElement.style.cursor = ""; - - window.removeEventListener("pointermove", onPointerMove); - window.removeEventListener("pointerup", onPointerUp); - window.removeEventListener("pointercancel", onPointerUp); } // Clean up in case component is destroyed mid-drag. @@ -170,13 +174,18 @@ function saveSettings(): void { diff --git a/app/pages/json.vue b/app/pages/json.vue index e0c7cd0..2135286 100644 --- a/app/pages/json.vue +++ b/app/pages/json.vue @@ -1,6 +1,92 @@ - some changes - \ No newline at end of file + \ No newline at end of file diff --git a/nuxt.config.ts b/nuxt.config.ts index e467658..125c442 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -24,4 +24,12 @@ export default defineNuxtConfig({ assetsInlineLimit: 0, }, }, + + typescript: { + tsConfig: { + compilerOptions: { + strictPropertyInitialization: false, + }, + }, + }, }) diff --git a/src/json/events.ts b/src/json/events.ts new file mode 100644 index 0000000..37120ab --- /dev/null +++ b/src/json/events.ts @@ -0,0 +1,45 @@ +type ClickListener = (e: MouseEvent) => void; +type ScrollListener = () => boolean; + +export class Events { + private readonly clickListeners: WeakMap = new WeakMap; + private scrollListeners: ScrollListener[] = []; + + constructor(private readonly root: HTMLElement) { + this.root.addEventListener('click', this.handleClick); + } + + destroy(): void { + this.root.removeEventListener('click', this.handleClick); + } + + onClick(e: HTMLElement, handler: ClickListener) { + this.clickListeners.set(e, handler); + } + + notifyOnScrollIfNodeStillAlive(fn: ScrollListener) { + this.scrollListeners.push(fn); + } + + rootHasScrolled() { + for (let i = this.scrollListeners.length - 1; i >= 0; i--) { + const sl = this.scrollListeners[i]!; + if (!sl()) { + this.scrollListeners.splice(i, 1); + } + } + } + + private handleClick = (event: MouseEvent) => { + let target = event.target as HTMLElement | null; + + while (target && target !== this.root) { + const handler = this.clickListeners.get(target); + if (handler) { + handler(event); + return; + } + target = target.parentElement; + } + }; +} \ No newline at end of file diff --git a/src/json/fenwickTree.ts b/src/json/fenwickTree.ts new file mode 100644 index 0000000..d558db7 --- /dev/null +++ b/src/json/fenwickTree.ts @@ -0,0 +1,66 @@ +/** Fenwick tree (Binary Indexed Tree) for dynamic prefix sums. made by GPT */ +export class FenwickTree { + private readonly n: number; + private readonly bit: number[]; + + constructor(values: number[]) { + this.n = values.length; + this.bit = new Array(this.n + 1).fill(0); + for (let i = 0; i < values.length; i++) { + this.add(i, values[i]!); + } + } + + /** Add delta to index i (0-based). */ + add(i: number, delta: number): void { + for (let x = i + 1; x <= this.n; x += x & -x) { + this.bit[x]! += delta; + } + } + + /** Sum of [0..i) (i from 0..n). */ + sum(i: number): number { + let s = 0; + for (let x = i; x > 0; x -= x & -x) { + s += this.bit[x]!; + } + return s; + } + + total(): number { + return this.sum(this.n); + } + + /** + * Find smallest index idx such that prefixSum(idx+1) > target. + * Equivalent: returns the item index that contains pixel "target". + * target is 0-based pixel offset into the list (0..total-1). + */ + lowerBound(target: number): number { + // Clamp target into [0, total-1] to avoid returning n. + if (target <= 0) { + return 0; + } + const total = this.total(); + if (target >= total) { + return Math.max(0, this.n - 1); + } + + let idx = 0; + // largest power of two >= n + let bitMask = 1; + while (bitMask << 1 <= this.n) bitMask <<= 1; + + // We want largest idx such that prefixSum(idx) <= target, then answer is idx + // This variant assumes bit[] stores partial sums, standard Fenwick lower bound. + for (let step = bitMask; step !== 0; step >>= 1) { + const next = idx + step; + if (next <= this.n && this.bit[next]! <= target) { + target -= this.bit[next]!; + idx = next; + } + } + // idx is in [0..n-1] (because we clamped target < total) + return Math.min(idx, this.n - 1); + } +} \ No newline at end of file diff --git a/src/json/jsonData.ts b/src/json/jsonData.ts new file mode 100644 index 0000000..30c090b --- /dev/null +++ b/src/json/jsonData.ts @@ -0,0 +1,68 @@ +import type {JsonDataBaseNode, SearchResult} from "./nodes"; +import {Events} from "./events"; +import {toNode} from "./utils"; + +export class JsonData { + private readonly rootNode: JsonDataBaseNode; + private element: HTMLElement; + private events: Events; + + constructor(data: unknown) { + this.rootNode = toNode(data, null); + } + + init(root: HTMLElement): void { + this.element = root; + this.events = new Events(root); + + this.rootNode.render(root, this.events, 0); + } + + search(query: string): SearchResult[] { + const results: SearchResult[] = []; + + if (query === '') { + this.rootNode.clearSearch(); + } else if (query.startsWith('=') || !query.includes('=')) { + let strict = false; + if (query.startsWith('=')) { + query = query.substring(1); + strict = true; + } else { + query = query.toLowerCase(); + } + + this.rootNode.searchValue(query, strict, results, this.events); + } else { + const strict = query.includes('=='); + let [fullAddress, searchTerm] = query.split(strict ? '==' : '=', 2); + if (!strict) { + searchTerm = searchTerm!.toLowerCase(); + } + + this.rootNode.searchOnPath( + searchTerm!, fullAddress!.split('.'), -1, strict, results, this.events + ); + } + + return results; + } + + destroy(): void { + this.element.innerHTML = ''; + this.events.destroy(); + } + + // main entry point of the viewer + static makeSafe(raw: string): JsonData | null { + let data: unknown; + + try { + data = JSON.parse(raw); + } catch (_) { + return null; + } + + return new JsonData(data); + } +} \ No newline at end of file diff --git a/src/json/lazyObjectNode.ts b/src/json/lazyObjectNode.ts new file mode 100644 index 0000000..8da912a --- /dev/null +++ b/src/json/lazyObjectNode.ts @@ -0,0 +1,336 @@ +import type {JsonDataNode, JsonDataParentNode, Key, PositionInfo, SearchResult} from "./nodes"; +import {ROW_HEIGHT_PX, LAZY_RENDERING_OVERSCAN_PX} from "./nodes"; +import type {Events} from "./events"; +import {button, div, span, makeKey, searchMatchesOnThisLevel} from "./utils"; +import {FenwickTree} from "./fenwickTree"; + +export class LazyObjectNode< + NodeKey extends Key, + ParentKey extends Key, +> implements JsonDataNode, JsonDataParentNode { + private parent: JsonDataParentNode; + private keyInParent: ParentKey; + + private readonly nodes: JsonDataNode[]; + + private isOpenedByUser: boolean = false; + private isOpen: boolean; + + private baseOfNodeIsRendered: boolean = false; + + private rootElement: HTMLElement; + private caret: HTMLElement; + private closingBraceOpen: HTMLElement; + private closingBraceClose: HTMLElement; + + private nextDepth: number; + private childRoot: HTMLDivElement; + private renderNode: HTMLDivElement; + private renderedStart = 0; + private renderedEnd = 0; + private mounted = new Set(); + + private heightMaybeChanging: boolean = false; + private readonly heights: number[]; + private totalHeightPx: number; + private readonly fenwickTree: FenwickTree; + + constructor( + nodes: Map, + private readonly isArray: boolean, + private readonly key: Key, + ) { + this.nodes = new Array(nodes.size); + this.heights = new Array(nodes.size); + + let i = 0; + for (const [, node] of nodes) { + this.heights[i] = ROW_HEIGHT_PX; + this.nodes[i] = node; + + node.boot(this, i); + i++; + } + + this.totalHeightPx = this.heights.length * ROW_HEIGHT_PX; + this.fenwickTree = new FenwickTree(this.heights); + } + + boot(parent: JsonDataParentNode, keyInParent: ParentKey): void { + this.parent = parent; + this.keyInParent = keyInParent; + } + + render(parent: HTMLElement, events: Events, depth: number): void { + this.baseOfNodeIsRendered = true; + this.rootElement = div([], parent, { + 'data-json-node': 'lazy-object-node', + 'data-json-object-type': this.isArray ? 'array' : 'object', + }); + + const collapseButton = button(['flex', 'space-x-1', 'items-center', 'cursor-pointer'], this.rootElement); + events.onClick(collapseButton, () => { + if (this.isOpen) { + this.close(true, true); + } else { + this.open(events, true, true); + } + }); + + this.caret = span(['json-viewer-caret', 'ps-1', 'text-gray-400'], collapseButton); + + makeKey(collapseButton, this.key); + span([], collapseButton).innerText = this.isArray ? '[' : '{'; + + this.nextDepth = depth + 1; + this.childRoot = div( + ['ms-4', 'border-l', 'border-gray-200', 'dark:border-gray-700', 'hidden', 'relative'], + this.rootElement, + ); + this.childRoot.style.height = `${this.totalHeightPx}px`; + + this.closingBraceOpen = div(['ms-4', 'hidden'], this.rootElement); + this.closingBraceOpen.innerText = this.isArray ? ']' : '}'; + + this.closingBraceClose = span([], collapseButton); + this.closingBraceClose.innerText = this.isArray ? '... ]' : '... }'; + + if (this.isOpen) { + this.open(events, false, true); + } + } + + destroy(): void { + this.baseOfNodeIsRendered = false; + this.rootElement.remove(); + } + + getElement(): HTMLElement { + return this.rootElement; + } + + private open(events: Events, fromClick: boolean, render: boolean): void { + if (render) { + this.renderNode = div( + ['absolute', 'left-2', 'right-0', 'top-0', 'will-change-transform'], + this.childRoot, + ); + + try { + this.heightMaybeChanging = true; + this.renderedStart = this.renderedEnd = 0; + this.performRendering(events); + } finally { + this.heightMaybeChanging = false; + } + + events.notifyOnScrollIfNodeStillAlive(() => { + if (!this.isOpen) { + return false; + } + this.performRendering(events); + return true; + }); + + this.caret.classList.toggle('rotate-90'); + this.childRoot.classList.toggle('hidden'); + this.closingBraceOpen.classList.toggle('hidden'); + this.closingBraceClose.classList.toggle('hidden'); + } + + this.isOpen = true; + if (fromClick) { + this.isOpenedByUser = true; + } + + this.dispatchHeightUpdated(); + } + + private performRendering(events: Events): void { + const position = this.parent.getMyPositionInfo(this.keyInParent); + + const viewportTop = position.rootScrollTop - position.yourStartPosition; + // might overshoot due to viewport top not 100% of the time being the top but is not a big problem + const viewportBottom = viewportTop + position.rootViewportHeight; + + // Compute target render band with overscan + const targetTop = Math.max(0, viewportTop - LAZY_RENDERING_OVERSCAN_PX); + const targetBottom = Math.min( + this.totalHeightPx, + Math.max(viewportBottom + LAZY_RENDERING_OVERSCAN_PX, targetTop + LAZY_RENDERING_OVERSCAN_PX), + ); + + // Map pixels to indices + let start = this.fenwickTree.lowerBound(targetTop); + let end = Math.min(this.nodes.length - 1, this.fenwickTree.lowerBound(targetBottom)) + 1; + + start = Math.max(0, Math.min(start, this.nodes.length - 1)); + end = Math.max(start + 1, Math.min(end, this.nodes.length)); + + const startOffset = this.fenwickTree.sum(start); + this.renderNode.style.transform = `translateY(${startOffset}px)`; + + // render range unchanged, do nothing + if (start === this.renderedStart && end === this.renderedEnd) { + return; + } + + let i = 0; + const wanted = new Array(end - start); + for (let j = start; j < end; j++) { + wanted[i++] = j; + } + + // we remove unwanted nodes + for (const index of this.mounted) { + if (index < start || index >= end) { + this.nodes[index]!.destroy(); + this.mounted.delete(index); + } + } + + // we create the new needed nodes + const elements: HTMLElement[] = []; + for (const index of wanted) { + const node = this.nodes[index]!; + if (!this.mounted.has(index)) { + node.render(this.renderNode, events, this.nextDepth); + this.mounted.add(index); + } + elements.push(node.getElement()); + } + + // we order the nodes, while trying to keep focus on them + let cursor: ChildNode | null = this.renderNode.firstChild; + for (const el of elements) { + if (el === cursor) { + cursor = cursor.nextSibling; + continue; + } + this.renderNode.insertBefore(el, cursor); + } + + this.renderedStart = start; + this.renderedEnd = end; + } + + private close(fromClick: boolean, render: boolean): void { + if (render) { + this.childRoot.innerHTML = ''; + + this.caret.classList.toggle('rotate-90'); + this.childRoot.classList.toggle('hidden'); + this.closingBraceOpen.classList.toggle('hidden'); + this.closingBraceClose.classList.toggle('hidden'); + } + + this.isOpen = false; + if (fromClick) { + this.isOpenedByUser = false; + } + + this.dispatchHeightUpdated(); + } + + getMyPositionInfo(key: number): PositionInfo { + const position = this.parent.getMyPositionInfo(this.keyInParent); + position.yourStartPosition = position.yourStartPosition + this.fenwickTree.sum(key); + return position; + } + + childHeightUpdated(key: number, newHeight: number): void { + const oldHeight = this.heights[key]!; + this.totalHeightPx -= oldHeight; + this.heights[key] = newHeight; + this.totalHeightPx += newHeight; + if (this.baseOfNodeIsRendered && this.isOpen) { + this.childRoot.style.height = `${this.totalHeightPx}px`; + } + + this.fenwickTree.add(key, newHeight - oldHeight); + + if (!this.heightMaybeChanging) { + this.dispatchHeightUpdated(); + } + } + + private dispatchHeightUpdated(): void { + if (this.isOpen) { + // height of child rows + open/close braces + this.parent.childHeightUpdated(this.keyInParent, this.totalHeightPx + ROW_HEIGHT_PX * 2); + } else { + // single row to show open node button + this.parent.childHeightUpdated(this.keyInParent, ROW_HEIGHT_PX); + } + } + + searchOnPath( + query: string, + address: string[], + lastMatch: number, + strictMatch: boolean, + results: SearchResult[], + events: Events, + ): boolean { + if (searchMatchesOnThisLevel(address, lastMatch, this.key)) { + lastMatch++; + } else { + lastMatch = -1; + } + + let any = false; + try { + this.heightMaybeChanging = true; + for (const node of this.nodes) { + if (node.searchOnPath(query, address, lastMatch, strictMatch, results, events)) { + any = true; + } + } + } finally { + this.heightMaybeChanging = false; + } + + return this.exitSearchFromNode(any, events); + } + + searchValue(query: string, strictMatch: boolean, results: SearchResult[], events: Events): boolean { + let any = false; + try { + this.heightMaybeChanging = true; + for (const node of this.nodes) { + if (node.searchValue(query, strictMatch, results, events)) { + any = true; + } + } + } finally { + this.heightMaybeChanging = false; + } + + return this.exitSearchFromNode(any, events); + } + + private exitSearchFromNode(any: boolean, events: Events): boolean { + if (any && !this.isOpen) { + this.open(events, false, this.baseOfNodeIsRendered); + } else if (!any && this.isOpen && !this.isOpenedByUser) { + this.close(false, this.baseOfNodeIsRendered); + } + + return any; + } + + clearSearch() { + try { + this.heightMaybeChanging = true; + for (const node of this.nodes) { + node.clearSearch(); + } + } finally { + this.heightMaybeChanging = false; + } + + if (this.isOpen && !this.isOpenedByUser) { + this.close(false, this.baseOfNodeIsRendered); + } + } +} \ No newline at end of file diff --git a/src/json/nodes.ts b/src/json/nodes.ts new file mode 100644 index 0000000..7d89c59 --- /dev/null +++ b/src/json/nodes.ts @@ -0,0 +1,42 @@ +import type {Events} from "./events"; + +export const ROW_HEIGHT_PX = 20; +export const NODE_CAN_BENEFIT_FROM_LAZY_RENDERING = 250; +export const LAZY_RENDERING_OVERSCAN_PX = 300; + +export type Key = string | number; +export interface SearchResult { + highlight(highlight: boolean): void; + getApproxScrollPosition(): number; +} + +export interface JsonDataBaseNode { + render(parent: HTMLElement, events: Events, depth: number): void; + searchOnPath( + query: string, + address: string[], + lastMatch: number, + strictMatch: boolean, + results: SearchResult[], + events: Events, + ): boolean; + searchValue(query: string, strictMatch: boolean, results: SearchResult[], events: Events): boolean; + clearSearch(): void; +} + +export interface JsonDataNode extends JsonDataBaseNode { + boot(parent: JsonDataParentNode, keyInParent: ParentKey): void; + destroy(): void; + getElement(): HTMLElement; +} + +export interface PositionInfo { + rootViewportHeight: number; + rootScrollTop: number; + yourStartPosition: number; +} + +export interface JsonDataParentNode { + getMyPositionInfo(key: ParentKey): PositionInfo; + childHeightUpdated(key: ParentKey, newHeight: number): void; +} \ No newline at end of file diff --git a/src/json/objectNode.ts b/src/json/objectNode.ts new file mode 100644 index 0000000..c7d0d68 --- /dev/null +++ b/src/json/objectNode.ts @@ -0,0 +1,289 @@ +import type {JsonDataNode, JsonDataBaseNode, JsonDataParentNode, Key, PositionInfo, SearchResult} from "./nodes"; +import {ROW_HEIGHT_PX, NODE_CAN_BENEFIT_FROM_LAZY_RENDERING} from "./nodes"; +import type {Events} from "./events"; +import {RootNode} from "./rootNode"; +import {LazyObjectNode} from "./lazyObjectNode"; +import {FenwickTree} from "./fenwickTree"; +import {searchMatchesOnThisLevel, toNode, button, div, span, makeKey} from "./utils"; + +export class ObjectNode< + NodeKey extends Key, + ParentKey extends Key, +> implements JsonDataNode, JsonDataParentNode { + private parent: JsonDataParentNode; + private keyInParent: ParentKey; + + private isOpenedByUser: boolean = false; + private isOpen: boolean; + + private baseOfNodeIsRendered: boolean = false; + + private rootElement: HTMLElement; + private caret: HTMLElement; + private closingBraceOpen: HTMLElement; + private closingBraceClose: HTMLElement; + + private nextDepth: number; + private childRoot: HTMLDivElement; + + private heightMaybeChanging: boolean = false; + private readonly heights = new Map(); + private totalHeightPx: number; + private fenwickTree: FenwickTree | null = null; + private readonly heightsToFenwickTreeIndex = new Map(); + + constructor( + private readonly nodes: Map, + private readonly isArray: boolean, + private readonly key: Key, + ) { + for (const [key, node] of this.nodes) { + this.heights.set(key, ROW_HEIGHT_PX); + node.boot(this, key); + } + + this.totalHeightPx = this.heights.size * ROW_HEIGHT_PX; + } + + boot(parent: JsonDataParentNode, keyInParent: ParentKey): void { + this.parent = parent; + this.keyInParent = keyInParent; + } + + render(parent: HTMLElement, events: Events, depth: number): void { + this.baseOfNodeIsRendered = true; + this.rootElement = div([], parent, { + 'data-json-node': 'object-node', + 'data-json-object-type': this.isArray ? 'array' : 'object', + }); + + const collapseButton = button(['flex', 'space-x-1', 'items-center', 'cursor-pointer'], this.rootElement); + events.onClick(collapseButton, () => { + if (this.isOpen) { + this.close(true, true); + } else { + this.open(events, true, true); + } + }); + + this.caret = span(['json-viewer-caret', 'ps-1', 'text-gray-400'], collapseButton); + + makeKey(collapseButton, this.key); + span([], collapseButton).innerText = this.isArray ? '[' : '{'; + + this.nextDepth = depth + 1; + this.childRoot = div(['ps-2', 'ms-4', 'border-l', 'border-gray-200', 'dark:border-gray-700'], this.rootElement); + + this.closingBraceOpen = div(['ms-4', 'hidden'], this.rootElement); + this.closingBraceOpen.innerText = this.isArray ? ']' : '}'; + + this.closingBraceClose = span([], collapseButton); + this.closingBraceClose.innerText = this.isArray ? '... ]' : '... }'; + + if (this.isOpen) { + this.open(events, false, true); + } + } + + destroy(): void { + this.baseOfNodeIsRendered = false; + this.rootElement.remove(); + } + + getElement(): HTMLElement { + return this.rootElement; + } + + private open(events: Events, fromClick: boolean, render: boolean): void { + if (render) { + try { + this.heightMaybeChanging = true; + for (const [, node] of this.nodes) { + node.render(this.childRoot, events, this.nextDepth); + } + } finally { + this.heightMaybeChanging = false; + } + + this.caret.classList.toggle('rotate-90'); + this.closingBraceOpen.classList.toggle('hidden'); + this.closingBraceClose.classList.toggle('hidden'); + } + + this.isOpen = true; + if (fromClick) { + this.isOpenedByUser = true; + } + + this.dispatchHeight(); + } + + private close(fromClick: boolean, render: boolean): void { + if (render) { + this.childRoot.innerHTML = ''; + + this.caret.classList.toggle('rotate-90'); + this.closingBraceOpen.classList.toggle('hidden'); + this.closingBraceClose.classList.toggle('hidden'); + } + + this.isOpen = false; + if (fromClick) { + this.isOpenedByUser = false; + } + + this.dispatchHeight(); + } + + getMyPositionInfo(key: NodeKey): PositionInfo { + // we don't create a fenwick tree if we don't have children with virtual rendering + if (this.fenwickTree === null) { + let i = 0; + const heights: number[] = []; + for (const [key] of this.nodes) { + this.heightsToFenwickTreeIndex.set(key, i++); + heights.push(this.heights.get(key)!); + } + this.fenwickTree = new FenwickTree(heights); + } + + const position = this.parent.getMyPositionInfo(this.keyInParent); + + const fenwickIndex = this.heightsToFenwickTreeIndex.get(key)!; + position.yourStartPosition = position.yourStartPosition + this.fenwickTree.sum(fenwickIndex) + ROW_HEIGHT_PX; + + return position; + } + + childHeightUpdated(key: NodeKey, newHeight: number): void { + const oldHeight = this.heights.get(key)!; + this.totalHeightPx -= oldHeight; + this.heights.set(key, newHeight); + this.totalHeightPx += newHeight; + + // we update the fenwick tree if we have one (i.e, one of our children is lazy rendering) + if (this.fenwickTree !== null) { + const fenwickIndex = this.heightsToFenwickTreeIndex.get(key)!; + this.fenwickTree.add(fenwickIndex, newHeight - oldHeight); + } + // if we are not in a render we update the parents that we updated our height else the rendered will + // dispatch the height at the end of the render. + if (!this.heightMaybeChanging) { + this.dispatchHeight(); + } + } + + private dispatchHeight(): void { + if (this.isOpen) { + // height of child rows + open/close braces + this.parent.childHeightUpdated(this.keyInParent, this.totalHeightPx + ROW_HEIGHT_PX * 2); + } else { + // single row to show open node button + this.parent.childHeightUpdated(this.keyInParent, ROW_HEIGHT_PX); + } + } + + searchOnPath( + query: string, + address: string[], + lastMatch: number, + strictMatch: boolean, + results: SearchResult[], + events: Events, + ): boolean { + if (searchMatchesOnThisLevel(address, lastMatch, this.key)) { + lastMatch++; + } else { + lastMatch = -1; + } + + let any = false; + try { + this.heightMaybeChanging = true; + for (const [, node] of this.nodes) { + if (node.searchOnPath(query, address, lastMatch, strictMatch, results, events)) { + any = true; + } + } + } finally { + this.heightMaybeChanging = false; + } + + return this.exitSearchFromNode(any, events); + } + + searchValue(query: string, strictMatch: boolean, results: SearchResult[], events: Events): boolean { + let any = false; + try { + this.heightMaybeChanging = true; + for (const [, node] of this.nodes) { + if (node.searchValue(query, strictMatch, results, events)) { + any = true; + } + } + } finally { + this.heightMaybeChanging = false; + } + + return this.exitSearchFromNode(any, events); + } + + private exitSearchFromNode(any: boolean, events: Events): boolean { + if (any && !this.isOpen) { + this.open(events, false, this.baseOfNodeIsRendered); + } else if (!any && this.isOpen && !this.isOpenedByUser) { + this.close(false, this.baseOfNodeIsRendered); + } + + return any; + } + + clearSearch() { + try { + this.heightMaybeChanging = true; + for (const [, node] of this.nodes) { + node.clearSearch(); + } + } finally { + this.heightMaybeChanging = false; + } + + if (this.isOpen && !this.isOpenedByUser) { + this.close(false, this.baseOfNodeIsRendered); + } + } + + static fromArray(data: unknown[], key: Key | null): JsonDataNode | JsonDataBaseNode { + const nodes: Map = new Map; + + let i = 0; + for (const value of data) { + nodes.set(i, toNode(value, i)); + i++; + } + + if (key === null) { + return new RootNode(nodes, true); + } + + return nodes.size > NODE_CAN_BENEFIT_FROM_LAZY_RENDERING + ? new LazyObjectNode(nodes, true, key) + : new ObjectNode(nodes, true, key); + } + + static fromObject(data: Record, key: Key | null): JsonDataNode | JsonDataBaseNode { + const nodes: Map = new Map; + + for (const key in data) { + nodes.set(key, toNode(data[key], key)); + } + + if (key === null) { + return new RootNode(nodes, false); + } + + return nodes.size > NODE_CAN_BENEFIT_FROM_LAZY_RENDERING + ? new LazyObjectNode(nodes, false, key) + : new ObjectNode(nodes, false, key); + } +} \ No newline at end of file diff --git a/src/json/rootNode.ts b/src/json/rootNode.ts new file mode 100644 index 0000000..8146a52 --- /dev/null +++ b/src/json/rootNode.ts @@ -0,0 +1,185 @@ +import type {JsonDataNode, JsonDataBaseNode, JsonDataParentNode, Key, PositionInfo, SearchResult} from "./nodes"; +import {ROW_HEIGHT_PX, LAZY_RENDERING_OVERSCAN_PX} from "./nodes"; +import {Events} from "./events"; +import {div} from "./utils"; +import {FenwickTree} from "./fenwickTree"; + +export class RootNode implements JsonDataBaseNode, JsonDataParentNode { + private renderQueuedJobId: number; + private events: Events; + + private readonly nodes: JsonDataNode[]; + + private readonly heights: number[] = []; + private readonly fenwick: FenwickTree; + private totalHeightPx = 0; + + private parentElement: HTMLElement; + private rootNode: HTMLDivElement; + private renderNode: HTMLDivElement; + + private renderedStart = 0; + private renderedEnd = 0; + private mounted = new Set(); + + constructor(nodes: Map, private readonly isArray: boolean) { + this.nodes = new Array(nodes.size); + this.heights = new Array(nodes.size); + + let i = 0; + for (const [, node] of nodes) { + this.nodes[i] = node; + this.heights[i] = ROW_HEIGHT_PX; + node.boot(this, i); + i++; + } + + this.fenwick = new FenwickTree(this.heights); + this.totalHeightPx = this.fenwick.total(); + } + + render(parent: HTMLElement, events: Events): void { + this.parentElement = parent; + this.events = events; + + this.rootNode = div(['relative'], parent, { + 'data-json-node': 'root-node', + 'data-json-object-type': this.isArray ? 'array' : 'object', + }); + this.rootNode.style.height = `${this.totalHeightPx}px`; + + this.renderNode = div(['absolute', 'left-0', 'right-0', 'top-0', 'will-change-transform'], this.rootNode); + this.renderNode.style.transform = 'translateY(0px)'; + + parent.addEventListener('scroll', () => { + this.queueRendering(); + }); + + this.performRendering(); + } + + private queueRendering(): void { + cancelAnimationFrame(this.renderQueuedJobId); + this.renderQueuedJobId = requestAnimationFrame(() => { + this.performRendering(); + this.events.rootHasScrolled(); + }); + } + + private performRendering(): void { + const viewportHeight = this.parentElement.clientHeight; + const viewportTop = this.parentElement.scrollTop; + const viewportBottom = viewportTop + viewportHeight; + + // Compute target render band with overscan + const targetTop = Math.max(0, viewportTop - LAZY_RENDERING_OVERSCAN_PX); + const targetBottom = Math.min( + this.totalHeightPx, + Math.max(viewportBottom + LAZY_RENDERING_OVERSCAN_PX, targetTop + LAZY_RENDERING_OVERSCAN_PX), + ); + + // Map pixels to indices + let start = this.fenwick.lowerBound(targetTop); + let end = Math.min(this.nodes.length - 1, this.fenwick.lowerBound(targetBottom)) + 1; + + start = Math.max(0, Math.min(start, this.nodes.length - 1)); + end = Math.max(start + 1, Math.min(end, this.nodes.length)); + + const startOffset = this.fenwick.sum(start); + this.renderNode.style.transform = `translateY(${startOffset}px)`; + + // render range unchanged, do nothing + if (start === this.renderedStart && end === this.renderedEnd) { + return; + } + + const wanted = new Set(); + for (let i = start; i < end; i++) { + wanted.add(i); + } + + // we remove unwanted nodes + for (const index of this.mounted) { + if (!wanted.has(index)) { + this.nodes[index]!.destroy(); + } + } + + // we create the new needed nodes + const elements: HTMLElement[] = []; + for (const index of wanted) { + const node = this.nodes[index]!; + if (!this.mounted.has(index)) { + node.render(this.renderNode, this.events, 1); + } + elements.push(node.getElement()); + } + + // we order the nodes, while trying to keep focus on them + let cursor: ChildNode | null = this.renderNode.firstChild; + for (const el of elements) { + if (el === cursor) { + cursor = cursor.nextSibling; + continue; + } + this.renderNode.insertBefore(el, cursor); + } + + this.mounted = wanted; + this.renderedStart = start; + this.renderedEnd = end; + } + + getMyPositionInfo(key: number): PositionInfo { + return { + rootViewportHeight: this.parentElement.clientHeight, + rootScrollTop: Math.floor(this.parentElement.scrollTop), + yourStartPosition: this.fenwick.sum(key), + }; + } + + childHeightUpdated(key: number, newHeight: number) { + const old = this.heights[key]!; + this.heights[key] = newHeight; + + this.fenwick.add(key, newHeight - old); + + this.totalHeightPx = this.fenwick.total(); + this.rootNode.style.height = `${this.totalHeightPx}px`; + + this.queueRendering(); + } + + searchOnPath( + query: string, + address: string[], + lastMatch: number, + strictMatch: boolean, + results: SearchResult[], + events: Events, + ): boolean { + let any = false; + for (const node of this.nodes) { + if (node.searchOnPath(query, address, lastMatch, strictMatch, results, events)) { + any = true; + } + } + return any; + } + + searchValue(query: string, strictMatch: boolean, results: SearchResult[], events: Events): boolean { + let any = false; + for (const node of this.nodes) { + if (node.searchValue(query, strictMatch, results, events)) { + any = true; + } + } + return any; + } + + clearSearch() { + for (const node of this.nodes) { + node.clearSearch(); + } + } +} \ No newline at end of file diff --git a/src/json/simpleNode.ts b/src/json/simpleNode.ts new file mode 100644 index 0000000..105f05b --- /dev/null +++ b/src/json/simpleNode.ts @@ -0,0 +1,161 @@ +import type {JsonDataNode, JsonDataParentNode, Key, SearchResult} from "./nodes"; +import {searchMatchesOnThisLevel, div, span, makeKey} from "./utils"; + +export const enum SimpleType { + String, + Number, + Null, + Boolean, +} + +const HIGHLIGHT_BASE_CLASSES = Object.freeze(['text-white!', 'rounded']); +const IS_HIGHLIGHTED_CLASS = 'bg-orange-600'; +const IS_NOT_HIGHLIGHTED_CLASS = 'bg-yellow-500'; + +export class SimpleNode implements JsonDataNode, SearchResult { + private parent: JsonDataParentNode; + private keyInParent: ParentKey; + + private rendered: boolean = false; + private highlighted: boolean = false; + private matchesInSearch: boolean = false; + + private element: HTMLElement; + + constructor( + private readonly value: string, + private readonly type: SimpleType, + private readonly key: Key | null, + ) {} + + boot(parent: JsonDataParentNode, keyInParent: ParentKey): void { + this.parent = parent; + this.keyInParent = keyInParent; + } + + render(parent: HTMLElement) { + this.rendered = true; + const root = div(['flex', 'space-x-1', 'ms-4'], parent, { + 'data-json-node': 'value-node', + }); + makeKey(root, this.key); + + switch (this.type) { + case SimpleType.String: + root.setAttribute('data-json-value-type', 'string'); + + this.element = span(['text-green-700', 'truncate', 'text-nowrap'], root); + this.element.innerText = `"${this.value.replace(/\r?\n/g, '\\n')}"`; + break; + + case SimpleType.Null: + case SimpleType.Boolean: + const type = this.type === SimpleType.Null ? 'null' : 'boolean'; + root.setAttribute('data-json-value-type', type); + + this.element = span(['text-orange-700'], root); + this.element.innerText = this.value; + break; + + case SimpleType.Number: + root.setAttribute('data-json-value-type', 'number'); + + this.element = span(['text-cyan-700'], root); + this.element.innerText = this.value; + break; + } + + if (this.matchesInSearch) { + root.setAttribute('data-json-search', 'match'); + this.element.classList.add( + this.highlighted ? IS_HIGHLIGHTED_CLASS : IS_NOT_HIGHLIGHTED_CLASS, + ...HIGHLIGHT_BASE_CLASSES, + ); + } + } + + destroy() { + this.rendered = false; + this.element.parentElement!.remove(); + } + + getElement(): HTMLElement { + return this.element.parentElement!; + } + + searchOnPath( + query: string, + address: string[], + lastMatch: number, + strictMatch: boolean, + results: SearchResult[], + ): boolean { + if ( + searchMatchesOnThisLevel(address, lastMatch, this.key) + // verify we are at the end of the searching path as we don't have children + && address.length === lastMatch + 2 + ) { + return this.searchValue(query, strictMatch, results); + } + + return false; + } + + searchValue(query: string, strictMatch: boolean, results: SearchResult[]): boolean { + if (strictMatch) { + this.matchesInSearch = this.value === query; + } else { + this.matchesInSearch = this.value.toLowerCase().includes(query); + } + + if (this.matchesInSearch) { + results.push(this); + } + + if (this.rendered) { + if (this.matchesInSearch) { + this.element.parentElement!.setAttribute('data-json-search', 'match'); + this.element.classList.add( + this.highlighted ? IS_HIGHLIGHTED_CLASS : IS_NOT_HIGHLIGHTED_CLASS, + ...HIGHLIGHT_BASE_CLASSES, + ); + } else { + this.element.parentElement!.removeAttribute('data-json-search'); + this.element.classList.remove( + IS_HIGHLIGHTED_CLASS, IS_NOT_HIGHLIGHTED_CLASS, ...HIGHLIGHT_BASE_CLASSES, + ); + } + } + + return this.matchesInSearch; + } + + clearSearch(): void { + if (this.rendered && this.matchesInSearch) { + this.element.parentElement!.removeAttribute('data-json-search'); + this.element.classList.remove( + IS_HIGHLIGHTED_CLASS, IS_NOT_HIGHLIGHTED_CLASS, ...HIGHLIGHT_BASE_CLASSES, + ); + } + + this.highlighted = false; + this.matchesInSearch = false; + } + + highlight(highlight: boolean) { + this.highlighted = highlight; + if (this.rendered) { + if (highlight) { + this.element.classList.add(IS_HIGHLIGHTED_CLASS); + this.element.classList.remove(IS_NOT_HIGHLIGHTED_CLASS); + } else { + this.element.classList.add(IS_NOT_HIGHLIGHTED_CLASS); + this.element.classList.remove(IS_HIGHLIGHTED_CLASS); + } + } + } + + getApproxScrollPosition(): number { + return this.parent.getMyPositionInfo(this.keyInParent).yourStartPosition; + } +} \ No newline at end of file diff --git a/src/json/utils.ts b/src/json/utils.ts new file mode 100644 index 0000000..b7500e4 --- /dev/null +++ b/src/json/utils.ts @@ -0,0 +1,105 @@ +import type {JsonDataBaseNode, JsonDataNode, Key} from "./nodes"; +import {SimpleNode, SimpleType} from "./simpleNode"; +import {ObjectNode} from "./objectNode"; + +export function toNode(data: any, key: null): JsonDataBaseNode; +export function toNode(data: any, key: Key): JsonDataNode; +export function toNode(data: any, key: Key | null): JsonDataNode|JsonDataBaseNode { + switch (typeof data) { + case "string": + return new SimpleNode(data, SimpleType.String, key); + case "number": + return new SimpleNode(data.toString(), SimpleType.Number, key); + case "object": + if (data === null) { + return new SimpleNode('null', SimpleType.Null, key); + } else if (Array.isArray(data)) { + return ObjectNode.fromArray(data, key); + } else { + return ObjectNode.fromObject(data, key); + } + case "boolean": + return new SimpleNode(data ? 'true' : 'false', SimpleType.Boolean, key); + default: + throw new Error(`Unsupported json data type: "${typeof data}"`); + } +} + +export function searchMatchesOnThisLevel(address: string[], lastMatch: number, key: Key | null): boolean { + const keyToCheck = lastMatch + 1; + if (keyToCheck < address.length) { + if (typeof key === 'number') { + key = key.toString(); + } + + const thisNodePossibleAddress = address[keyToCheck]; + return thisNodePossibleAddress === '*' || thisNodePossibleAddress === key; + } + + return false; +} + +type Attributes = Record; + +function element( + type: string, + classes: string[], + parent: HTMLElement | null, + attributes: Attributes, +): T { + const e = document.createElement(type) as T; + + if (classes.length > 0) { + e.classList.add(...classes); + } + if (parent !== null) { + parent.appendChild(e); + } + for (const attribute in attributes) { + e.setAttribute(attribute, attributes[attribute]!); + } + + return e; +} + +export function div( + classes: string[] = [], + parent: HTMLElement | null = null, + attributes: Attributes = {}, +): HTMLDivElement { + return element('div', classes, parent, attributes); +} + +export function span( + classes: string[] = [], + parent: HTMLElement | null = null, + attributes: Attributes = {}, +): HTMLSpanElement { + return element('span', classes, parent, attributes); +} + +export function button( + classes: string[] = [], + parent: HTMLElement | null = null, + attributes: Attributes = {}, +): HTMLButtonElement { + return element('button', classes, parent, attributes); +} + +export function idiomatic( + classes: string[] = [], + parent: HTMLElement | null = null, + attributes: Attributes = {}, +): HTMLElement { + return element('i', classes, parent, attributes); +} + +export function makeKey(parent: HTMLElement, key: Key | null): void { + if (key !== null) { + if (typeof key === 'string') { + span(['text-blue-700'], parent).innerText = `"${key}":`; + } else { + span(['text-purple-700'], parent).innerText = `${key.toString()}:`; + } + } +} \ No newline at end of file diff --git a/tests/e2e/json.test.ts b/tests/e2e/json.test.ts new file mode 100644 index 0000000..e8459e3 --- /dev/null +++ b/tests/e2e/json.test.ts @@ -0,0 +1,7 @@ +import {expect, test} from "@playwright/test" +import {setup} from "./_helpers"; + +test('json -> input to be focused on page enter', async ({page}) => { + await setup(page, '/json'); + await expect(page.getByTestId('json-encoded-input')).toBeFocused(); +}); \ No newline at end of file diff --git a/tests/nuxt/json.test.ts b/tests/nuxt/json.test.ts new file mode 100644 index 0000000..c74b9bb --- /dev/null +++ b/tests/nuxt/json.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { mount } from "@vue/test-utils"; +import { testId } from "~~/tests/nuxt/_helpers"; +import JsonPage from "~/pages/json.vue"; +import JsonViewer from "~/components/json/json-viewer.vue"; + +describe("json page", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + async function mountComponent() { + const wrapper = mount(JsonPage); + await wrapper.vm.$nextTick(); // let ClientOnly mount + return wrapper; + } + + it('starts in empty state', async () => { + const wrapper = await mountComponent(); + expect(wrapper.find(testId('json-empty-state')).exists()).toBe(true); + }); + + it('shows viewer when valid JSON is entered', async () => { + const wrapper = await mountComponent(); + + const input = wrapper.get(testId('json-encoded-input')); + await input.setValue('{"a":1}'); + await input.trigger('input'); + + vi.runOnlyPendingTimers(); // input is debounced + await wrapper.vm.$nextTick(); + + expect(wrapper.find(testId('json-viewer')).exists()).toBe(true); + expect(wrapper.find(testId('json-invalid-state')).exists()).toBe(false); + }); + + it('hides textarea and shows large input state when pasting huge JSON', async () => { + const wrapper = await mountComponent(); + + const huge = 'x'.repeat(100_001); // just above limit, quite sensible + const textarea = wrapper.get(testId('json-encoded-input')); + + await textarea.trigger("paste", { + clipboardData: { getData: () => huge }, + preventDefault: vi.fn(), + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.find(testId('json-large-input-state')).exists()).toBe(true); + }); +}); + +const initMock = vi.fn(); +const destroyMock = vi.fn(); +const searchMock = vi.fn(); + +vi.mock("~~/src/json/jsonData", () => { + return { + JsonData: class { + static makeSafe(raw: string) { + if (raw === "INVALID") return null; + return new (this as any)(); + } + init = initMock; + destroy = destroyMock; + search = searchMock; + }, + }; +}); + +describe("JsonViewer component", () => { + beforeEach(() => { + vi.useFakeTimers(); + initMock.mockClear(); + destroyMock.mockClear(); + searchMock.mockClear(); + }); + + it('emits invalid=true when JSON is invalid', async () => { + const wrapper = mount(JsonViewer, { props: { json: 'INVALID' } }); + await wrapper.vm.$nextTick(); + + const ev = wrapper.emitted('invalid'); + expect(ev).toBeTruthy(); + expect(ev![0]![0]).toBe(true); + }); + + it('emits invalid=false and initializes viewer when JSON is valid', async () => { + const wrapper = mount(JsonViewer, { props: { json: '{"a":1}' } }); + await wrapper.vm.$nextTick(); + + const ev = wrapper.emitted('invalid'); + expect(ev).toBeTruthy(); + expect(ev![0]![0]).toBe(false); + + await wrapper.vm.$nextTick(); + expect(initMock).toHaveBeenCalled(); + }); + + it("debounces search and calls data.search", async () => { + searchMock.mockReturnValue([]); + + const wrapper = mount(JsonViewer, { props: { json: '{"a":"needle"}' } }); + await wrapper.vm.$nextTick(); + await wrapper.vm.$nextTick(); + + await wrapper.get(testId('json-viewer-search-input')).setValue('needle'); + + expect(searchMock).not.toHaveBeenCalled(); + vi.runOnlyPendingTimers(); + expect(searchMock).toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/tests/unit/json/fenwickTree.test.ts b/tests/unit/json/fenwickTree.test.ts new file mode 100644 index 0000000..eba4fb6 --- /dev/null +++ b/tests/unit/json/fenwickTree.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from "vitest"; +import { FenwickTree } from "../../../src/json/fenwickTree"; + +describe("FenwickTree", () => { + it("sum() matches naive prefix sums", () => { + const vals = [10, 20, 5, 0, 7]; + const ft = new FenwickTree(vals); + + const naive = (i: number) => vals.slice(0, i).reduce((a, b) => a + b, 0); + + for (let i = 0; i <= vals.length; i++) { + expect(ft.sum(i)).toBe(naive(i)); + } + expect(ft.total()).toBe(naive(vals.length)); + }); + + it("add() updates sums correctly", () => { + const vals = [1, 1, 1]; + const ft = new FenwickTree(vals); + + ft.add(1, 5); // [1,6,1] + expect(ft.sum(0)).toBe(0); + expect(ft.sum(1)).toBe(1); + expect(ft.sum(2)).toBe(7); + expect(ft.sum(3)).toBe(8); + }); + + it("lowerBound clamps at edges", () => { + const ft = new FenwickTree([10, 10, 10]); + expect(ft.lowerBound(-1)).toBe(0); + expect(ft.lowerBound(0)).toBe(0); + expect(ft.lowerBound(29)).toBe(2); // last pixel in total=30 + expect(ft.lowerBound(30)).toBe(2); // clamped + expect(ft.lowerBound(999)).toBe(2); + }); + + it("lowerBound finds the bucket containing target pixel", () => { + // row ranges: [0..2), [2..7), [7..8) + const ft = new FenwickTree([2, 5, 1]); + expect(ft.lowerBound(0)).toBe(0); + expect(ft.lowerBound(1)).toBe(0); + expect(ft.lowerBound(2)).toBe(1); + expect(ft.lowerBound(6)).toBe(1); + expect(ft.lowerBound(7)).toBe(2); + }); +}); \ No newline at end of file diff --git a/tests/unit/json/jsonViewer.test.ts b/tests/unit/json/jsonViewer.test.ts new file mode 100644 index 0000000..93b2324 --- /dev/null +++ b/tests/unit/json/jsonViewer.test.ts @@ -0,0 +1,298 @@ +import { describe, it, expect } from "vitest"; +import { JsonData } from "../../../src/json/jsonData"; + +function makeViewer(json: any) { + const root = document.createElement("div"); + root.style.height = `300px`; + root.style.overflow = "auto"; + document.body.appendChild(root); + + const jd = new JsonData(json); + jd.init(root); + + return { root, jd }; +} + +function clickOpenButton(nodeEl: Element) { + const btn = nodeEl.querySelector("button") as HTMLButtonElement | null; + expect(btn).not.toBeNull(); + btn!.click(); +} + +function getTopLevelOpenable(root: Element): Element { + const top = + root.querySelector('[data-json-node="object-node"]') ?? + root.querySelector('[data-json-node="lazy-object-node"]'); + expect(top).not.toBeNull(); + return top!; +} + +function clickFirstOpenable(root: Element) { + return clickOpenButton( + getTopLevelOpenable(root) + ); +} + +describe('json viewer search', () => { + it('adds data-json-search=match on matches and removes it when clearing', () => { + const { root, jd } = makeViewer({ a: 'hello', b: 'world' }); + + jd.search('hello'); + expect( + root.querySelectorAll('[data-json-node="value-node"][data-json-search="match"]').length, + ).toBe(1); + + jd.search(''); // clear + expect(root.querySelectorAll('[data-json-search="match"]').length).toBe(0); + }); + + it('supports strict and non-strict value search', () => { + const { root, jd } = makeViewer({ a: 'Hello', b: 'world' }); + + jd.search('hello'); // non-strict -> lowercase match + expect(root.querySelectorAll('[data-json-search="match"]').length).toBe(1); + + jd.search('=hello'); // strict -> should NOT match "Hello" + expect(root.querySelectorAll('[data-json-search="match"]').length).toBe(0); + + jd.search('=Hello'); // strict -> match + expect(root.querySelectorAll('[data-json-search="match"]').length).toBe(1); + }); + + it('value search (non-path) opens ancestors so the match becomes renderable', () => { + const { root, jd } = makeViewer({ a: { b: { c: 'needle' } } }); + + jd.search('needle'); + + const match = root.querySelector( + '[data-json-node="value-node"][data-json-search="match"]', + ); + expect(match).not.toBeNull(); + expect(match!.textContent).toContain('needle'); + }); +}); + +describe('json viewer path search', () => { + it('supports \'*\' matching a single path segment', () => { + const { root, jd } = makeViewer({ + a: { b: { c: 'needle' } }, + x: { y: { c: 'nope' } }, + }); + + // * matches "b" here, so only a.b.c should match + jd.search('a.*.c=needle'); + + const matches = root.querySelectorAll( + '[data-json-node="value-node"][data-json-search="match"]', + ); + expect(matches.length).toBe(1); + expect(matches[0]!.textContent).toContain("needle"); + }); + + it('supports \'*\' at the root level segment', () => { + const { root, jd } = makeViewer({ + a: { b: { c: 'needle' } }, + x: { b: { c: 'needle' } }, + }); + + jd.search('*.b.c=needle'); + + const matches = root.querySelectorAll( + '[data-json-node="value-node"][data-json-search="match"]', + ); + expect(matches.length).toBe(2); + }); + + it('does not match if the path is longer than the actual leaf path', () => { + const { root, jd } = makeViewer({ a: { b: { c: 'needle' } } }); + + jd.search('a.b.c.d=needle'); + + const matches = root.querySelectorAll('[data-json-search="match"]'); + expect(matches.length).toBe(0); + }); + + it('path search supports strict matching with ==', () => { + const { root, jd } = makeViewer({ a: { b: { c: 'Needle' } } }); + + jd.search('a.b.c==needle'); // strict: should NOT match "Needle" + expect(root.querySelectorAll('[data-json-search="match"]').length).toBe(0); + + jd.search('a.b.c==Needle'); // strict: should match + expect(root.querySelectorAll('[data-json-search="match"]').length).toBe(1); + }); +}); + +describe('json viewer open/close behavior during search', () => { + it('path search opens ancestors so the match becomes renderable', () => { + const { root, jd } = makeViewer({ a: { b: { c: 'needle' } } }); + + jd.search('a.b.c=needle'); + + const match = root.querySelector( + '[data-json-node="value-node"][data-json-search="match"]', + ); + expect(match).not.toBeNull(); + expect(match!.textContent).toContain('needle'); + }); + + it('keeps user-opened nodes open after search is cleared', () => { + const { root, jd } = makeViewer({ a: { b: { c: 'needle' } }, z: 1 }); + + // user click + clickFirstOpenable(root); + + // trigger search which should open a, b and c + jd.search('a.b.c=needle'); + expect(root.querySelectorAll('[data-json-search="match"]').length).toBe(1); + + // clear search + jd.search(''); + + // we assert z is still rendered + expect(root.querySelectorAll('[data-json-node="value-node"][data-json-value-type="number"]').length).toBeGreaterThan(0); + }); + + it('closes nodes that were only opened by search once search is cleared', () => { + const { root, jd } = makeViewer({ a: { b: { c: 'needle' } }, other: { k: 1 } }); + + // no user clicks, but search should open some nodes + jd.search('a.b.c=needle'); + expect(root.querySelectorAll('[data-json-search="match"]').length).toBe(1); + + // clear search, search opened nodes should close and their children should be removed from the dom + jd.search(''); + + expect(root.querySelectorAll('[data-json-search="match"]').length).toBe(0); + + // we expect that "needle" is no longer present in the dom because the branch should collapse + expect(root.textContent).not.toContain('needle'); + }); +}); + +// some nodes uses requestAnimationFrame on scroll, so we need to await a frame +function flushRaf(): Promise { + return new Promise((resolve) => requestAnimationFrame(() => resolve())); +} + +describe('json viewer virtualization (root)', () => { + it('renders a root-node container', () => { + const { root } = makeViewer([1, 2, 3]); + expect(root.querySelector('[data-json-node="root-node"]')).not.toBeNull(); + }); + + it('mounts only a window of value-nodes for a huge top-level array', () => { + const big = Array.from({ length: 10000 }, (_, i) => `v${i}`); + const { root } = makeViewer(big); + + // value nodes mounted anywhere in the viewer. + const mounted = root.querySelectorAll('[data-json-node="value-node"]').length; + + // Should be far below 10k due to root virtualization. + expect(mounted).toBeGreaterThan(0); + expect(mounted).toBeLessThan(2000); + }); + + it('scrolling changes which root value-nodes are mounted', async () => { + const big = Array.from({ length: 10000 }, (_, i) => `v${i}`); + const { root } = makeViewer(big); + + const firstBefore = root.querySelector('[data-json-node="value-node"]')?.textContent ?? ""; + + root.scrollTop = 12000; + root.dispatchEvent(new Event('scroll')); + await flushRaf(); + + const firstAfter = root.querySelector('[data-json-node="value-node"]')?.textContent ?? ""; + + expect(firstAfter).not.toBe(firstBefore); + }); + + it('unmounts offscreen nodes (dom stays bounded after repeated scrolls)', async () => { + const big = Array.from({ length: 20000 }, (_, i) => `v${i}`); + const { root } = makeViewer(big); + + const countAt = async (scrollTop: number) => { + root.scrollTop = scrollTop; + root.dispatchEvent(new Event('scroll')); + await flushRaf(); + return root.querySelectorAll('[data-json-node="value-node"]').length; + }; + + const c1 = await countAt(0); + const c2 = await countAt(15000); + const c3 = await countAt(30000); + const c4 = await countAt(0); + + expect(Math.min(c1, c2, c3, c4)).toBeGreaterThan(0); + expect(Math.max(c1, c2, c3, c4)).toBeLessThan(3000); + }); +}); + +describe('json viewer virtualization (child nodes)', () => { + it('uses object-node (non-lazy) for small arrays', async () => { + const smallArr = Array.from({ length: 20 }, (_, i) => `s${i}`); + const { root } = makeViewer({ array: smallArr }); + + const arrNode = root.querySelector( + '[data-json-node="object-node"][data-json-object-type="array"]', + ) as HTMLElement | null; + + expect(arrNode).not.toBeNull(); + + // Open the array + const btn = arrNode!.querySelector("button") as HTMLButtonElement; + btn.click(); + await flushRaf(); + + const values = arrNode!.querySelectorAll('[data-json-node="value-node"]'); + expect(values.length).toBe(20); + }); + + it('uses lazy-object-node for large arrays', async () => { + const bigArr = Array.from({ length: 2000 }, (_, i) => `b${i}`); + const { root } = makeViewer({ array: bigArr }); + + const lazyArrNode = root.querySelector( + '[data-json-node="lazy-object-node"][data-json-object-type="array"]', + ) as HTMLElement | null; + + expect(lazyArrNode).not.toBeNull(); + + // Open the array + const btn = lazyArrNode!.querySelector("button") as HTMLButtonElement; + btn.click(); + await flushRaf(); + + const values = lazyArrNode!.querySelectorAll('[data-json-node="value-node"]'); + expect(values.length).toBeGreaterThan(0); + expect(values.length).toBeLessThan(2000); + }); + + it('lazy-object-node changes mounted children after scroll', async () => { + const bigArr = Array.from({ length: 3000 }, (_, i) => `b${i}`); + const { root } = makeViewer({ array: bigArr }); + + const lazyArrNode = root.querySelector( + '[data-json-node="lazy-object-node"][data-json-object-type="array"]', + ) as HTMLElement; + + expect(lazyArrNode).not.toBeNull(); + + // Open the array + lazyArrNode!.querySelector("button")!.click(); + await flushRaf(); + + const firstBefore = + lazyArrNode!.querySelector('[data-json-node="value-node"]')?.textContent ?? ""; + + root.scrollTop = 12000; + root.dispatchEvent(new Event('scroll')); + await flushRaf(); + + const firstAfter = + lazyArrNode!.querySelector('[data-json-node="value-node"]')?.textContent ?? ""; + + expect(firstAfter).not.toBe(firstBefore); + }); +}); \ No newline at end of file