feat(ui): add trajectory inspection ledger
This commit is contained in:
@@ -22,8 +22,12 @@
|
||||
"dependencies": {
|
||||
"@shikijs/langs": "^4.3.1",
|
||||
"clsx": "^2.0.0",
|
||||
"mdast-util-from-markdown": "^2.0.3",
|
||||
"mdast-util-gfm": "^3.1.0",
|
||||
"micromark-extension-gfm": "^3.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-json-view-lite": "^2.5.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"shiki": "^4.3.1"
|
||||
|
||||
221
packages/client/ui-primitives/src/JsonTree.module.css
Normal file
221
packages/client/ui-primitives/src/JsonTree.module.css
Normal file
@@ -0,0 +1,221 @@
|
||||
.root {
|
||||
--json-tree-property: #881391;
|
||||
--json-tree-string: #c41a16;
|
||||
--json-tree-number: #1c00cf;
|
||||
--json-tree-keyword: #1c00cf;
|
||||
--json-tree-punctuation: #202124;
|
||||
--json-tree-icon: #5f6368;
|
||||
--json-tree-hover: rgb(60 64 67 / 4%);
|
||||
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
font: 12px/16px var(--ds-font-family-code);
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
:global(body[data-ds-dark-theme]) .root {
|
||||
--json-tree-property: #5db0d7;
|
||||
--json-tree-string: #f28b82;
|
||||
--json-tree-number: #99c8ff;
|
||||
--json-tree-keyword: #99c8ff;
|
||||
--json-tree-punctuation: #e8eaed;
|
||||
--json-tree-icon: #9aa0a6;
|
||||
--json-tree-hover: rgb(232 234 237 / 5%);
|
||||
}
|
||||
|
||||
.container {
|
||||
box-sizing: border-box;
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
margin: 0;
|
||||
padding: 6px 8px 8px;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.expandedTopLevel {
|
||||
box-sizing: border-box;
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
padding: 6px 8px 8px 14px;
|
||||
}
|
||||
|
||||
.expandedTopLevel:has(> .topLevelBracket[data-json-root-row]:hover),
|
||||
.expandedTopLevel:has(> .topLevelBracket[data-json-root-row][data-json-copy-active]) {
|
||||
background: var(--json-tree-hover);
|
||||
}
|
||||
|
||||
.expandedTopLevelContainer {
|
||||
padding: 0 0 0 calc(2ch - 12px);
|
||||
}
|
||||
|
||||
.row.topLevelBracket {
|
||||
margin-left: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.children {
|
||||
margin: 0;
|
||||
padding: 0 0 0 4px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.row {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
min-width: 100%;
|
||||
min-height: 16px;
|
||||
margin: 0;
|
||||
padding: 0 0 0 12px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.row:not(.topLevelBracket):hover:not(:has(.row:hover))::after,
|
||||
.row:not(.topLevelBracket)[data-json-copy-active]::after,
|
||||
.row:has(> .expander:focus-visible)::after {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
top: 0;
|
||||
right: -100vw;
|
||||
left: -100vw;
|
||||
height: 16px;
|
||||
background: var(--json-tree-hover);
|
||||
content: '';
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.row > span:not(.expander) {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.label {
|
||||
margin-right: 3px;
|
||||
color: var(--json-tree-property);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.clickableLabel {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.stringValue {
|
||||
color: var(--json-tree-string);
|
||||
}
|
||||
|
||||
.numberValue {
|
||||
color: var(--json-tree-number);
|
||||
}
|
||||
|
||||
.keywordValue {
|
||||
color: var(--json-tree-keyword);
|
||||
}
|
||||
|
||||
.otherValue {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.punctuation {
|
||||
color: var(--json-tree-punctuation);
|
||||
}
|
||||
|
||||
.preview {
|
||||
color: var(--json-tree-punctuation);
|
||||
}
|
||||
|
||||
.previewProperty {
|
||||
color: var(--json-tree-punctuation);
|
||||
}
|
||||
|
||||
.previewEllipsis {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.copyAnchor {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.copyButton {
|
||||
box-sizing: border-box;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
box-shadow: -5px 0 5px var(--dsw-alias-bg-layer-1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.copyButton:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.copyButton:focus-visible {
|
||||
outline: 1px solid var(--dsw-alias-state-business-primary);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.copyButton[data-state='failed'] {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.expander {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
width: 8px;
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
color: var(--json-tree-icon);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.expander::before {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-top: 4px solid transparent;
|
||||
border-bottom: 4px solid transparent;
|
||||
border-left: 6px solid currentColor;
|
||||
content: '';
|
||||
transform: scale(0.75);
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.collapseIcon::before {
|
||||
transform: rotate(90deg) scale(0.75);
|
||||
}
|
||||
|
||||
.expander:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.expander:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.collapsedContent {
|
||||
margin: 0 1px;
|
||||
color: var(--json-tree-punctuation);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.collapsedContent::after {
|
||||
content: '…';
|
||||
}
|
||||
390
packages/client/ui-primitives/src/JsonTree.tsx
Normal file
390
packages/client/ui-primitives/src/JsonTree.tsx
Normal file
@@ -0,0 +1,390 @@
|
||||
import clsx from 'clsx'
|
||||
import { collapseAllNested, JsonView } from 'react-json-view-lite'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { MouseEvent as ReactMouseEvent, ReactNode, UIEvent as ReactUIEvent } from 'react'
|
||||
import type { Props as LiteJsonViewProps } from 'react-json-view-lite'
|
||||
import { IconCheckOutline16, IconCopyOutline16 } from './icons/index.tsx'
|
||||
import { Menu } from './Menu.tsx'
|
||||
import type { MenuEntry } from './Menu.tsx'
|
||||
import css from './JsonTree.module.css'
|
||||
|
||||
const OBJECT_PREVIEW_LIMIT = 4
|
||||
const ARRAY_PREVIEW_LIMIT = 5
|
||||
const PREVIEW_DEPTH_LIMIT = 2
|
||||
const VALUE_COPY_MENU_ITEMS: readonly MenuEntry[] = [
|
||||
{ id: 'value', label: 'Copy value' },
|
||||
{ id: 'json', label: 'Copy JSON' },
|
||||
{ id: 'path', label: 'Copy property path' },
|
||||
]
|
||||
const OBJECT_COPY_MENU_ITEMS: readonly MenuEntry[] = [
|
||||
{ id: 'prettyJson', label: 'Copy pretty JSON' },
|
||||
{ id: 'json', label: 'Copy compact JSON' },
|
||||
{ id: 'path', label: 'Copy property path' },
|
||||
]
|
||||
|
||||
const TREE_STYLES: NonNullable<LiteJsonViewProps['style']> = {
|
||||
container: css.container!,
|
||||
childFieldsContainer: css.children!,
|
||||
basicChildStyle: css.row!,
|
||||
label: css.label!,
|
||||
clickableLabel: `${css.label!} ${css.clickableLabel!}`,
|
||||
nullValue: css.keywordValue!,
|
||||
undefinedValue: css.keywordValue!,
|
||||
numberValue: css.numberValue!,
|
||||
stringValue: css.stringValue!,
|
||||
booleanValue: css.keywordValue!,
|
||||
otherValue: css.otherValue!,
|
||||
punctuation: css.punctuation!,
|
||||
expandIcon: `${css.expander!} ${css.expandIcon!}`,
|
||||
collapseIcon: `${css.expander!} ${css.collapseIcon!}`,
|
||||
collapsedContent: css.collapsedContent!,
|
||||
noQuotesForStringValues: false,
|
||||
quotesForFieldNames: false,
|
||||
stringifyStringValues: true,
|
||||
ariaLables: {
|
||||
collapseJson: 'Collapse JSON node',
|
||||
expandJson: 'Expand JSON node',
|
||||
},
|
||||
}
|
||||
|
||||
const EXPANDED_TOP_LEVEL_TREE_STYLES: NonNullable<LiteJsonViewProps['style']> = {
|
||||
...TREE_STYLES,
|
||||
container: `${css.container!} ${css.expandedTopLevelContainer!}`,
|
||||
}
|
||||
|
||||
function previewPrimitive(value: unknown): ReactNode {
|
||||
if (value === null) return <span className={css.keywordValue}>null</span>
|
||||
if (typeof value === 'string') {
|
||||
return <span className={css.stringValue}>{JSON.stringify(value)}</span>
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return <span className={css.numberValue}>{String(value)}</span>
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return <span className={css.keywordValue}>{String(value)}</span>
|
||||
}
|
||||
return <span className={css.otherValue}>{String(value)}</span>
|
||||
}
|
||||
|
||||
function previewValue(value: unknown, depth: number): ReactNode {
|
||||
if (typeof value !== 'object' || value === null) return previewPrimitive(value)
|
||||
|
||||
const array = Array.isArray(value)
|
||||
const entries = array
|
||||
? value.map((item, index) => [String(index), item] as const)
|
||||
: Object.entries(value)
|
||||
const limit = array ? ARRAY_PREVIEW_LIMIT : OBJECT_PREVIEW_LIMIT
|
||||
const visible = entries.slice(0, limit)
|
||||
const open = array ? '[' : '{'
|
||||
const close = array ? ']' : '}'
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className={css.punctuation}>{open}</span>
|
||||
{depth >= PREVIEW_DEPTH_LIMIT
|
||||
? <span className={css.previewEllipsis}>…</span>
|
||||
: visible.map(([key, item], index) => (
|
||||
<span key={key}>
|
||||
{index > 0 && <span className={css.punctuation}>, </span>}
|
||||
{!array && (
|
||||
<>
|
||||
<span className={css.previewProperty}>{key}</span>
|
||||
<span className={css.punctuation}>: </span>
|
||||
</>
|
||||
)}
|
||||
{previewValue(item, depth + 1)}
|
||||
</span>
|
||||
))}
|
||||
{depth < PREVIEW_DEPTH_LIMIT && entries.length > limit && (
|
||||
<span className={css.previewEllipsis}>{visible.length > 0 ? ', …' : '…'}</span>
|
||||
)}
|
||||
<span className={css.punctuation}>{close}</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function renderExpandableValue(value: object): ReactNode {
|
||||
return <span className={css.preview}>{previewValue(value, 0)}</span>
|
||||
}
|
||||
|
||||
interface CopyTarget {
|
||||
left: number
|
||||
path: readonly (number | string)[]
|
||||
side: 'bottom' | 'top'
|
||||
top: number
|
||||
value: unknown
|
||||
}
|
||||
|
||||
function fieldOf(row: HTMLElement): string | undefined {
|
||||
const label = Array.from(row.children).find(
|
||||
child => child instanceof HTMLElement && child.classList.contains(css.label!),
|
||||
)
|
||||
const text = label?.textContent
|
||||
return text === undefined || text === null ? undefined : text.slice(0, -1)
|
||||
}
|
||||
|
||||
function resolveRow(data: object | unknown[], row: HTMLElement, expandTopLevel: boolean): {
|
||||
path: readonly (number | string)[]
|
||||
value: unknown
|
||||
} | undefined {
|
||||
if (row.hasAttribute('data-json-root-row')) return { path: [], value: data }
|
||||
|
||||
const lineage: HTMLElement[] = []
|
||||
let cursor: HTMLElement | null = row
|
||||
while (cursor !== null) {
|
||||
lineage.unshift(cursor)
|
||||
const group: HTMLElement | null = cursor.parentElement
|
||||
const parentRow: Element | null = group?.getAttribute('role') === 'group'
|
||||
? group.parentElement?.closest('[role="treeitem"]') ?? null
|
||||
: null
|
||||
cursor = parentRow instanceof HTMLElement ? parentRow : null
|
||||
}
|
||||
|
||||
let value: unknown = data
|
||||
const path: (number | string)[] = []
|
||||
for (const item of expandTopLevel ? lineage : lineage.slice(1)) {
|
||||
const field = fieldOf(item)
|
||||
if (field === undefined) return undefined
|
||||
if (Array.isArray(value)) {
|
||||
const index = Number(field)
|
||||
if (!Number.isInteger(index)) return undefined
|
||||
path.push(index)
|
||||
value = value[index]
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
path.push(field)
|
||||
value = (value as Record<string, unknown>)[field]
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
return { path, value }
|
||||
}
|
||||
|
||||
function formattedPath(path: readonly (number | string)[]): string {
|
||||
return path.reduce<string>((result, part) => {
|
||||
if (typeof part === 'number') return `${result}[${String(part)}]`
|
||||
return /^[A-Za-z_$][\w$]*$/.test(part)
|
||||
? `${result}.${part}`
|
||||
: `${result}[${JSON.stringify(part)}]`
|
||||
}, '$')
|
||||
}
|
||||
|
||||
function copyText(target: CopyTarget, mode: 'json' | 'path' | 'prettyJson' | 'value'): string {
|
||||
if (mode === 'path') return formattedPath(target.path)
|
||||
if (mode === 'prettyJson') return JSON.stringify(target.value, null, 2)
|
||||
if (mode === 'json') return JSON.stringify(target.value) ?? String(target.value)
|
||||
if (typeof target.value === 'string') return target.value
|
||||
if (typeof target.value === 'object' && target.value !== null) {
|
||||
return JSON.stringify(target.value, null, 2)
|
||||
}
|
||||
return JSON.stringify(target.value) ?? String(target.value)
|
||||
}
|
||||
|
||||
/** Props for the read-only, token-themed JSON tree. */
|
||||
export interface JsonTreeProps {
|
||||
/** Parsed JSON object or array. */
|
||||
data: object | unknown[]
|
||||
/** Accessible label for the tree. */
|
||||
label?: string
|
||||
/** Optional positioning class owned by the caller. */
|
||||
className?: string | undefined
|
||||
/** Whether JSON rows expose copy actions. */
|
||||
copyable?: boolean
|
||||
/** Whether the top-level object or array is always expanded. */
|
||||
expandTopLevel?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Render parsed JSON as a compact, keyboard-accessible inspector tree.
|
||||
* @param props - Parsed data, accessible label, and display options.
|
||||
* @returns A read-only JSON tree with an optionally fixed-open top level.
|
||||
*/
|
||||
export function JsonTree({
|
||||
data,
|
||||
label = 'JSON',
|
||||
className,
|
||||
copyable = true,
|
||||
expandTopLevel = true,
|
||||
}: JsonTreeProps) {
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const activeRowRef = useRef<HTMLElement>()
|
||||
const copyButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const copyMenuOpenRef = useRef(false)
|
||||
const resetTimer = useRef<ReturnType<typeof setTimeout>>()
|
||||
const [copyTarget, setCopyTarget] = useState<CopyTarget>()
|
||||
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>('idle')
|
||||
const [copyMenuOpen, setCopyMenuOpen] = useState(false)
|
||||
|
||||
useEffect(() => () => {
|
||||
if (resetTimer.current !== undefined) clearTimeout(resetTimer.current)
|
||||
activeRowRef.current?.removeAttribute('data-json-copy-active')
|
||||
}, [])
|
||||
|
||||
const setActiveRow = (row: HTMLElement | undefined) => {
|
||||
activeRowRef.current?.removeAttribute('data-json-copy-active')
|
||||
activeRowRef.current = row
|
||||
row?.setAttribute('data-json-copy-active', '')
|
||||
}
|
||||
|
||||
const positionCopyButton = (row: HTMLElement, target: {
|
||||
path: readonly (number | string)[]
|
||||
value: unknown
|
||||
}) => {
|
||||
const root = rootRef.current
|
||||
if (root === null) return
|
||||
const rootRect = root.getBoundingClientRect()
|
||||
const rowRect = row.getBoundingClientRect()
|
||||
setCopyTarget({
|
||||
left: root.scrollLeft + root.clientWidth - 26,
|
||||
path: target.path,
|
||||
side: rowRect.top - rootRect.top > root.clientHeight / 2 ? 'top' : 'bottom',
|
||||
top: root.scrollTop + rowRect.top - rootRect.top,
|
||||
value: target.value,
|
||||
})
|
||||
}
|
||||
|
||||
const clearCopyTarget = () => {
|
||||
setActiveRow(undefined)
|
||||
setCopyTarget(undefined)
|
||||
setCopyState('idle')
|
||||
copyMenuOpenRef.current = false
|
||||
setCopyMenuOpen(false)
|
||||
}
|
||||
|
||||
const handleMouseOver = (event: ReactMouseEvent<HTMLDivElement>) => {
|
||||
if (!copyable || !(event.target instanceof Element)) return
|
||||
if (copyMenuOpenRef.current) return
|
||||
if (!event.currentTarget.contains(event.target)) return
|
||||
if (event.target.closest('[data-json-copy-button]') !== null) return
|
||||
const row = event.target.closest<HTMLElement>('[data-json-root-row], [role="treeitem"]')
|
||||
if (row === null) {
|
||||
clearCopyTarget()
|
||||
return
|
||||
}
|
||||
if (activeRowRef.current === row) return
|
||||
const resolved = resolveRow(data, row, expandTopLevel)
|
||||
if (resolved === undefined) return
|
||||
setActiveRow(row)
|
||||
setCopyState('idle')
|
||||
copyMenuOpenRef.current = false
|
||||
setCopyMenuOpen(false)
|
||||
positionCopyButton(row, resolved)
|
||||
}
|
||||
|
||||
const handleScroll = (event: ReactUIEvent<HTMLDivElement>) => {
|
||||
if (event.currentTarget !== event.target) return
|
||||
const row = activeRowRef.current
|
||||
if (row === undefined) return
|
||||
const resolved = resolveRow(data, row, expandTopLevel)
|
||||
if (resolved !== undefined) positionCopyButton(row, resolved)
|
||||
}
|
||||
|
||||
const copy = async (mode: 'json' | 'path' | 'prettyJson' | 'value') => {
|
||||
if (copyTarget === undefined) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(copyText(copyTarget, mode))
|
||||
setCopyState('copied')
|
||||
} catch {
|
||||
setCopyState('failed')
|
||||
}
|
||||
if (resetTimer.current !== undefined) clearTimeout(resetTimer.current)
|
||||
resetTimer.current = setTimeout(() => setCopyState('idle'), 1_500)
|
||||
}
|
||||
|
||||
const copyTargetIsObject = typeof copyTarget?.value === 'object' && copyTarget.value !== null
|
||||
const defaultCopyMode = copyTargetIsObject ? 'prettyJson' : 'value'
|
||||
const copyTitle = copyState === 'copied'
|
||||
? 'Copied'
|
||||
: copyState === 'failed'
|
||||
? 'Copy failed'
|
||||
: copyTargetIsObject ? 'Copy pretty JSON' : 'Copy value'
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={clsx(css.root, className)}
|
||||
onMouseOver={handleMouseOver}
|
||||
onMouseLeave={() => {
|
||||
if (!copyMenuOpenRef.current) clearCopyTarget()
|
||||
}}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{expandTopLevel
|
||||
? (
|
||||
<div className={css.expandedTopLevel}>
|
||||
<div className={clsx(css.row, css.topLevelBracket)} data-json-root-row>
|
||||
<span className={css.punctuation}>{Array.isArray(data) ? '[' : '{'}</span>
|
||||
</div>
|
||||
<JsonView
|
||||
aria-label={label}
|
||||
compactTopLevel
|
||||
data={data}
|
||||
style={EXPANDED_TOP_LEVEL_TREE_STYLES}
|
||||
shouldExpandNode={collapseAllNested}
|
||||
clickToExpandNode
|
||||
renderExpandableValue={renderExpandableValue}
|
||||
/>
|
||||
<div className={clsx(css.row, css.topLevelBracket)}>
|
||||
<span className={css.punctuation}>{Array.isArray(data) ? ']' : '}'}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<JsonView
|
||||
aria-label={label}
|
||||
data={data}
|
||||
style={TREE_STYLES}
|
||||
shouldExpandNode={collapseAllNested}
|
||||
clickToExpandNode
|
||||
renderExpandableValue={renderExpandableValue}
|
||||
/>
|
||||
)}
|
||||
{copyTarget !== undefined && (
|
||||
<span
|
||||
className={css.copyAnchor}
|
||||
style={{ left: copyTarget.left, top: copyTarget.top }}
|
||||
>
|
||||
<Menu
|
||||
open={copyMenuOpen}
|
||||
compact
|
||||
portal
|
||||
align="end"
|
||||
side={copyTarget.side}
|
||||
anchor={(
|
||||
<button
|
||||
ref={copyButtonRef}
|
||||
type="button"
|
||||
className={css.copyButton}
|
||||
data-json-copy-button
|
||||
data-state={copyState}
|
||||
aria-label={copyTitle}
|
||||
title={`${copyTitle}; right-click for copy options`}
|
||||
onClick={() => void copy(defaultCopyMode)}
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
copyMenuOpenRef.current = true
|
||||
setCopyMenuOpen(true)
|
||||
}}
|
||||
>
|
||||
{copyState === 'copied'
|
||||
? <IconCheckOutline16 size={12} />
|
||||
: <IconCopyOutline16 size={12} />}
|
||||
</button>
|
||||
)}
|
||||
items={copyTargetIsObject ? OBJECT_COPY_MENU_ITEMS : VALUE_COPY_MENU_ITEMS}
|
||||
onSelect={(id) => {
|
||||
if (id === 'value' || id === 'json' || id === 'prettyJson' || id === 'path') {
|
||||
void copy(id)
|
||||
}
|
||||
copyMenuOpenRef.current = false
|
||||
setCopyMenuOpen(false)
|
||||
}}
|
||||
onClose={clearCopyTarget}
|
||||
getAnchorRect={() => copyButtonRef.current?.getBoundingClientRect() ?? null}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -77,6 +77,37 @@
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.list.compactList,
|
||||
.submenu.compactList {
|
||||
min-width: 164px;
|
||||
padding: 2px;
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.compactList .item {
|
||||
min-height: 26px;
|
||||
gap: 6px;
|
||||
padding: 3px 7px;
|
||||
border-radius: 5px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.compactList .itemIcon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.compactList .separator {
|
||||
margin: 2px;
|
||||
}
|
||||
|
||||
.compactList .label {
|
||||
padding: 4px 7px;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.item:disabled {
|
||||
color: var(--dsw-alias-label-dimmed);
|
||||
cursor: not-allowed;
|
||||
|
||||
@@ -66,6 +66,7 @@ function isLabel(entry: MenuEntry): entry is MenuLabel {
|
||||
* keeps the pure-CSS in-place behavior.
|
||||
* @param props.closeOnPointerLeave - close the list when the pointer leaves
|
||||
* it (default false keeps it open until outside click/Escape/selection).
|
||||
* @param props.compact - use reduced menu typography and spacing.
|
||||
* @param props.getAnchorRect - portal mode only: supply the anchor rect
|
||||
* directly (e.g. from a host-owned trigger button) instead of measuring the
|
||||
* Menu's own wrapper span. Required when the wrapper isn't itself laid out at
|
||||
@@ -74,7 +75,7 @@ function isLabel(entry: MenuEntry): entry is MenuLabel {
|
||||
* scroll/resize; return null to skip placement for that frame.
|
||||
* @returns anchor wrapper with the conditional list.
|
||||
*/
|
||||
export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, getAnchorRect, className }: {
|
||||
export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, compact = false, getAnchorRect, className }: {
|
||||
open: boolean
|
||||
anchor: ReactNode
|
||||
items: readonly MenuEntry[]
|
||||
@@ -85,6 +86,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
|
||||
side?: 'bottom' | 'top'
|
||||
portal?: boolean
|
||||
closeOnPointerLeave?: boolean
|
||||
compact?: boolean
|
||||
getAnchorRect?: () => DOMRect | null
|
||||
className?: string
|
||||
}) {
|
||||
@@ -149,7 +151,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
|
||||
const list = open && (!portal || fixedPos !== null) && (
|
||||
<div
|
||||
ref={listRef}
|
||||
className={clsx(css.list, portal && css.portal, side === 'top' && !portal && css.sideTop, align === 'end' && !portal && css.alignEnd)}
|
||||
className={clsx(css.list, compact && css.compactList, portal && css.portal, side === 'top' && !portal && css.sideTop, align === 'end' && !portal && css.alignEnd)}
|
||||
style={fixedPos ?? undefined}
|
||||
role="menu"
|
||||
onPointerLeave={closeOnPointerLeave ? () => { onClose() } : undefined}
|
||||
@@ -196,7 +198,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
|
||||
{entry.id === selectedId && <IconCheckOutline16 className={css.check} />}
|
||||
</button>
|
||||
{subOpen && entry.submenu !== undefined && (
|
||||
<div className={css.submenu} role="menu">
|
||||
<div className={clsx(css.submenu, compact && css.compactList)} role="menu">
|
||||
{entry.submenu.map(sub => (
|
||||
<button
|
||||
key={sub.id}
|
||||
|
||||
@@ -17,8 +17,12 @@ export { FishLogo } from './FishLogo.tsx'
|
||||
export { BrandWordmark } from './BrandWordmark.tsx'
|
||||
export { Tooltip } from './Tooltip.tsx'
|
||||
export type { TooltipSide } from './Tooltip.tsx'
|
||||
export { JsonTree } from './JsonTree.tsx'
|
||||
export type { JsonTreeProps } from './JsonTree.tsx'
|
||||
export { CodeBlock } from './markdown/CodeBlock.tsx'
|
||||
export { JsonBlock } from './markdown/JsonBlock.tsx'
|
||||
export { MarkdownText } from './markdown/MarkdownText.tsx'
|
||||
export { MessageText } from './markdown/MessageText.tsx'
|
||||
export { extractMarkdownPlainText } from './markdown/plain-text.ts'
|
||||
export type { MarkdownPlainTextMode, MarkdownPlainTextOptions } from './markdown/plain-text.ts'
|
||||
export * from './icons/index.tsx'
|
||||
|
||||
126
packages/client/ui-primitives/src/markdown/plain-text.ts
Normal file
126
packages/client/ui-primitives/src/markdown/plain-text.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Markdown-to-plain-text projection for compact summaries and labels.
|
||||
* Parsing shares the renderer's GFM grammar; raw HTML is intentionally
|
||||
* omitted, links keep their labels, images keep alt text, and code keeps its
|
||||
* source text.
|
||||
*/
|
||||
|
||||
import { fromMarkdown } from 'mdast-util-from-markdown'
|
||||
import { gfmFromMarkdown } from 'mdast-util-gfm'
|
||||
import { gfm } from 'micromark-extension-gfm'
|
||||
|
||||
/** Amount of parsed Markdown content returned by the extractor. */
|
||||
export type MarkdownPlainTextMode = 'all' | 'first-line' | 'first-paragraph'
|
||||
|
||||
/** Options for {@link extractMarkdownPlainText}. */
|
||||
export interface MarkdownPlainTextOptions {
|
||||
/** Projection boundary; defaults to the complete document. */
|
||||
mode?: MarkdownPlainTextMode
|
||||
}
|
||||
|
||||
interface MarkdownNode {
|
||||
type: string
|
||||
value?: string
|
||||
alt?: string
|
||||
children?: MarkdownNode[]
|
||||
}
|
||||
|
||||
function inlineText(node: MarkdownNode): string {
|
||||
switch (node.type) {
|
||||
case 'text':
|
||||
case 'inlineCode':
|
||||
case 'code':
|
||||
return node.value ?? ''
|
||||
case 'image':
|
||||
case 'imageReference':
|
||||
return node.alt ?? ''
|
||||
case 'break':
|
||||
return '\n'
|
||||
case 'html':
|
||||
case 'thematicBreak':
|
||||
case 'definition':
|
||||
return ''
|
||||
default:
|
||||
return node.children?.map(inlineText).join('') ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
function compactInline(text: string): string {
|
||||
return text.replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
function blockText(node: MarkdownNode): string {
|
||||
switch (node.type) {
|
||||
case 'root':
|
||||
case 'blockquote':
|
||||
return node.children?.map(blockText).filter(Boolean).join('\n\n') ?? ''
|
||||
case 'paragraph':
|
||||
case 'heading':
|
||||
return compactInline(inlineText(node))
|
||||
case 'code':
|
||||
return node.value?.trim() ?? ''
|
||||
case 'list':
|
||||
return node.children?.map(blockText).filter(Boolean).join('\n') ?? ''
|
||||
case 'listItem':
|
||||
return node.children?.map(blockText).filter(Boolean).join(' ') ?? ''
|
||||
case 'table':
|
||||
return node.children?.map(blockText).filter(Boolean).join('\n') ?? ''
|
||||
case 'tableRow':
|
||||
return node.children?.map(blockText).join('\t') ?? ''
|
||||
case 'tableCell':
|
||||
return compactInline(inlineText(node))
|
||||
case 'html':
|
||||
case 'thematicBreak':
|
||||
case 'definition':
|
||||
return ''
|
||||
default:
|
||||
return compactInline(inlineText(node))
|
||||
}
|
||||
}
|
||||
|
||||
function findFirstParagraph(node: MarkdownNode): string | undefined {
|
||||
if (node.type === 'paragraph') {
|
||||
const text = compactInline(inlineText(node))
|
||||
if (text !== '') return text
|
||||
}
|
||||
for (const child of node.children ?? []) {
|
||||
const text = findFirstParagraph(child)
|
||||
if (text !== undefined) return text
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function fullText(root: MarkdownNode): string {
|
||||
return blockText(root)
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.join('\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse GFM Markdown and project its user-visible plain text.
|
||||
* @param markdown - Markdown source.
|
||||
* @param options - Optional extraction boundary.
|
||||
* @returns Plain text for the whole document, first visible line, or first semantic paragraph.
|
||||
*/
|
||||
export function extractMarkdownPlainText(
|
||||
markdown: string,
|
||||
options: MarkdownPlainTextOptions = {},
|
||||
): string {
|
||||
const { mode = 'all' } = options
|
||||
const root = fromMarkdown(markdown, {
|
||||
extensions: [gfm()],
|
||||
mdastExtensions: [gfmFromMarkdown()],
|
||||
}) as MarkdownNode
|
||||
const all = fullText(root)
|
||||
switch (mode) {
|
||||
case 'all':
|
||||
return all
|
||||
case 'first-line':
|
||||
return all.split('\n').find(line => line !== '') ?? ''
|
||||
case 'first-paragraph':
|
||||
return findFirstParagraph(root) ?? all.split('\n').find(line => line !== '') ?? ''
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { extractMarkdownPlainText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
|
||||
const MARKDOWN = [
|
||||
'# Release notes',
|
||||
'',
|
||||
'First **paragraph** with [a link](https://example.com) and .',
|
||||
'',
|
||||
'- shipped',
|
||||
'- `verified`',
|
||||
'',
|
||||
'```ts',
|
||||
'const ready = true',
|
||||
'```',
|
||||
].join('\n')
|
||||
|
||||
describe('extractMarkdownPlainText', () => {
|
||||
it('projects the complete GFM document without presentation syntax', () => {
|
||||
expect(extractMarkdownPlainText(MARKDOWN)).toBe([
|
||||
'Release notes',
|
||||
'',
|
||||
'First paragraph with a link and diagram.',
|
||||
'',
|
||||
'shipped',
|
||||
'verified',
|
||||
'',
|
||||
'const ready = true',
|
||||
].join('\n'))
|
||||
})
|
||||
|
||||
it('selects the first visible line or first semantic paragraph', () => {
|
||||
expect(extractMarkdownPlainText(MARKDOWN, { mode: 'first-line' })).toBe('Release notes')
|
||||
expect(extractMarkdownPlainText(MARKDOWN, { mode: 'first-paragraph' }))
|
||||
.toBe('First paragraph with a link and diagram.')
|
||||
})
|
||||
|
||||
it('omits raw HTML and returns a useful fallback when no paragraph exists', () => {
|
||||
const markdown = '<script>alert(1)</script>\n\n## Safe heading'
|
||||
expect(extractMarkdownPlainText(markdown)).toBe('Safe heading')
|
||||
expect(extractMarkdownPlainText(markdown, { mode: 'first-paragraph' })).toBe('Safe heading')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user