forked from mengyxu/noob-components
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1075 lines
28 KiB
1075 lines
28 KiB
|
3 months ago
|
<template>
|
||
|
|
<div
|
||
|
|
class="json-view"
|
||
|
|
:class="[
|
||
|
|
`json-view--${props.theme}`,
|
||
|
|
{
|
||
|
|
'json-view--suppress-menu-hover': suppressMenuHover,
|
||
|
|
'json-view--wrap-lines': wrapsLines,
|
||
|
|
'json-view--show-line': props.showLine,
|
||
|
|
},
|
||
|
|
]"
|
||
|
|
>
|
||
|
|
<div
|
||
|
|
ref="viewportRef"
|
||
|
|
class="json-view__viewport"
|
||
|
|
:style="viewportStyle"
|
||
|
|
role="tree"
|
||
|
|
aria-label="JSON viewer"
|
||
|
|
@pointermove="handleViewportPointerMove"
|
||
|
|
@pointerleave="clearMenuHoverSuppression"
|
||
|
|
@scroll="handleScroll"
|
||
|
|
>
|
||
|
|
<div v-if="props.virtual" class="json-view__spacer" :style="{ height: `${virtualTotalHeight}px` }">
|
||
|
|
<div
|
||
|
|
v-for="row in renderedRows"
|
||
|
|
:key="row.node.id"
|
||
|
|
class="json-view__row"
|
||
|
|
:style="getRowStyle(row)"
|
||
|
|
role="treeitem"
|
||
|
|
:aria-level="row.node.level + 1"
|
||
|
|
:aria-setsize="row.node.setSize"
|
||
|
|
:aria-posinset="row.node.posInSet"
|
||
|
|
:aria-expanded="row.node.hasToggle ? row.node.isExpanded : undefined"
|
||
|
|
tabindex="-1"
|
||
|
|
@click="emit('nodeClick', row.node)"
|
||
|
|
@mouseenter="emit('nodeMouseover', row.node)"
|
||
|
|
>
|
||
|
|
<div v-if="props.showLineNumber" class="json-view__line-number" :style="lineNumberStyle">
|
||
|
|
{{ getLineNumber(row.node, row.index) }}
|
||
|
|
</div>
|
||
|
|
<div class="json-view__line" :style="getLineStyle(row.node)">
|
||
|
|
<button
|
||
|
|
v-if="props.showIcon && row.node.hasToggle"
|
||
|
|
class="json-view__toggle"
|
||
|
|
type="button"
|
||
|
|
:class="{ 'is-expanded': row.node.isExpanded }"
|
||
|
|
:aria-label="row.node.isExpanded ? 'Collapse node' : 'Expand node'"
|
||
|
|
@click.stop="toggleNode(row.node, 'icon')"
|
||
|
|
></button>
|
||
|
|
|
||
|
|
<span v-if="hasKey(row.node)" class="json-view__key">
|
||
|
|
<KeyRenderer :node="row.node" />
|
||
|
|
<span class="json-view__colon">{{ props.showKeyValueSpace ? ": " : ":" }}</span>
|
||
|
|
</span>
|
||
|
|
|
||
|
|
<span
|
||
|
|
class="json-view__value"
|
||
|
|
:class="getValueClass(row.node)"
|
||
|
|
@click.stop="handleBracketClick(row.node)"
|
||
|
|
>
|
||
|
|
<ValueRenderer :node="row.node" />
|
||
|
|
</span>
|
||
|
|
|
||
|
|
<span v-if="props.showLength && shouldShowLength(row.node)" class="json-view__length">
|
||
|
|
({{ row.node.length }})
|
||
|
|
</span>
|
||
|
|
|
||
|
|
<ActionsRenderer v-if="hasActionsRenderer" :node="row.node" />
|
||
|
|
|
||
|
|
<div v-if="showsMenu" class="json-view__menu-shell">
|
||
|
|
<button
|
||
|
|
class="json-view__menu-trigger"
|
||
|
|
type="button"
|
||
|
|
aria-haspopup="menu"
|
||
|
|
:aria-expanded="isMenuOpen(row.node)"
|
||
|
|
aria-label="Open node menu"
|
||
|
|
@click.stop="openMenu(row.node, $event)"
|
||
|
|
></button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div v-else class="json-view__list">
|
||
|
|
<div
|
||
|
|
v-for="row in renderedRows"
|
||
|
|
:key="row.node.id"
|
||
|
|
class="json-view__row"
|
||
|
|
:style="getRowStyle(row)"
|
||
|
|
role="treeitem"
|
||
|
|
:aria-level="row.node.level + 1"
|
||
|
|
:aria-setsize="row.node.setSize"
|
||
|
|
:aria-posinset="row.node.posInSet"
|
||
|
|
:aria-expanded="row.node.hasToggle ? row.node.isExpanded : undefined"
|
||
|
|
tabindex="-1"
|
||
|
|
@click="emit('nodeClick', row.node)"
|
||
|
|
@mouseenter="emit('nodeMouseover', row.node)"
|
||
|
|
>
|
||
|
|
<div v-if="props.showLineNumber" class="json-view__line-number" :style="lineNumberStyle">
|
||
|
|
{{ getLineNumber(row.node, row.index) }}
|
||
|
|
</div>
|
||
|
|
<div class="json-view__line" :style="getLineStyle(row.node)">
|
||
|
|
<button
|
||
|
|
v-if="props.showIcon && row.node.hasToggle"
|
||
|
|
class="json-view__toggle"
|
||
|
|
type="button"
|
||
|
|
:class="{ 'is-expanded': row.node.isExpanded }"
|
||
|
|
:aria-label="row.node.isExpanded ? 'Collapse node' : 'Expand node'"
|
||
|
|
@click.stop="toggleNode(row.node, 'icon')"
|
||
|
|
></button>
|
||
|
|
|
||
|
|
<span v-if="hasKey(row.node)" class="json-view__key">
|
||
|
|
<KeyRenderer :node="row.node" />
|
||
|
|
<span class="json-view__colon">{{ props.showKeyValueSpace ? ": " : ":" }}</span>
|
||
|
|
</span>
|
||
|
|
|
||
|
|
<span
|
||
|
|
class="json-view__value"
|
||
|
|
:class="getValueClass(row.node)"
|
||
|
|
@click.stop="handleBracketClick(row.node)"
|
||
|
|
>
|
||
|
|
<ValueRenderer :node="row.node" />
|
||
|
|
</span>
|
||
|
|
|
||
|
|
<span v-if="props.showLength && shouldShowLength(row.node)" class="json-view__length">
|
||
|
|
({{ row.node.length }})
|
||
|
|
</span>
|
||
|
|
|
||
|
|
<ActionsRenderer v-if="hasActionsRenderer" :node="row.node" />
|
||
|
|
|
||
|
|
<div v-if="showsMenu" class="json-view__menu-shell">
|
||
|
|
<button
|
||
|
|
class="json-view__menu-trigger"
|
||
|
|
type="button"
|
||
|
|
aria-haspopup="menu"
|
||
|
|
:aria-expanded="isMenuOpen(row.node)"
|
||
|
|
aria-label="Open node menu"
|
||
|
|
@click.stop="openMenu(row.node, $event)"
|
||
|
|
></button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<el-dropdown
|
||
|
|
v-if="showsMenu && activeMenuTrigger"
|
||
|
|
ref="sharedMenuRef"
|
||
|
|
class="json-view__shared-menu"
|
||
|
|
trigger="click"
|
||
|
|
placement="bottom-end"
|
||
|
|
popper-class="json-view__menu-dropdown"
|
||
|
|
:popper-options="{ strategy: 'fixed' }"
|
||
|
|
:virtual-ref="activeMenuTrigger"
|
||
|
|
virtual-triggering
|
||
|
|
@command="handleMenuCommand"
|
||
|
|
@visible-change="handleMenuVisibleChange"
|
||
|
|
>
|
||
|
|
<span class="json-view__shared-menu-anchor" aria-hidden="true"></span>
|
||
|
|
|
||
|
|
<template #dropdown>
|
||
|
|
<el-dropdown-menu>
|
||
|
|
<el-dropdown-item
|
||
|
|
v-for="item in activeMenuItems"
|
||
|
|
:key="item.key"
|
||
|
|
:command="item.key"
|
||
|
|
:disabled="item.disabled"
|
||
|
|
>
|
||
|
|
{{ item.label }}
|
||
|
|
</el-dropdown-item>
|
||
|
|
</el-dropdown-menu>
|
||
|
|
</template>
|
||
|
|
</el-dropdown>
|
||
|
|
</div>
|
||
|
|
</template>
|
||
|
|
|
||
|
|
<script lang="tsx" setup>
|
||
|
|
import { computed, nextTick, onMounted, onUnmounted, ref, useSlots, type CSSProperties, type Slot } from "vue";
|
||
|
|
import type { DropdownInstance } from "element-plus";
|
||
|
|
import { measureShrinkWrapWidth, measureTextHeight } from "../list-table-v2/measureText";
|
||
|
|
import { useVirtualRows } from "../list-table-v2/useVirtualRows";
|
||
|
|
import { buildVisibleJsonRows } from "./flattenJson";
|
||
|
|
import type { JsonViewMenuActionContext, JsonViewMenuItem, JsonViewNode, JsonViewProps } from "./types";
|
||
|
|
|
||
|
|
const DEFAULT_FONT = "13px ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace";
|
||
|
|
const DEFAULT_LINE_HEIGHT = 20;
|
||
|
|
const ROW_VERTICAL_PADDING = 0;
|
||
|
|
const LINE_NUMBER_PADDING_START = 6;
|
||
|
|
const LINE_NUMBER_PADDING_END = 6;
|
||
|
|
const MIN_LINE_NUMBER_WIDTH = 24;
|
||
|
|
const TOGGLE_WIDTH = 20;
|
||
|
|
const ROW_GAP = 4;
|
||
|
|
const MENU_TRIGGER_WIDTH = 18;
|
||
|
|
const EXTRA_GUTTER = 32;
|
||
|
|
|
||
|
|
interface RenderedRow {
|
||
|
|
index: number;
|
||
|
|
node: JsonViewNode;
|
||
|
|
height: number;
|
||
|
|
offsetY?: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
const slots = useSlots() as {
|
||
|
|
renderNodeKey?: Slot;
|
||
|
|
renderNodeValue?: Slot;
|
||
|
|
renderNodeActions?: Slot;
|
||
|
|
};
|
||
|
|
|
||
|
|
const props = withDefaults(defineProps<JsonViewProps>(), {
|
||
|
|
data: undefined,
|
||
|
|
rootPath: "root",
|
||
|
|
indent: 2,
|
||
|
|
collapsedNodeLength: undefined,
|
||
|
|
deep: undefined,
|
||
|
|
showLength: false,
|
||
|
|
showLine: true,
|
||
|
|
showLineNumber: false,
|
||
|
|
showIcon: false,
|
||
|
|
showDoubleQuotes: true,
|
||
|
|
showKeyValueSpace: true,
|
||
|
|
virtual: false,
|
||
|
|
height: 400,
|
||
|
|
itemHeight: 20,
|
||
|
|
dynamicHeight: true,
|
||
|
|
collapsedOnClickBrackets: true,
|
||
|
|
theme: "light",
|
||
|
|
renderNodeKey: undefined,
|
||
|
|
renderNodeValue: undefined,
|
||
|
|
renderNodeActions: undefined,
|
||
|
|
showMenu: true,
|
||
|
|
menuItems: undefined,
|
||
|
|
});
|
||
|
|
|
||
|
|
const emit = defineEmits<{
|
||
|
|
(e: "nodeClick", node: JsonViewNode): void;
|
||
|
|
(e: "nodeMouseover", node: JsonViewNode): void;
|
||
|
|
(e: "bracketsClick", collapsed: boolean, node: JsonViewNode): void;
|
||
|
|
(e: "iconClick", collapsed: boolean, node: JsonViewNode): void;
|
||
|
|
}>();
|
||
|
|
|
||
|
|
const viewportRef = ref<HTMLElement | null>(null);
|
||
|
|
const viewportWidth = ref(0);
|
||
|
|
let resizeObserver: ResizeObserver | null = null;
|
||
|
|
const sharedMenuRef = ref<DropdownInstance>();
|
||
|
|
|
||
|
|
const expandedState = ref(new Map<string, boolean>());
|
||
|
|
const activeMenuNode = ref<JsonViewNode | null>(null);
|
||
|
|
const activeMenuTrigger = ref<HTMLElement | null>(null);
|
||
|
|
const suppressMenuHover = ref(false);
|
||
|
|
|
||
|
|
const indentSize = computed(() => Math.max(props.indent, 1) * 8);
|
||
|
|
|
||
|
|
const visibleNodes = computed(() =>
|
||
|
|
buildVisibleJsonRows(props.data, {
|
||
|
|
rootPath: props.rootPath,
|
||
|
|
deep: props.deep,
|
||
|
|
collapsedNodeLength: props.collapsedNodeLength,
|
||
|
|
expandedState: expandedState.value,
|
||
|
|
})
|
||
|
|
);
|
||
|
|
|
||
|
|
const hasActionsRenderer = computed(() => Boolean(props.renderNodeActions || slots.renderNodeActions));
|
||
|
|
const hasCustomRenderers = computed(() =>
|
||
|
|
Boolean(
|
||
|
|
props.renderNodeKey ||
|
||
|
|
props.renderNodeValue ||
|
||
|
|
props.renderNodeActions ||
|
||
|
|
slots.renderNodeKey ||
|
||
|
|
slots.renderNodeValue ||
|
||
|
|
slots.renderNodeActions
|
||
|
|
)
|
||
|
|
);
|
||
|
|
const wrapsLines = computed(() => props.dynamicHeight && !hasCustomRenderers.value);
|
||
|
|
const showsMenu = computed(() => props.showMenu);
|
||
|
|
const maxVisibleLineNumber = computed(() =>
|
||
|
|
visibleNodes.value.reduce((maxLineNumber, node) => Math.max(maxLineNumber, node.lineNumber), 1)
|
||
|
|
);
|
||
|
|
const lineNumberWidth = computed(() =>
|
||
|
|
Math.max(
|
||
|
|
MIN_LINE_NUMBER_WIDTH,
|
||
|
|
Math.ceil(
|
||
|
|
measureShrinkWrapWidth(String(maxVisibleLineNumber.value), DEFAULT_FONT) +
|
||
|
|
LINE_NUMBER_PADDING_START +
|
||
|
|
LINE_NUMBER_PADDING_END
|
||
|
|
)
|
||
|
|
)
|
||
|
|
);
|
||
|
|
const lineNumberStyle = computed<CSSProperties>(() => ({
|
||
|
|
flex: `0 0 ${lineNumberWidth.value}px`,
|
||
|
|
}));
|
||
|
|
const activeMenuItems = computed(() => (activeMenuNode.value ? getMenuItems(activeMenuNode.value) : []));
|
||
|
|
|
||
|
|
function hasKey(node: JsonViewNode) {
|
||
|
|
return node.key !== undefined || node.displayKey !== undefined || node.index !== undefined;
|
||
|
|
}
|
||
|
|
|
||
|
|
function shouldShowLength(node: JsonViewNode) {
|
||
|
|
return node.type === "objectCollapsed" || node.type === "arrayCollapsed";
|
||
|
|
}
|
||
|
|
|
||
|
|
function getDefaultKeyText(node: JsonViewNode) {
|
||
|
|
if (node.displayKey !== undefined) {
|
||
|
|
return node.displayKey;
|
||
|
|
}
|
||
|
|
if (node.index !== undefined) {
|
||
|
|
return String(node.index);
|
||
|
|
}
|
||
|
|
if (node.key === undefined) {
|
||
|
|
return "";
|
||
|
|
}
|
||
|
|
return props.showDoubleQuotes ? JSON.stringify(node.key) : node.key;
|
||
|
|
}
|
||
|
|
|
||
|
|
function getDefaultValueText(node: JsonViewNode) {
|
||
|
|
switch (node.type) {
|
||
|
|
case "objectEnd":
|
||
|
|
case "objectCollapsed":
|
||
|
|
case "arrayEnd":
|
||
|
|
case "arrayCollapsed":
|
||
|
|
case "content":
|
||
|
|
return node.showComma ? `${node.content},` : node.content;
|
||
|
|
case "objectStart":
|
||
|
|
case "arrayStart":
|
||
|
|
default:
|
||
|
|
return node.content;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function getMeasurementText(node: JsonViewNode) {
|
||
|
|
const key = getDefaultKeyText(node);
|
||
|
|
const value = getDefaultValueText(node);
|
||
|
|
const prefix = key ? `${key}${props.showKeyValueSpace ? ": " : ":"}` : "";
|
||
|
|
const suffix = props.showLength && shouldShowLength(node) ? ` (${node.length})` : "";
|
||
|
|
|
||
|
|
return {
|
||
|
|
prefix,
|
||
|
|
value,
|
||
|
|
suffix,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function getAvailableWidth(node: JsonViewNode) {
|
||
|
|
const currentLineNumberWidth = props.showLineNumber ? lineNumberWidth.value : 0;
|
||
|
|
const toggleWidth = props.showIcon ? TOGGLE_WIDTH + ROW_GAP : 0;
|
||
|
|
const menuWidth = showsMenu.value ? MENU_TRIGGER_WIDTH + ROW_GAP : 0;
|
||
|
|
const indentWidth = node.level * indentSize.value;
|
||
|
|
return Math.max(
|
||
|
|
80,
|
||
|
|
viewportWidth.value - currentLineNumberWidth - toggleWidth - menuWidth - indentWidth - EXTRA_GUTTER
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function getLineNumber(node: JsonViewNode, fallbackIndex: number) {
|
||
|
|
return node.lineNumber || fallbackIndex + 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
function getMeasurementHeight(node: JsonViewNode) {
|
||
|
|
const availableWidth = getAvailableWidth(node);
|
||
|
|
const { prefix, value, suffix } = getMeasurementText(node);
|
||
|
|
const suffixWithGap = suffix ? `${props.showLength ? " " : ""}${suffix.trimStart()}` : "";
|
||
|
|
const reservedWidth =
|
||
|
|
(prefix ? measureShrinkWrapWidth(prefix, DEFAULT_FONT) : 0) +
|
||
|
|
(suffixWithGap ? measureShrinkWrapWidth(suffixWithGap, DEFAULT_FONT) : 0);
|
||
|
|
const valueWidth = Math.max(80, availableWidth - reservedWidth);
|
||
|
|
const measurementText = `${value}${suffixWithGap}`;
|
||
|
|
|
||
|
|
return (
|
||
|
|
measureTextHeight(measurementText, DEFAULT_FONT, valueWidth, DEFAULT_LINE_HEIGHT) + ROW_VERTICAL_PADDING * 2
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
const rowHeights = computed(() => {
|
||
|
|
if (!wrapsLines.value || viewportWidth.value <= 0) {
|
||
|
|
return visibleNodes.value.map(() => props.itemHeight);
|
||
|
|
}
|
||
|
|
|
||
|
|
return visibleNodes.value.map((node) =>
|
||
|
|
Math.max(props.itemHeight, getMeasurementHeight(node))
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
const viewportHeight = computed(() => props.height);
|
||
|
|
const virtualizer = useVirtualRows(rowHeights, viewportHeight, { overscan: 8 });
|
||
|
|
const { visibleRows: virtualRows, totalHeight: virtualTotalHeight, onScroll } = virtualizer;
|
||
|
|
|
||
|
|
const renderedRows = computed<RenderedRow[]>(() => {
|
||
|
|
if (props.virtual) {
|
||
|
|
const rows: RenderedRow[] = [];
|
||
|
|
|
||
|
|
for (const row of virtualRows.value) {
|
||
|
|
const node = visibleNodes.value[row.index];
|
||
|
|
if (!node) continue;
|
||
|
|
rows.push({
|
||
|
|
index: row.index,
|
||
|
|
node,
|
||
|
|
height: row.height,
|
||
|
|
offsetY: row.offsetY,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
return rows;
|
||
|
|
}
|
||
|
|
|
||
|
|
return visibleNodes.value.map((node, index) => ({
|
||
|
|
index,
|
||
|
|
node,
|
||
|
|
height: rowHeights.value[index] ?? props.itemHeight,
|
||
|
|
}));
|
||
|
|
});
|
||
|
|
|
||
|
|
const viewportStyle = computed<CSSProperties>(() => ({
|
||
|
|
height: props.virtual ? `${props.height}px` : undefined,
|
||
|
|
maxHeight: !props.virtual ? `${props.height}px` : undefined,
|
||
|
|
}));
|
||
|
|
|
||
|
|
function getLineStyle(node: JsonViewNode): CSSProperties {
|
||
|
|
const indentWidth = Math.max(0, node.level * indentSize.value);
|
||
|
|
const spacerWidth = props.showIcon && !node.hasToggle ? TOGGLE_WIDTH + ROW_GAP : 0;
|
||
|
|
|
||
|
|
return {
|
||
|
|
"--json-indent-size": `${indentSize.value}px`,
|
||
|
|
"--json-guides-width": `${indentWidth}px`,
|
||
|
|
"--json-leading-width": `${indentWidth + spacerWidth}px`,
|
||
|
|
paddingInlineStart: `${indentWidth + spacerWidth}px`,
|
||
|
|
} as CSSProperties;
|
||
|
|
}
|
||
|
|
|
||
|
|
function getRowStyle(row: RenderedRow): CSSProperties {
|
||
|
|
const style: CSSProperties = {
|
||
|
|
height: `${row.height}px`,
|
||
|
|
};
|
||
|
|
|
||
|
|
if (props.virtual) {
|
||
|
|
style.position = "absolute";
|
||
|
|
style.left = "0";
|
||
|
|
style.right = "0";
|
||
|
|
style.transform = `translateY(${row.offsetY ?? 0}px)`;
|
||
|
|
}
|
||
|
|
|
||
|
|
return style;
|
||
|
|
}
|
||
|
|
|
||
|
|
function updateExpandedState(path: string, nextExpanded: boolean) {
|
||
|
|
const next = new Map(expandedState.value);
|
||
|
|
next.set(path, nextExpanded);
|
||
|
|
expandedState.value = next;
|
||
|
|
}
|
||
|
|
|
||
|
|
function toggleNode(node: JsonViewNode, source: "icon" | "brackets") {
|
||
|
|
if (!node.hasToggle) return;
|
||
|
|
const nextExpanded = !node.isExpanded;
|
||
|
|
updateExpandedState(node.path, nextExpanded);
|
||
|
|
const collapsed = !nextExpanded;
|
||
|
|
|
||
|
|
if (source === "icon") {
|
||
|
|
emit("iconClick", collapsed, node);
|
||
|
|
} else {
|
||
|
|
emit("bracketsClick", collapsed, node);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function handleBracketClick(node: JsonViewNode) {
|
||
|
|
if (!props.collapsedOnClickBrackets || !node.hasToggle) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
toggleNode(node, "brackets");
|
||
|
|
}
|
||
|
|
|
||
|
|
function handleScroll(event: Event) {
|
||
|
|
closeMenu();
|
||
|
|
if (!props.virtual) return;
|
||
|
|
onScroll((event.target as HTMLElement).scrollTop);
|
||
|
|
}
|
||
|
|
|
||
|
|
function getValueClass(node: JsonViewNode) {
|
||
|
|
if (node.type !== "content") {
|
||
|
|
return "json-view__value--container";
|
||
|
|
}
|
||
|
|
|
||
|
|
if (typeof node.value === "string") return "json-view__value--string";
|
||
|
|
if (typeof node.value === "number") return "json-view__value--number";
|
||
|
|
if (typeof node.value === "boolean") return "json-view__value--boolean";
|
||
|
|
if (node.value === null) return "json-view__value--null";
|
||
|
|
if (node.value === undefined) return "json-view__value--undefined";
|
||
|
|
return "json-view__value--container";
|
||
|
|
}
|
||
|
|
|
||
|
|
const KeyRenderer = ({ node }: { node: JsonViewNode }) => {
|
||
|
|
const defaultKey = getDefaultKeyText(node);
|
||
|
|
if (slots.renderNodeKey) {
|
||
|
|
return slots.renderNodeKey({ node, defaultKey });
|
||
|
|
}
|
||
|
|
if (props.renderNodeKey) {
|
||
|
|
return props.renderNodeKey({ node, defaultKey });
|
||
|
|
}
|
||
|
|
return defaultKey;
|
||
|
|
};
|
||
|
|
|
||
|
|
const ValueRenderer = ({ node }: { node: JsonViewNode }) => {
|
||
|
|
const defaultValue = getDefaultValueText(node);
|
||
|
|
if (slots.renderNodeValue) {
|
||
|
|
return slots.renderNodeValue({ node, defaultValue });
|
||
|
|
}
|
||
|
|
if (props.renderNodeValue) {
|
||
|
|
return props.renderNodeValue({ node, defaultValue });
|
||
|
|
}
|
||
|
|
return defaultValue;
|
||
|
|
};
|
||
|
|
|
||
|
|
const ActionsRenderer = ({ node }: { node: JsonViewNode }) => {
|
||
|
|
const defaultActions = null;
|
||
|
|
if (slots.renderNodeActions) {
|
||
|
|
return slots.renderNodeActions({ node, defaultActions });
|
||
|
|
}
|
||
|
|
if (props.renderNodeActions) {
|
||
|
|
return props.renderNodeActions({ node, defaultActions });
|
||
|
|
}
|
||
|
|
return null;
|
||
|
|
};
|
||
|
|
|
||
|
|
function toSerializableValue(value: unknown, ancestors = new WeakSet<object>()): unknown {
|
||
|
|
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean" || value === null) {
|
||
|
|
return value;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (typeof value === "bigint") {
|
||
|
|
return `${String(value)}n`;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (value === undefined) {
|
||
|
|
return "undefined";
|
||
|
|
}
|
||
|
|
|
||
|
|
if (typeof value === "symbol") {
|
||
|
|
return value.toString();
|
||
|
|
}
|
||
|
|
|
||
|
|
if (typeof value === "function") {
|
||
|
|
return value.name ? `[Function ${value.name}]` : "[Function]";
|
||
|
|
}
|
||
|
|
|
||
|
|
if (typeof value !== "object") {
|
||
|
|
return String(value);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (ancestors.has(value)) {
|
||
|
|
return "[Circular]";
|
||
|
|
}
|
||
|
|
|
||
|
|
ancestors.add(value);
|
||
|
|
|
||
|
|
if (Array.isArray(value)) {
|
||
|
|
const serialized = value.map((entry) => toSerializableValue(entry, ancestors));
|
||
|
|
ancestors.delete(value);
|
||
|
|
return serialized;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (value instanceof Set) {
|
||
|
|
const serialized = { $set: Array.from(value, (entry) => toSerializableValue(entry, ancestors)) };
|
||
|
|
ancestors.delete(value);
|
||
|
|
return serialized;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (value instanceof Map) {
|
||
|
|
const serialized = {
|
||
|
|
$map: Array.from(value, ([key, entry]) => [
|
||
|
|
toSerializableValue(key, ancestors),
|
||
|
|
toSerializableValue(entry, ancestors),
|
||
|
|
]),
|
||
|
|
};
|
||
|
|
ancestors.delete(value);
|
||
|
|
return serialized;
|
||
|
|
}
|
||
|
|
|
||
|
|
const serialized = Object.fromEntries(
|
||
|
|
Object.entries(value).map(([key, entry]) => [key, toSerializableValue(entry, ancestors)])
|
||
|
|
);
|
||
|
|
ancestors.delete(value);
|
||
|
|
return serialized;
|
||
|
|
}
|
||
|
|
|
||
|
|
function serializeNodeValue(value: unknown): string {
|
||
|
|
if (typeof value === "string") {
|
||
|
|
return value;
|
||
|
|
}
|
||
|
|
if (value === undefined) {
|
||
|
|
return "undefined";
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
const json = JSON.stringify(toSerializableValue(value), null, 2);
|
||
|
|
if (json !== undefined) {
|
||
|
|
return json;
|
||
|
|
}
|
||
|
|
} catch {
|
||
|
|
// Fall through to String() for circular or non-JSON-safe values.
|
||
|
|
}
|
||
|
|
return String(value);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function copyText(text: string) {
|
||
|
|
try {
|
||
|
|
if (navigator.clipboard?.writeText) {
|
||
|
|
await navigator.clipboard.writeText(text);
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
} catch {
|
||
|
|
// Fall back to execCommand below.
|
||
|
|
}
|
||
|
|
|
||
|
|
const textarea = document.createElement("textarea");
|
||
|
|
textarea.value = text;
|
||
|
|
textarea.setAttribute("readonly", "true");
|
||
|
|
textarea.style.position = "fixed";
|
||
|
|
textarea.style.opacity = "0";
|
||
|
|
document.body.appendChild(textarea);
|
||
|
|
textarea.select();
|
||
|
|
|
||
|
|
try {
|
||
|
|
return document.execCommand("copy");
|
||
|
|
} finally {
|
||
|
|
document.body.removeChild(textarea);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function createMenuContext(node: JsonViewNode): JsonViewMenuActionContext {
|
||
|
|
return {
|
||
|
|
node,
|
||
|
|
path: getDisplayPath(node.path),
|
||
|
|
value: node.value,
|
||
|
|
copy: copyText,
|
||
|
|
closeMenu,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function getDisplayPath(path: string) {
|
||
|
|
if (path === props.rootPath) {
|
||
|
|
return "$";
|
||
|
|
}
|
||
|
|
|
||
|
|
if (path.startsWith(`${props.rootPath}.`)) {
|
||
|
|
return `$${path.slice(props.rootPath.length)}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (path.startsWith(`${props.rootPath}[`)) {
|
||
|
|
return `$${path.slice(props.rootPath.length)}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
return path;
|
||
|
|
}
|
||
|
|
|
||
|
|
function getMenuItems(node: JsonViewNode): JsonViewMenuItem[] {
|
||
|
|
const context = createMenuContext(node);
|
||
|
|
const defaultItems: JsonViewMenuItem[] = [
|
||
|
|
{
|
||
|
|
key: "copy-value",
|
||
|
|
label: "Copy Value",
|
||
|
|
onSelect: async ({ value, copy }) => {
|
||
|
|
await copy(serializeNodeValue(value));
|
||
|
|
},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
key: "copy-path",
|
||
|
|
label: "Copy Path",
|
||
|
|
onSelect: async ({ path, copy }) => {
|
||
|
|
await copy(path);
|
||
|
|
},
|
||
|
|
},
|
||
|
|
];
|
||
|
|
|
||
|
|
if (!props.menuItems) {
|
||
|
|
return defaultItems;
|
||
|
|
}
|
||
|
|
|
||
|
|
const customItems = Array.isArray(props.menuItems) ? props.menuItems : props.menuItems(context);
|
||
|
|
return [...defaultItems, ...customItems];
|
||
|
|
}
|
||
|
|
|
||
|
|
async function handleMenuItemClick(item: JsonViewMenuItem, node: JsonViewNode) {
|
||
|
|
if (item.disabled) return;
|
||
|
|
await item.onSelect?.(createMenuContext(node));
|
||
|
|
closeMenu();
|
||
|
|
}
|
||
|
|
|
||
|
|
function isMenuOpen(node: JsonViewNode) {
|
||
|
|
return activeMenuNode.value?.id === node.id;
|
||
|
|
}
|
||
|
|
|
||
|
|
function closeMenu() {
|
||
|
|
sharedMenuRef.value?.handleClose();
|
||
|
|
suppressMenuHover.value = true;
|
||
|
|
activeMenuTrigger.value?.blur();
|
||
|
|
activeMenuNode.value = null;
|
||
|
|
activeMenuTrigger.value = null;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function openMenu(node: JsonViewNode, event: MouseEvent) {
|
||
|
|
const trigger = event.currentTarget;
|
||
|
|
if (!(trigger instanceof HTMLElement)) return;
|
||
|
|
|
||
|
|
if (isMenuOpen(node)) {
|
||
|
|
closeMenu();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
activeMenuNode.value = node;
|
||
|
|
activeMenuTrigger.value = trigger;
|
||
|
|
suppressMenuHover.value = false;
|
||
|
|
await nextTick();
|
||
|
|
sharedMenuRef.value?.handleOpen();
|
||
|
|
}
|
||
|
|
|
||
|
|
function handleMenuVisibleChange(visible: boolean) {
|
||
|
|
if (!visible) {
|
||
|
|
suppressMenuHover.value = true;
|
||
|
|
activeMenuTrigger.value?.blur();
|
||
|
|
activeMenuNode.value = null;
|
||
|
|
activeMenuTrigger.value = null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function handleMenuCommand(command: string | number | object) {
|
||
|
|
if (!activeMenuNode.value) return;
|
||
|
|
const item = activeMenuItems.value.find((entry) => entry.key === command);
|
||
|
|
if (!item) return;
|
||
|
|
void handleMenuItemClick(item, activeMenuNode.value);
|
||
|
|
}
|
||
|
|
|
||
|
|
function handleViewportPointerMove() {
|
||
|
|
if (suppressMenuHover.value) {
|
||
|
|
suppressMenuHover.value = false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function clearMenuHoverSuppression() {
|
||
|
|
suppressMenuHover.value = false;
|
||
|
|
}
|
||
|
|
|
||
|
|
onMounted(() => {
|
||
|
|
if (viewportRef.value) {
|
||
|
|
viewportWidth.value = viewportRef.value.getBoundingClientRect().width;
|
||
|
|
}
|
||
|
|
|
||
|
|
resizeObserver = new ResizeObserver((entries) => {
|
||
|
|
for (const entry of entries) {
|
||
|
|
viewportWidth.value = entry.contentRect.width;
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
if (viewportRef.value) {
|
||
|
|
resizeObserver.observe(viewportRef.value);
|
||
|
|
}
|
||
|
|
|
||
|
|
});
|
||
|
|
|
||
|
|
onUnmounted(() => {
|
||
|
|
if (resizeObserver) {
|
||
|
|
resizeObserver.disconnect();
|
||
|
|
resizeObserver = null;
|
||
|
|
}
|
||
|
|
});
|
||
|
|
</script>
|
||
|
|
|
||
|
|
<style lang="scss" scoped>
|
||
|
|
.json-view {
|
||
|
|
--json-bg: #fff;
|
||
|
|
--json-border: #dfe4ea;
|
||
|
|
--json-color: #1f2937;
|
||
|
|
--json-muted: #6b7280;
|
||
|
|
--json-key: #7c3aed;
|
||
|
|
--json-string: #0f766e;
|
||
|
|
--json-number: #c2410c;
|
||
|
|
--json-boolean: #2563eb;
|
||
|
|
--json-null: #9333ea;
|
||
|
|
--json-guide: rgba(148, 163, 184, 0.26);
|
||
|
|
--json-hover: rgba(51, 103, 209, 0.08);
|
||
|
|
--json-toggle: #64748b;
|
||
|
|
--json-menu-bg: rgba(255, 255, 255, 0.96);
|
||
|
|
--json-menu-border: rgba(203, 213, 225, 0.9);
|
||
|
|
--json-menu-shadow: 0 12px 32px rgba(15, 23, 42, 0.12);
|
||
|
|
--json-row-gap: 4px;
|
||
|
|
background: var(--json-bg);
|
||
|
|
border: 1px solid var(--json-border);
|
||
|
|
border-radius: 10px;
|
||
|
|
color: var(--json-color);
|
||
|
|
font: 13px/20px ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace;
|
||
|
|
}
|
||
|
|
|
||
|
|
.json-view--dark {
|
||
|
|
--json-bg: #0f172a;
|
||
|
|
--json-border: #243041;
|
||
|
|
--json-color: #e2e8f0;
|
||
|
|
--json-muted: #94a3b8;
|
||
|
|
--json-key: #c084fc;
|
||
|
|
--json-string: #34d399;
|
||
|
|
--json-number: #fb923c;
|
||
|
|
--json-boolean: #60a5fa;
|
||
|
|
--json-null: #d8b4fe;
|
||
|
|
--json-guide: rgba(148, 163, 184, 0.18);
|
||
|
|
--json-hover: rgba(96, 165, 250, 0.12);
|
||
|
|
--json-toggle: #94a3b8;
|
||
|
|
--json-menu-bg: rgba(15, 23, 42, 0.96);
|
||
|
|
--json-menu-border: rgba(71, 85, 105, 0.9);
|
||
|
|
--json-menu-shadow: 0 12px 32px rgba(2, 6, 23, 0.45);
|
||
|
|
}
|
||
|
|
|
||
|
|
.json-view__viewport {
|
||
|
|
overflow-y: auto;
|
||
|
|
overflow-x: auto;
|
||
|
|
position: relative;
|
||
|
|
min-height: 80px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.json-view--wrap-lines .json-view__viewport {
|
||
|
|
overflow-x: hidden;
|
||
|
|
}
|
||
|
|
|
||
|
|
.json-view__spacer {
|
||
|
|
position: relative;
|
||
|
|
}
|
||
|
|
|
||
|
|
.json-view__list {
|
||
|
|
position: relative;
|
||
|
|
}
|
||
|
|
|
||
|
|
.json-view__row {
|
||
|
|
display: flex;
|
||
|
|
align-items: stretch;
|
||
|
|
min-width: max-content;
|
||
|
|
}
|
||
|
|
|
||
|
|
.json-view--wrap-lines .json-view__row {
|
||
|
|
min-width: 100%;
|
||
|
|
}
|
||
|
|
|
||
|
|
.json-view__row:hover {
|
||
|
|
background: var(--json-hover);
|
||
|
|
}
|
||
|
|
|
||
|
|
.json-view__line-number {
|
||
|
|
box-sizing: border-box;
|
||
|
|
color: var(--json-muted);
|
||
|
|
padding: 0 6px 0 6px;
|
||
|
|
text-align: right;
|
||
|
|
user-select: none;
|
||
|
|
}
|
||
|
|
|
||
|
|
.json-view__line {
|
||
|
|
align-items: flex-start;
|
||
|
|
box-sizing: border-box;
|
||
|
|
display: flex;
|
||
|
|
flex: 1;
|
||
|
|
gap: var(--json-row-gap);
|
||
|
|
min-height: 100%;
|
||
|
|
min-width: 0;
|
||
|
|
padding: 0 12px 0 8px;
|
||
|
|
position: relative;
|
||
|
|
white-space: nowrap;
|
||
|
|
}
|
||
|
|
|
||
|
|
.json-view--wrap-lines .json-view__line {
|
||
|
|
width: 100%;
|
||
|
|
}
|
||
|
|
|
||
|
|
.json-view--show-line .json-view__line::before {
|
||
|
|
background-image: repeating-linear-gradient(
|
||
|
|
to right,
|
||
|
|
transparent 0 calc(var(--json-indent-size) - 1px),
|
||
|
|
var(--json-guide) calc(var(--json-indent-size) - 1px) var(--json-indent-size)
|
||
|
|
);
|
||
|
|
background-position: left top;
|
||
|
|
background-repeat: no-repeat;
|
||
|
|
background-size: var(--json-guides-width) 100%;
|
||
|
|
content: "";
|
||
|
|
inset: 0 auto 0 0;
|
||
|
|
pointer-events: none;
|
||
|
|
position: absolute;
|
||
|
|
width: var(--json-leading-width);
|
||
|
|
}
|
||
|
|
|
||
|
|
.json-view__toggle {
|
||
|
|
align-self: flex-start;
|
||
|
|
flex: 0 0 20px;
|
||
|
|
height: 20px;
|
||
|
|
position: relative;
|
||
|
|
width: 20px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.json-view__toggle {
|
||
|
|
background: transparent;
|
||
|
|
border: none;
|
||
|
|
border-radius: 6px;
|
||
|
|
color: var(--json-toggle);
|
||
|
|
cursor: pointer;
|
||
|
|
padding: 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
.json-view__toggle:hover {
|
||
|
|
background: rgba(148, 163, 184, 0.1);
|
||
|
|
}
|
||
|
|
|
||
|
|
.json-view__toggle::before {
|
||
|
|
border-bottom: 1.5px solid currentColor;
|
||
|
|
border-right: 1.5px solid currentColor;
|
||
|
|
content: "";
|
||
|
|
height: 6px;
|
||
|
|
inset: 6px auto auto 6px;
|
||
|
|
position: absolute;
|
||
|
|
transform: rotate(-45deg);
|
||
|
|
transition: transform 0.12s ease;
|
||
|
|
width: 6px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.json-view__toggle.is-expanded::before {
|
||
|
|
transform: rotate(45deg);
|
||
|
|
}
|
||
|
|
|
||
|
|
.json-view__key {
|
||
|
|
color: var(--json-key);
|
||
|
|
flex: 0 1 auto;
|
||
|
|
}
|
||
|
|
|
||
|
|
.json-view__colon {
|
||
|
|
color: var(--json-muted);
|
||
|
|
}
|
||
|
|
|
||
|
|
.json-view__value {
|
||
|
|
color: var(--json-color);
|
||
|
|
flex: 1 1 auto;
|
||
|
|
min-width: 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
.json-view--wrap-lines .json-view__value {
|
||
|
|
overflow-wrap: anywhere;
|
||
|
|
white-space: pre-wrap;
|
||
|
|
word-break: break-word;
|
||
|
|
}
|
||
|
|
|
||
|
|
.json-view--wrap-lines .json-view__key,
|
||
|
|
.json-view--wrap-lines .json-view__length {
|
||
|
|
white-space: nowrap;
|
||
|
|
}
|
||
|
|
|
||
|
|
.json-view:not(.json-view--wrap-lines) .json-view__value {
|
||
|
|
overflow: hidden;
|
||
|
|
text-overflow: ellipsis;
|
||
|
|
}
|
||
|
|
|
||
|
|
.json-view__value--string {
|
||
|
|
color: var(--json-string);
|
||
|
|
}
|
||
|
|
|
||
|
|
.json-view__value--number {
|
||
|
|
color: var(--json-number);
|
||
|
|
}
|
||
|
|
|
||
|
|
.json-view__value--boolean {
|
||
|
|
color: var(--json-boolean);
|
||
|
|
}
|
||
|
|
|
||
|
|
.json-view__value--null,
|
||
|
|
.json-view__value--undefined {
|
||
|
|
color: var(--json-null);
|
||
|
|
}
|
||
|
|
|
||
|
|
.json-view__value--container {
|
||
|
|
color: var(--json-color);
|
||
|
|
}
|
||
|
|
|
||
|
|
.json-view__length {
|
||
|
|
color: var(--json-muted);
|
||
|
|
}
|
||
|
|
|
||
|
|
.json-view__menu-shell {
|
||
|
|
margin-left: auto;
|
||
|
|
position: relative;
|
||
|
|
}
|
||
|
|
|
||
|
|
.json-view__menu-trigger {
|
||
|
|
background: transparent;
|
||
|
|
border: none;
|
||
|
|
border-radius: 6px;
|
||
|
|
color: var(--json-toggle);
|
||
|
|
cursor: pointer;
|
||
|
|
height: 18px;
|
||
|
|
opacity: 0;
|
||
|
|
padding: 0;
|
||
|
|
pointer-events: none;
|
||
|
|
position: relative;
|
||
|
|
transition:
|
||
|
|
opacity 0.12s ease,
|
||
|
|
background-color 0.12s ease;
|
||
|
|
width: 18px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.json-view__menu-trigger::before {
|
||
|
|
background: currentColor;
|
||
|
|
border-radius: 50%;
|
||
|
|
box-shadow:
|
||
|
|
0 -4px 0 currentColor,
|
||
|
|
0 4px 0 currentColor;
|
||
|
|
content: "";
|
||
|
|
height: 3px;
|
||
|
|
inset: 7px auto auto 7px;
|
||
|
|
position: absolute;
|
||
|
|
width: 3px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.json-view:not(.json-view--suppress-menu-hover) .json-view__row:hover .json-view__menu-trigger,
|
||
|
|
.json-view__row:focus-within .json-view__menu-trigger,
|
||
|
|
.json-view__menu-trigger[aria-expanded="true"] {
|
||
|
|
opacity: 1;
|
||
|
|
pointer-events: auto;
|
||
|
|
}
|
||
|
|
|
||
|
|
.json-view__menu-trigger:hover,
|
||
|
|
.json-view__menu-trigger:focus-visible {
|
||
|
|
background: rgba(148, 163, 184, 0.1);
|
||
|
|
outline: none;
|
||
|
|
}
|
||
|
|
|
||
|
|
:deep(.json-view__menu-dropdown.el-popper) {
|
||
|
|
background: var(--json-menu-bg);
|
||
|
|
border: 1px solid var(--json-menu-border);
|
||
|
|
border-radius: 10px;
|
||
|
|
box-shadow: var(--json-menu-shadow);
|
||
|
|
padding: 6px;
|
||
|
|
}
|
||
|
|
|
||
|
|
:deep(.json-view__menu-dropdown .el-popper__arrow) {
|
||
|
|
display: none;
|
||
|
|
}
|
||
|
|
|
||
|
|
:deep(.json-view__menu-dropdown .el-dropdown-menu) {
|
||
|
|
background: transparent;
|
||
|
|
border: none;
|
||
|
|
box-shadow: none;
|
||
|
|
min-width: 136px;
|
||
|
|
padding: 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
:deep(.json-view__menu-dropdown .el-dropdown-menu__item) {
|
||
|
|
border-radius: 8px;
|
||
|
|
color: inherit;
|
||
|
|
font: inherit;
|
||
|
|
line-height: 1.25;
|
||
|
|
padding: 7px 10px;
|
||
|
|
}
|
||
|
|
|
||
|
|
:deep(.json-view__menu-dropdown .el-dropdown-menu__item:not(.is-disabled):hover),
|
||
|
|
:deep(.json-view__menu-dropdown .el-dropdown-menu__item:focus-visible) {
|
||
|
|
background: rgba(148, 163, 184, 0.12);
|
||
|
|
color: inherit;
|
||
|
|
}
|
||
|
|
|
||
|
|
:deep(.json-view__menu-dropdown .el-dropdown-menu__item.is-disabled) {
|
||
|
|
opacity: 0.5;
|
||
|
|
}
|
||
|
|
|
||
|
|
.json-view__shared-menu {
|
||
|
|
height: 0;
|
||
|
|
left: 0;
|
||
|
|
overflow: hidden;
|
||
|
|
pointer-events: none;
|
||
|
|
position: absolute;
|
||
|
|
top: 0;
|
||
|
|
width: 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
.json-view__shared-menu-anchor {
|
||
|
|
display: block;
|
||
|
|
height: 0;
|
||
|
|
width: 0;
|
||
|
|
}
|
||
|
|
</style>
|