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.
1078 lines
28 KiB
1078 lines
28 KiB
<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 class="json-view__list" :style="props.virtual ? { height: `${virtualTotalHeight}px` } : undefined"> |
|
<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 { JSON_VIEW_DEFAULT_FONT } from "./inlinePreview"; |
|
import { |
|
formatCircularReference, |
|
isCircularReferenceMarker, |
|
normalizeVueValue, |
|
} from "./normalizeValue"; |
|
import type { JsonViewMenuActionContext, JsonViewMenuItem, JsonViewNode, JsonViewProps } from "./types"; |
|
|
|
const DEFAULT_FONT = JSON_VIEW_DEFAULT_FONT; |
|
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, |
|
inline: false, |
|
maxInlineDiplayWidth: 600, |
|
collapseFullyInlineNode: false, |
|
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, |
|
inline: props.inline, |
|
maxInlineDiplayWidth: props.maxInlineDiplayWidth, |
|
collapseFullyInlineNode: props.collapseFullyInlineNode, |
|
showDoubleQuotes: props.showDoubleQuotes, |
|
showKeyValueSpace: props.showKeyValueSpace, |
|
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 buildDisplayObjectPath(parentPath: string, key: string) { |
|
if (/^[A-Za-z_$][\w$]*$/.test(key)) { |
|
return `${parentPath}.${key}`; |
|
} |
|
|
|
return `${parentPath}[${JSON.stringify(key)}]`; |
|
} |
|
|
|
function buildDisplayArrayPath(parentPath: string, index: number) { |
|
return `${parentPath}[${index}]`; |
|
} |
|
|
|
function buildDisplayMapPath(parentPath: string, key: unknown, index: number) { |
|
if (typeof key === "string") { |
|
return `${parentPath}.get(${JSON.stringify(key)})`; |
|
} |
|
|
|
if (typeof key === "number" || typeof key === "boolean" || typeof key === "bigint") { |
|
return `${parentPath}.get(${String(key)})`; |
|
} |
|
|
|
if (key === null) { |
|
return `${parentPath}.get(null)`; |
|
} |
|
|
|
if (key === undefined) { |
|
return `${parentPath}.get(undefined)`; |
|
} |
|
|
|
if (typeof key === "symbol") { |
|
return `${parentPath}.get(${String(key)})`; |
|
} |
|
|
|
return `${parentPath}.entries[${index}]`; |
|
} |
|
|
|
function toSerializableValue( |
|
value: unknown, |
|
currentPath = "$", |
|
ancestors = new WeakMap<object, string>() |
|
): unknown { |
|
value = normalizeVueValue(value, currentPath); |
|
if (isCircularReferenceMarker(value)) { |
|
return formatCircularReference(value.path ?? currentPath); |
|
} |
|
|
|
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); |
|
} |
|
|
|
const existingPath = ancestors.get(value); |
|
if (existingPath) { |
|
return formatCircularReference(existingPath); |
|
} |
|
|
|
ancestors.set(value, currentPath); |
|
|
|
if (Array.isArray(value)) { |
|
const serialized = value.map((entry, index) => |
|
toSerializableValue(entry, buildDisplayArrayPath(currentPath, index), ancestors) |
|
); |
|
ancestors.delete(value); |
|
return serialized; |
|
} |
|
|
|
if (value instanceof Set) { |
|
const serialized = { |
|
$set: Array.from(value, (entry, index) => |
|
toSerializableValue(entry, buildDisplayArrayPath(currentPath, index), ancestors) |
|
), |
|
}; |
|
ancestors.delete(value); |
|
return serialized; |
|
} |
|
|
|
if (value instanceof Map) { |
|
const serialized = { |
|
$map: Array.from(value, ([key, entry], index) => [ |
|
toSerializableValue(key, buildDisplayMapPath(currentPath, key, index), ancestors), |
|
toSerializableValue(entry, buildDisplayMapPath(currentPath, key, index), ancestors), |
|
]), |
|
}; |
|
ancestors.delete(value); |
|
return serialized; |
|
} |
|
|
|
const serialized = Object.fromEntries( |
|
Object.entries(value).map(([key, entry]) => [ |
|
key, |
|
toSerializableValue(entry, buildDisplayObjectPath(currentPath, key), ancestors), |
|
]) |
|
); |
|
ancestors.delete(value); |
|
return serialized; |
|
} |
|
|
|
function serializeNodeValue(value: unknown, currentPath = "$"): string { |
|
if (typeof value === "string") { |
|
return value; |
|
} |
|
if (value === undefined) { |
|
return "undefined"; |
|
} |
|
try { |
|
const json = JSON.stringify(toSerializableValue(value, currentPath), 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 ({ path, value, copy }) => { |
|
await copy(serializeNodeValue(value, path)); |
|
}, |
|
}, |
|
{ |
|
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__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>
|
|
|