+
\ 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 @@
@@ -8,7 +94,123 @@ import ToolHeader from "~/components/tool-header.vue";
Decode and Inspect Data
- WIP
+
+
+
+
+
+
+
+
+ {{ fileName }}
+
+
+
+
+
+
+
+
+
+
+
+
+ Input hidden for performance
+
+ Large JSON entered.
+ Rendering the editor would impact performance.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Invalid JSON
+ The input can’t be parsed.
+
+
+
+
+
+
+
+ No JSON to display
+ Paste JSON, drop a file, or choose one to get started.
+
+
+
+
+
+
+
+
+
+
+
+ Drop to open JSON
+ (stays in your browser)
+
+
+
+
+
- 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