217 lines
7.3 KiB
Vue
217 lines
7.3 KiB
Vue
<template>
|
|
<el-dialog
|
|
:model-value="props.visible"
|
|
title="编辑二进制数据"
|
|
width="min(640px, 92vw)"
|
|
:close-on-click-modal="false"
|
|
@update:model-value="$emit('update:visible', $event)"
|
|
>
|
|
<!-- toolbar -->
|
|
<div class="flex items-center gap-2 mb-2 flex-wrap">
|
|
<span class="text-sm text-gray-500">{{ byteCount }} 字节</span>
|
|
<el-button-group class="ml-2">
|
|
<el-button size="small" :type="activeTab === 'edit' ? 'primary' : ''" @click="activeTab = 'edit'">编辑</el-button>
|
|
<el-button size="small" :type="activeTab === 'preview' ? 'primary' : ''" @click="activeTab = 'preview'">预览</el-button>
|
|
</el-button-group>
|
|
<span class="flex-1" />
|
|
<el-button size="small" :icon="Upload" @click="importInput?.click()">导入文件</el-button>
|
|
<el-button size="small" :icon="Download" @click="exportFile">导出文件</el-button>
|
|
</div>
|
|
|
|
<!-- edit tab: offset + textarea -->
|
|
<div v-if="activeTab === 'edit'" class="hex-editor-wrapper">
|
|
<div ref="offsetPanel" class="hex-offset-panel" :style="{ height: editorHeight + 'px' }" aria-hidden="true">
|
|
<div v-for="(_, i) in lineCount" :key="i" class="hex-offset-row">
|
|
{{ (i * 16).toString(16).padStart(8, '0') }}
|
|
</div>
|
|
</div>
|
|
<textarea
|
|
v-model="hexText"
|
|
class="blob-hex-textarea"
|
|
:style="{ height: editorHeight + 'px', overflowY: 'auto' }"
|
|
wrap="off"
|
|
spellcheck="false"
|
|
placeholder="输入十六进制字节 (如: de ad be ef 01 02) 支持空格、逗号、0x前缀分隔"
|
|
@input="onHexInput"
|
|
@scroll="syncOffsetScroll"
|
|
/>
|
|
</div>
|
|
<!-- preview tab: HexDump -->
|
|
<HexDump v-else :data="currentBytes" :height="editorHeight" />
|
|
|
|
<div v-if="parseError" class="text-red-500 text-xs mt-1">{{ parseError }}</div>
|
|
|
|
<input ref="importInput" type="file" accept="*/*" style="display:none" @change="onImport" />
|
|
|
|
<template #footer>
|
|
<el-button @click="$emit('update:visible', false)">取消</el-button>
|
|
<el-button type="primary" :disabled="!!parseError" @click="confirm">确认</el-button>
|
|
</template>
|
|
</el-dialog>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, watch, withDefaults } from 'vue';
|
|
import { Upload, Download } from '@element-plus/icons-vue';
|
|
import HexDump from '../app-image-viewer/HexDump.vue';
|
|
import { parseHexString } from '../../lib/nvs';
|
|
|
|
const props = withDefaults(defineProps<{
|
|
modelValue: Uint8Array;
|
|
visible: boolean;
|
|
entryKey?: string;
|
|
}>(), { entryKey: 'blob' });
|
|
|
|
const emit = defineEmits<{
|
|
'update:modelValue': [value: Uint8Array];
|
|
'update:visible': [value: boolean];
|
|
}>();
|
|
|
|
const importInput = ref<HTMLInputElement>();
|
|
const offsetPanel = ref<HTMLDivElement>();
|
|
const hexText = ref('');
|
|
const parseError = ref<string | null>(null);
|
|
const activeTab = ref<'edit' | 'preview'>('edit');
|
|
|
|
// ── Formatting ────────────────────────────────────────────────────────────────
|
|
|
|
function formatHex(data: Uint8Array): string {
|
|
const lines: string[] = [];
|
|
for (let i = 0; i < data.length; i += 16) {
|
|
const chunk = data.subarray(i, i + 16);
|
|
lines.push(Array.from(chunk).map(b => b.toString(16).padStart(2, '0')).join(' '));
|
|
}
|
|
return lines.join('\n');
|
|
}
|
|
|
|
// ── Parsing ───────────────────────────────────────────────────────────────────
|
|
|
|
function parseHex(text: string): Uint8Array | null {
|
|
const result = parseHexString(text);
|
|
if ('error' in result) {
|
|
parseError.value = result.error;
|
|
return null;
|
|
}
|
|
parseError.value = null;
|
|
return result.bytes;
|
|
}
|
|
|
|
const currentBytes = computed<Uint8Array>(() => parseHex(hexText.value) ?? new Uint8Array(0));
|
|
const byteCount = computed(() => currentBytes.value.length);
|
|
|
|
/** Number of non-empty lines in the textarea (= number of 16-byte offset rows to show) */
|
|
const lineCount = computed(() => {
|
|
const lines = hexText.value.split('\n');
|
|
const count = (lines.length > 0 && lines[lines.length - 1].trim() === '')
|
|
? lines.length - 1
|
|
: lines.length;
|
|
return Math.max(1, count);
|
|
});
|
|
|
|
/** Editor/preview height in px — uses max of textarea line count and hex-dump row count */
|
|
const editorHeight = computed(() => {
|
|
const textRows = lineCount.value + 1;
|
|
const byteRows = Math.ceil(currentBytes.value.length / 16) + 1;
|
|
return Math.min(20, Math.max(textRows, byteRows)) * 20 + 16;
|
|
});
|
|
|
|
// ── Sync with prop ────────────────────────────────────────────────────────────
|
|
|
|
watch(() => props.visible, (v) => {
|
|
if (v) {
|
|
hexText.value = formatHex(props.modelValue as Uint8Array);
|
|
parseError.value = null;
|
|
activeTab.value = 'edit';
|
|
}
|
|
});
|
|
|
|
// ── Handlers ──────────────────────────────────────────────────────────────────
|
|
|
|
function onHexInput() {
|
|
parseHex(hexText.value); // side-effect: sets parseError
|
|
}
|
|
|
|
function syncOffsetScroll(e: Event) {
|
|
if (offsetPanel.value)
|
|
offsetPanel.value.scrollTop = (e.target as HTMLTextAreaElement).scrollTop;
|
|
}
|
|
|
|
function confirm() {
|
|
const bytes = parseHex(hexText.value);
|
|
if (bytes === null) return;
|
|
emit('update:modelValue', bytes);
|
|
emit('update:visible', false);
|
|
}
|
|
|
|
async function onImport(e: Event) {
|
|
const file = (e.target as HTMLInputElement).files?.[0];
|
|
(e.target as HTMLInputElement).value = '';
|
|
if (!file) return;
|
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
hexText.value = formatHex(bytes);
|
|
parseError.value = null;
|
|
}
|
|
|
|
function exportFile() {
|
|
const bytes = parseHex(hexText.value);
|
|
if (!bytes) return;
|
|
const url = URL.createObjectURL(new Blob([bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer]));
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = props.entryKey + '.bin';
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
setTimeout(() => URL.revokeObjectURL(url), 10000);
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.hex-editor-wrapper {
|
|
display: flex;
|
|
border: 1px solid var(--el-border-color);
|
|
border-radius: 4px;
|
|
overflow: hidden;
|
|
}
|
|
.hex-editor-wrapper:focus-within {
|
|
border-color: var(--el-color-primary);
|
|
}
|
|
.hex-offset-panel {
|
|
flex-shrink: 0;
|
|
width: 80px;
|
|
overflow: hidden;
|
|
background: var(--el-fill-color-light);
|
|
border-right: 1px solid var(--el-border-color-lighter);
|
|
user-select: none;
|
|
-webkit-user-select: none;
|
|
padding: 8px 6px;
|
|
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
|
font-size: 13px;
|
|
line-height: 1.6;
|
|
color: var(--el-text-color-secondary);
|
|
box-sizing: border-box;
|
|
}
|
|
.hex-offset-row {
|
|
white-space: nowrap;
|
|
}
|
|
.blob-hex-textarea {
|
|
flex: 1;
|
|
min-width: 0;
|
|
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
|
font-size: 13px;
|
|
line-height: 1.6;
|
|
padding: 8px 10px;
|
|
border: none;
|
|
border-radius: 0;
|
|
background: var(--el-fill-color-blank);
|
|
color: var(--el-text-color-primary);
|
|
outline: none;
|
|
box-sizing: border-box;
|
|
width: 100%;
|
|
overflow-x: auto;
|
|
}
|
|
.blob-hex-textarea:focus {
|
|
outline: none;
|
|
}
|
|
</style>
|