feat: Wireshark-style hex/field bidirectional highlighting
This commit is contained in:
parent
0e21d6cb99
commit
f7fbf57095
|
|
@ -5,7 +5,9 @@ import {
|
|||
SPI_FLASH_MODE_NAMES, SPI_FLASH_SPEED_NAMES, SPI_FLASH_SIZE_NAMES,
|
||||
parseAppImage,
|
||||
} from '../../lib/app-image';
|
||||
import { computeFieldRanges, type FieldGroup } from '../../lib/app-image/ranges';
|
||||
import HexDump from './HexDump.vue';
|
||||
import HexFieldPanel from './HexFieldPanel.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
isDark?: boolean;
|
||||
|
|
@ -17,6 +19,9 @@ const showHex = ref(false);
|
|||
const statusMessage = ref('');
|
||||
const statusType = ref<'success' | 'error' | 'info'>('info');
|
||||
const fileName = ref('');
|
||||
const fieldGroups = ref<FieldGroup[]>([]);
|
||||
const activeRange = ref<{ start: number; end: number } | null>(null);
|
||||
const hexDumpRef = ref<InstanceType<typeof HexDump> | null>(null);
|
||||
|
||||
let statusTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
|
|
@ -43,6 +48,27 @@ function formatSha256(data: Uint8Array): string {
|
|||
return Array.from(data).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
function onByteHover(offset: number | null) {
|
||||
if (offset === null) { activeRange.value = null; return; }
|
||||
// Pass 1: named field match (checked across all groups first)
|
||||
for (const g of fieldGroups.value) {
|
||||
const f = g.fields.find(f => offset >= f.start && offset < f.end);
|
||||
if (f) { activeRange.value = { start: f.start, end: f.end }; return; }
|
||||
}
|
||||
// Pass 2: data-only group (segment data blobs)
|
||||
for (const g of fieldGroups.value) {
|
||||
if (g.fields.length === 0 && offset >= g.start && offset < g.end) {
|
||||
activeRange.value = { start: g.start, end: g.end }; return;
|
||||
}
|
||||
}
|
||||
activeRange.value = null;
|
||||
}
|
||||
|
||||
function onFieldSelect(range: { start: number; end: number }) {
|
||||
activeRange.value = range;
|
||||
hexDumpRef.value?.scrollTo(range.start);
|
||||
}
|
||||
|
||||
async function handleOpenFile(file: File): Promise<false> {
|
||||
try {
|
||||
const buffer = await file.arrayBuffer();
|
||||
|
|
@ -53,6 +79,8 @@ async function handleOpenFile(file: File): Promise<false> {
|
|||
}
|
||||
imageInfo.value = parseAppImage(data);
|
||||
rawData.value = data;
|
||||
fieldGroups.value = computeFieldRanges(data, imageInfo.value);
|
||||
activeRange.value = null;
|
||||
showHex.value = false;
|
||||
fileName.value = file.name;
|
||||
showStatus(`已加载 ${file.name} (${data.byteLength} 字节)`, 'success');
|
||||
|
|
@ -146,7 +174,22 @@ async function handleOpenFile(file: File): Promise<false> {
|
|||
<el-button size="small" @click="showHex = !showHex">
|
||||
{{ showHex ? '隐藏原始字节' : '查看原始字节' }}
|
||||
</el-button>
|
||||
<HexDump v-if="showHex && rawData" :data="rawData" :height="400" class="mt-2" />
|
||||
<template v-if="showHex && rawData">
|
||||
<HexFieldPanel
|
||||
:groups="fieldGroups"
|
||||
:activeRange="activeRange"
|
||||
class="mt-2"
|
||||
@select="onFieldSelect"
|
||||
/>
|
||||
<HexDump
|
||||
ref="hexDumpRef"
|
||||
:data="rawData"
|
||||
:height="400"
|
||||
:highlight="activeRange"
|
||||
class="mt-1"
|
||||
@byte-hover="onByteHover"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,20 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{ data: Uint8Array; height?: number }>(), { height: 400 })
|
||||
const props = withDefaults(defineProps<{
|
||||
data: Uint8Array
|
||||
height?: number
|
||||
highlight?: { start: number; end: number } | null
|
||||
}>(), { height: 400, highlight: null })
|
||||
|
||||
const emit = defineEmits<{
|
||||
'byte-hover': [offset: number | null]
|
||||
}>()
|
||||
|
||||
const BYTES = 16
|
||||
const ROW_H = 20
|
||||
|
||||
const wrapEl = ref<HTMLElement | null>(null)
|
||||
const scrollTop = ref(0)
|
||||
|
||||
const totalRows = computed(() => Math.ceil(props.data.length / BYTES))
|
||||
|
|
@ -24,19 +33,48 @@ const rows = computed(() => {
|
|||
return out
|
||||
})
|
||||
|
||||
function isHi(offset: number): boolean {
|
||||
return props.highlight != null
|
||||
&& offset >= props.highlight.start
|
||||
&& offset < props.highlight.end
|
||||
}
|
||||
|
||||
function onScroll(e: Event) {
|
||||
scrollTop.value = (e.target as HTMLElement).scrollTop
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
scrollTo(offset: number) {
|
||||
const row = Math.floor(offset / BYTES)
|
||||
wrapEl.value?.scrollTo({ top: row * ROW_H, behavior: 'smooth' })
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="hd-wrap" :style="{ height: height + 'px' }" @scroll="onScroll">
|
||||
<div ref="wrapEl" class="hd-wrap" :style="{ height: height + 'px' }" @scroll="onScroll">
|
||||
<div :style="{ height: totalRows * ROW_H + 'px', position: 'relative' }">
|
||||
<div :style="{ transform: `translateY(${firstRow * ROW_H}px)` }">
|
||||
<div v-for="row in rows" :key="row.off" class="hd-row">
|
||||
<span class="hd-off">{{ row.off.toString(16).padStart(8, '0') }}</span>
|
||||
<span class="hd-hex">{{ row.hex.slice(0, 8).join(' ') }} {{ row.hex.slice(8).join(' ') }}</span>
|
||||
<span class="hd-asc">|{{ row.asc.join('') }}|</span>
|
||||
|
||||
<span class="hd-hex">
|
||||
<span
|
||||
v-for="(h, bi) in row.hex"
|
||||
:key="bi"
|
||||
:class="['hd-byte', { 'hd-byte-hi': isHi(row.off + bi) }, bi === 8 ? 'hd-byte-gap' : '']"
|
||||
@mouseenter="emit('byte-hover', row.off + bi)"
|
||||
@mouseleave="emit('byte-hover', null)"
|
||||
>{{ h }}</span>
|
||||
</span>
|
||||
|
||||
<span class="hd-asc">|<span
|
||||
v-for="(a, bi) in row.asc"
|
||||
:key="bi"
|
||||
:class="['hd-asc-char', { 'hd-byte-hi': isHi(row.off + bi) }]"
|
||||
@mouseenter="emit('byte-hover', row.off + bi)"
|
||||
@mouseleave="emit('byte-hover', null)"
|
||||
>{{ a }}</span>|</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -58,7 +96,6 @@ function onScroll(e: Event) {
|
|||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 8px;
|
||||
white-space: pre;
|
||||
}
|
||||
.hd-row:hover {
|
||||
background: var(--el-fill-color-light);
|
||||
|
|
@ -69,9 +106,30 @@ function onScroll(e: Event) {
|
|||
user-select: none;
|
||||
}
|
||||
.hd-hex {
|
||||
display: inline-flex;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
.hd-byte {
|
||||
display: inline-block;
|
||||
width: 3ch;
|
||||
cursor: default;
|
||||
border-radius: 2px;
|
||||
}
|
||||
/* Extra left margin to create the double-space gap between the two groups of 8 */
|
||||
.hd-byte-gap {
|
||||
margin-left: 1ch;
|
||||
}
|
||||
.hd-byte-hi {
|
||||
background: var(--el-color-primary-light-7);
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
.hd-asc {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
.hd-asc-char {
|
||||
display: inline-block;
|
||||
width: 1ch;
|
||||
cursor: default;
|
||||
border-radius: 2px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,169 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import type { FieldGroup } from '../../lib/app-image/ranges'
|
||||
|
||||
const props = defineProps<{
|
||||
groups: FieldGroup[]
|
||||
activeRange: { start: number; end: number } | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [range: { start: number; end: number }]
|
||||
}>()
|
||||
|
||||
const collapsed = ref<Set<number>>(new Set())
|
||||
|
||||
function toggle(i: number) {
|
||||
if (collapsed.value.has(i)) collapsed.value.delete(i)
|
||||
else collapsed.value.add(i)
|
||||
// trigger reactivity
|
||||
collapsed.value = new Set(collapsed.value)
|
||||
}
|
||||
|
||||
function isGroupActive(g: FieldGroup): boolean {
|
||||
if (!props.activeRange) return false
|
||||
return props.activeRange.start >= g.start && props.activeRange.end <= g.end
|
||||
}
|
||||
|
||||
function isFieldActive(start: number, end: number): boolean {
|
||||
if (!props.activeRange) return false
|
||||
return props.activeRange.start === start && props.activeRange.end === end
|
||||
}
|
||||
|
||||
function selectGroup(g: FieldGroup) {
|
||||
emit('select', { start: g.start, end: g.end })
|
||||
}
|
||||
|
||||
function selectField(start: number, end: number) {
|
||||
emit('select', { start, end })
|
||||
}
|
||||
|
||||
function fmtRange(start: number, end: number): string {
|
||||
if (end - start === 1) return start.toString(16).padStart(8, '0')
|
||||
return `${start.toString(16).padStart(8, '0')}–${(end - 1).toString(16).padStart(8, '0')}`
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="hfp-wrap">
|
||||
<!-- Group list -->
|
||||
<div class="hfp-groups">
|
||||
<div v-for="(g, gi) in groups" :key="gi" class="hfp-group">
|
||||
<!-- Group header row -->
|
||||
<div
|
||||
class="hfp-group-hdr"
|
||||
:class="{ 'hfp-row-active': isGroupActive(g) && g.fields.length === 0 }"
|
||||
@click="g.fields.length ? toggle(gi) : selectGroup(g)"
|
||||
>
|
||||
<span class="hfp-arrow" v-if="g.fields.length">{{ collapsed.has(gi) ? '▶' : '▼' }}</span>
|
||||
<span class="hfp-arrow" v-else>◇</span>
|
||||
<span class="hfp-group-label">{{ g.label }}</span>
|
||||
<span class="hfp-mono hfp-range">{{ fmtRange(g.start, g.end) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Field rows -->
|
||||
<template v-if="!collapsed.has(gi) && g.fields.length">
|
||||
<div
|
||||
v-for="(f, fi) in g.fields"
|
||||
:key="fi"
|
||||
class="hfp-field-row"
|
||||
:class="{ 'hfp-row-active': isFieldActive(f.start, f.end) }"
|
||||
@click="selectField(f.start, f.end)"
|
||||
>
|
||||
<span class="hfp-field-label">{{ f.label }}</span>
|
||||
<span class="hfp-mono hfp-range">{{ fmtRange(f.start, f.end) }}</span>
|
||||
<span class="hfp-mono hfp-value">{{ f.value }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.hfp-wrap {
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--el-fill-color-blank);
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.hfp-groups {
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.hfp-group-hdr {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
background: var(--el-fill-color-light);
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
.hfp-group-hdr:hover {
|
||||
background: var(--el-fill-color);
|
||||
}
|
||||
|
||||
.hfp-field-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 1px 8px 1px 22px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
border-bottom: 1px solid var(--el-border-color-extra-light);
|
||||
}
|
||||
.hfp-field-row:hover {
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
.hfp-row-active,
|
||||
.hfp-row-active:hover {
|
||||
background: var(--el-color-primary-light-8) !important;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.hfp-arrow {
|
||||
width: 12px;
|
||||
font-size: 10px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hfp-group-label,
|
||||
.hfp-field-label {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.hfp-mono {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hfp-range {
|
||||
color: var(--el-text-color-placeholder);
|
||||
min-width: 9em;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.hfp-value {
|
||||
color: var(--el-text-color-secondary);
|
||||
min-width: 8em;
|
||||
max-width: 18em;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in New Issue