Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions app/assets/css/main.css
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
@import "tailwindcss";
@plugin "@tailwindcss/forms";

@source "../../../src/**/*.ts";

@custom-variant dark (&:where(.dark, .dark *));

/* Floating Vue tooltip base */
Expand Down Expand Up @@ -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%;
}
247 changes: 247 additions & 0 deletions app/components/json/json-viewer.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
<script setup lang="ts">
import { JsonData } from "~~/src/json/jsonData";
import { ref, nextTick, watch, computed, onBeforeUnmount, onMounted } from "vue";
import type {SearchResult} from "~~/src/json/nodes";

const props = defineProps<{ json: string }>();
const emit = defineEmits<{ (e: "invalid", value: boolean): void }>();

const search = ref("");
const element = ref<HTMLElement>();

let data: JsonData | null = null;

const matches = ref<SearchResult[]>([]);
const matchIndex = ref<number | null>(0);

function clampIndex(i: number, len: number) {
if (len <= 0) {
return 0;
}
return ((i % len) + len) % len;
}

function scrollToMatch(i: number) {
const scroller = element.value!;
const list = matches.value;
if (!list.length) return;

// unhighlight previous
if (matchIndex.value !== null) {
list[matchIndex.value]!.highlight(false);
}

matchIndex.value = clampIndex(i, list.length);
const target = list[matchIndex.value]!;
target.highlight(true);

const y = target.getApproxScrollPosition();

const viewTop = scroller.scrollTop + 100;
const viewBottom = scroller.scrollTop + scroller.clientHeight - 100;

// already in viewport?
if (y >= viewTop && y <= viewBottom) {
return;
}

scroller.scrollTo({ top: Math.max(0, y - 100) });
}

function nextMatch() {
if (matches.value.length > 0) {
scrollToMatch(matchIndex.value! + 1);
}
}
function prevMatch() {
if (matches.value.length > 0) {
scrollToMatch(matchIndex.value! - 1);
}
}

function runSearch() {
matches.value = data!.search(search.value);
matchIndex.value = null;

if (matches.value.length > 0) {
scrollToMatch(0);
}
}

let timerId: number;
function startSearching() {
window.clearTimeout(timerId);
timerId = window.setTimeout(runSearch, 200);
}

onBeforeUnmount(() => {
window.clearTimeout(timerId);
data?.destroy();
});

const isInvalid = computed(() => {
data?.destroy();

search.value = "";
matches.value = [];
matchIndex.value = null;

data = JsonData.makeSafe(props.json);
if (data === null) {
return true;
}

nextTick().then(() => {
data?.init(element.value!);
});

return false;
});

watch(isInvalid, (val) => emit("invalid", val), { immediate: true });

// Keyboard behavior like browser find: Enter / Shift+Enter
function onSearchKeydown(e: KeyboardEvent) {
stopPlaceholderExampleLoop();
if (e.key === "ArrowDown" || e.key === "Enter") {
e.preventDefault();
nextMatch();
} else if (e.key === "ArrowUp") {
e.preventDefault();
prevMatch();
} else if (e.key === "Escape") {
search.value = "";
runSearch();
}
}

const placeholder = ref("Search…");
let placeholderLoopRunning = true;
let placeholderTimers: number[] = [];
const placeholderExamples = [
`"John Doe" = values contain`,
`"=John Doe" = values exact match`,
`"name=John Doe" = key + value contain`,
`"name==John Doe" = key + value exact match`,
`"comments.2.user.id=123" = path contains`,
`"comments.*.user.id=123" = wildcard path contains`,
`"comments.*.user.id==123" = wildcard path exact match`,
];

function stopPlaceholderExampleLoop() {
if (!placeholderLoopRunning) {
return;
}

for (const id of placeholderTimers) {
window.clearTimeout(id);
}
placeholder.value = "Search…";
placeholderLoopRunning = false;
placeholderTimers = [];
}

function schedule(fn: () => void, ms: number) {
const id = window.setTimeout(fn, ms);
placeholderTimers.push(id);
}

function startPlaceholderExampleLoop() {
let idx = Math.floor(Math.random() * (placeholderExamples.length + 1));

const runOne = () => {
if (!placeholderLoopRunning) {
return;
}

const text = placeholderExamples[idx % placeholderExamples.length]!;
idx++;

const cycleStart = performance.now();

placeholder.value = "";
let i = 0;

const typeNext = () => {
if (!placeholderLoopRunning) {
return;
}

if (i < text.length) {
placeholder.value += text[i++];
schedule(typeNext, 45);
} else {
// finished typing; wait the remaining time
const elapsed = performance.now() - cycleStart;
const remaining = Math.max(0, 5000 - elapsed); // 5s
schedule(runOne, remaining);
}
};

schedule(typeNext, 250);
};

runOne();
}

onMounted(() => startPlaceholderExampleLoop());
onBeforeUnmount(() => {
stopPlaceholderExampleLoop();
});
</script>

<template>
<div class="flex flex-col space-y-2 h-full">
<div class="flex space-x-2">
<div class="relative w-full">
<input
type="text"
name="filter"
autocomplete="off"
data-testid="json-viewer-search-input"
v-model="search"
@input="startSearching"
@keydown="onSearchKeydown"
class="bg-gray-100 dark:bg-gray-900 border border-gray-200 dark:border-gray-800 w-full rounded-md p-2 pr-24 text-sm focus:ring-orange-600 focus:border-orange-600"
:placeholder="placeholder"
/>

<div class="absolute inset-y-0 right-2 flex items-center space-x-1 select-none" v-if="search !== ''">
<span class="text-xs tabular-nums text-gray-500 dark:text-gray-400">
{{ matches.length > 0 ? `${matchIndex! + 1} / ${matches.length}` : "0 / 0" }}
</span>

<button
type="button"
class="py-1 px-2 rounded hover:bg-gray-200 dark:hover:bg-gray-800 disabled:opacity-40 cursor-pointer"
:disabled="matches.length === 0"
@click="prevMatch"
title="Previous (Arrow Up)"
>
<svg class="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m5 15 7-7 7 7"/>
</svg>
</button>

<button
type="button"
class="py-1 px-2 rounded hover:bg-gray-200 dark:hover:bg-gray-800 disabled:opacity-40 cursor-pointer"
:disabled="matches.length === 0"
@click="nextMatch"
title="Next (Arrow Down)"
>
<svg class="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m19 9-7 7-7-7"/>
</svg>
</button>
</div>
</div>
</div>

<div
ref="element"
style="overflow-anchor: none;"
class="p-3 bg-gray-100 dark:bg-gray-900 border border-gray-200 dark:border-gray-800 text-sm rounded-md min-w-0 min-h-0 overflow-auto grow-1"
></div>
</div>
</template>
5 changes: 5 additions & 0 deletions app/components/ui/full-height-container.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<template>
<div class="w-full h-[calc(100vh-137px)]">
<slot />
</div>
</template>
27 changes: 19 additions & 8 deletions app/components/ui/split-layout.vue
Original file line number Diff line number Diff line change
Expand Up @@ -126,17 +126,21 @@ 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;

dragging.value = false;
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.
Expand Down Expand Up @@ -170,13 +174,18 @@ function saveSettings(): void {
<template>
<div
ref="rootEl"
class="relative w-full h-full overflow-hidden select-none flex"
:class="horizontal ? 'flex-col' : 'flex-row'"
class="relative w-full h-full overflow-hidden flex"
:class="[
horizontal ? 'flex-col' : 'flex-row',
dragging ? 'select-none' : 'select-text'
]"
@pointerup="onPointerUp"
@pointercancel="onPointerUp"
>
<div class="min-w-0 min-h-0 overflow-auto" :style="firstStyle">
<slot name="first" />
<div class="w-full h-full p-0.5">
<slot name="first" />
</div>
</div>

<div
Expand All @@ -198,7 +207,9 @@ function saveSettings(): void {
class="min-w-0 min-h-0 overflow-auto flex-1"
:style="secondStyle"
>
<slot name="second" />
<div class="w-full h-full p-0.5">
<slot name="second" />
</div>
</div>

<div
Expand Down
Loading
Loading