效果图
1、默认状态
2、全屏状态
功能介绍
1、按键功能
2、细节介绍
-
右侧提示按钮为绿色表示格式正确,为红色则错误
-
支持显示行号并与右侧文本域滚动条联动
-
最大化时支持实时预览json对象,同时支持错误提示和定位
- 点击中括号、花括号、大括号时会高亮显示另一半
- 支持括号、双引号自动补全
组件结构
JsonEditor.vue
<template>
<div ref="center">
<div
style="
border: 1px solid #bac6e7;
padding-left: 5px;
padding-right: 5px;
display: flex;
justify-content: space-between;
"
>
<div style="display: flex; align-items: center">
<eye-outlined class="icon_hover" @click="isPreview = true" title="预览JSON配置" />
<div style="height: 18px; border: 1px solid #858585; margin: 0 3px"></div>
<tool-outlined class="icon_hover" @click="prettyFormat(viewJsonStr)" title="格式化" />
<div style="height: 18px; border: 1px solid #858585; margin: 0 3px"></div>
<line-outlined
class="icon_hover"
@click="viewJsonStr = viewJsonStr.replace(/\s+/g, '')"
title="去除空格"
/>
<div
style="
display: flex;
align-items: center;
border-left: 2px solid #858585;
height: 18px;
margin: 0 3px;
padding: 0 3px;
"
>
<fullscreen-outlined
v-if="!isFullScreen"
class="icon_hover"
@click="fullScreen"
title="全屏"
/>
<fullscreen-exit-outlined v-else class="icon_hover" @click="fullScreen" title="退出" />
</div>
</div>
<div>
<check-circle-outlined title="格式正确" v-if="isPass" style="color: #63ca31" />
<info-circle-outlined title="格式错误" v-else style="color: red" />
</div>
</div>
<div class="edit-container">
<textarea
wrap="off"
cols="1"
id="leftNum"
disabled
onscroll="document.getElementById('rightNum').scrollTop = this.scrollTop;"
></textarea>
<a-textarea
ref="myTextarea"
id="rightNum"
:key="isFullScreen"
:auto-size="isFullScreen ? false : { minRows: rows, maxRows: rows }"
style="height: calc(100vh - 30px)"
placeholder="请输入JSON字符串"
onscroll="document.getElementById('leftNum').scrollTop = this.scrollTop;"
:value="viewJsonStr"
@click="handleClick"
@change="handleTextareaInput1"
/>
<vue-json-pretty
style="width: 50%; padding: 20px; background-color: white; border: 1px solid #d9d9d9"
v-if="isFullScreen"
:data="jsonObj"
/>
</div>
<a-modal
bodyStyle="padding:20px;border-radius: 5px"
title="预览JSON对象"
style="top: 112px; width: 800px"
:visible="isPreview"
@cancel="isPreview = false"
>
<div style="height: 500px; overflow: auto">
<vue-json-pretty :data="jsonObj" />
</div>
<template #footer>
<a-button @click="isPreview = false">关闭</a-button>
</template>
</a-modal>
</div>
</template>
<script lang="ts" setup>
import VueJsonPretty from 'vue-json-pretty';
import 'vue-json-pretty/lib/styles.css';
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { cloneDeep } from 'lodash-es';
import {
handleBackspace,
handleClick,
handleClickEnter,
handleTabKey,
handleTextareaInput,
} from '/@/components/JsonEditor';
const emit = defineEmits(['update:jsonStr']);
const props = defineProps({
jsonStr: {
type: String,
default: '',
},
rows: {
type: Number,
default: 4,
},
});
const isPreview = ref(false);
const viewJsonStr: any = ref(props.jsonStr);
nextTick(() => {
viewJsonStr.value = props.jsonStr;
});
// 自动补全
function handleTextareaInput1(event) {
handleTextareaInput(viewJsonStr, event);
}
function IsJsonString(str) {
try {
JSON.parse(str);
} catch (e) {
return false;
}
return true;
}
const isPass = ref(true);
watch(
() => viewJsonStr.value,
(newValue) => {
isPass.value = IsJsonString(newValue);
calculateNum(newValue);
emit('update:jsonStr', newValue);
},
);
const num = ref('');
function calculateNum(value) {
if (value) {
let str = value;
str = str.replace(/\r/gi, '');
str = str.split('\n');
let n = str.length;
let lineBbj: any = document.getElementById('leftNum');
for (let i = 1; i <= n; i++) {
if (document.all) {
num.value += i + '\r\n'; //判断浏览器是否是IE
} else {
num.value += i + '\n';
}
}
lineBbj.value = num.value;
num.value = '';
}
}
// 预览对象
const jsonObj = computed(() => {
const str = cloneDeep(props.jsonStr);
try {
return JSON.parse(str);
} catch (e: any) {
if (e.message?.match(/position\s+(\d+)/)) {
const location = e.message?.match(/position\s+(\d+)/)[1];
const str1 = str.substring(0, location).trim();
const str2 = str1.split('\n');
const message = e.message.substring(0, e.message.indexOf('position'));
// 如果当前行或者前一行有'['
if (str2[str2.length - 1]?.includes('[')) {
const { line, column } = getLineAndColumn(str1, str1.length - 1);
return `${message} at line ${line},column ${column}`;
}
const { line, column } = getLineAndColumn(str, location);
return `${message} at line ${line},column ${column}`;
} else {
return null;
}
}
});
//计算错误信息所在行列
function getLineAndColumn(str, index) {
let line = 1;
let column = 1;
for (let i = 0; i < index; i++) {
if (str[i] === '\n') {
line++;
column = 1;
} else {
column++;
}
}
return { line, column };
}
//json格式美化
function prettyFormat(str) {
try {
// 设置缩进为2个空格
str = JSON.stringify(JSON.parse(str), null, 4);
str = str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
viewJsonStr.value = str.replace(
/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g,
function (match) {
return match;
},
);
} catch (e) {
console.log('异常信息:' + e);
}
}
const center = ref();
const isFullScreen = ref(false);
function fullScreen() {
if (center.value) {
if (center.value.className.includes('fullScreen')) {
center.value.className = center.value.className.replace(' fullScreen', '');
isFullScreen.value = false;
} else {
center.value.className += ' fullScreen';
isFullScreen.value = true;
}
}
}
const myTextarea: any = ref(null);
function handleKeyDown(event) {
if (myTextarea.value) {
if (event.key === 'Backspace') {
handleBackspace(viewJsonStr, event);
} else if (event.key === 'Enter') {
handleClickEnter(viewJsonStr, event);
} else if (event.key === 'Tab') {
handleTabKey(event);
}
}
}
onMounted(() => {
window.addEventListener('keydown', handleKeyDown);
});
onBeforeUnmount(() => {
window.removeEventListener('keydown', handleKeyDown);
});
</script>
<style scoped lang="less">
.icon_hover {
&:hover {
color: #5c82ff;
}
}
#leftNum {
overflow: hidden;
padding: 6px 2px;
width: 30px;
line-height: 22px;
font-size: 13px;
color: rgba(0, 0, 0, 0.25);
font-weight: bold;
resize: none;
text-align: center;
outline: none;
border: 0;
background: #f5f7fa;
box-sizing: border-box;
}
#rightNum {
white-space: nowrap;
line-height: 22px;
&::-webkit-scrollbar {
width: 5px;
height: 5px;
background-color: #efeae6;
}
}
.leftBox {
height: 100%;
text-align: left;
}
.edit-container {
border: 1px solid #f5f7fa;
display: flex;
background-color: #f5f7fa;
}
.fullScreen {
position: fixed;
z-index: 9999;
height: 100vh;
width: 100vw;
left: 0;
top: 0;
right: 0;
bottom: 0;
background-color: #f5f7fa;
}
</style>
index.js
import { nextTick } from 'vue';
export const handleTextareaInput = (viewJsonStr, event) => {
const textarea = event.target;
const cursorPosition: any = textarea.selectionStart; // 获取光标位置
const previousValue = viewJsonStr.value;
const newValue = textarea.value;
// 删除字符
if (newValue.length < previousValue.length) {
viewJsonStr.value = newValue;
// 新增字符
} else if (newValue.length > previousValue.length) {
viewJsonStr.value = newValue;
const value = textarea.value;
/* 符号自动补全*/
if (value[cursorPosition - 1] === '"' && value[cursorPosition] !== '"') {
textarea.value = value.slice(0, cursorPosition) + '"' + value.slice(cursorPosition);
viewJsonStr.value = textarea.value;
textarea.selectionStart = cursorPosition;
textarea.selectionEnd = cursorPosition;
}else if (value[cursorPosition - 1] === "'" && value[cursorPosition] !== "'") {
textarea.value = value.slice(0, cursorPosition) + "'" + value.slice(cursorPosition);
viewJsonStr.value = textarea.value;
textarea.selectionStart = cursorPosition;
textarea.selectionEnd = cursorPosition;
} else if (value[cursorPosition - 1] === '{' && value[cursorPosition] !== '{') {
textarea.value = value.slice(0, cursorPosition) + '}' + value.slice(cursorPosition);
viewJsonStr.value = textarea.value;
textarea.selectionStart = cursorPosition;
textarea.selectionEnd = cursorPosition;
} else if (value[cursorPosition - 1] === '[' && value[cursorPosition] !== ']') {
textarea.value = value.slice(0, cursorPosition) + ']' + value.slice(cursorPosition);
viewJsonStr.value = textarea.value;
textarea.selectionStart = cursorPosition;
textarea.selectionEnd = cursorPosition;
} else if (value[cursorPosition - 1] === '(' && value[cursorPosition] !== ')') {
textarea.value = value.slice(0, cursorPosition) + ')' + value.slice(cursorPosition);
viewJsonStr.value = textarea.value;
textarea.selectionStart = cursorPosition;
textarea.selectionEnd = cursorPosition;
}
// 字符串长度不变,可能是其他操作,如粘贴
} else {
viewJsonStr.value = newValue;
}
};
/*------------------------------------------------括号高亮------------------------------------------------------------*/
const findOpeningBracketIndex = (text, startIndex, char) => {
const openingBrackets = {
']': '[',
'}': '{',
')': '(',
};
let count = 0;
for (let i = startIndex; i >= 0; i--) {
if (text.charAt(i) === char) {
count++;
} else if (text.charAt(i) === openingBrackets[char]) {
count--;
if (count === 0) {
return i;
}
}
}
return -1;
};
const findClosingBracketIndex = (text, startIndex, char) => {
const closingBrackets = {
'[': ']',
'{': '}',
'(': ')',
};
let count = 0;
for (let i = startIndex; i < text.length; i++) {
if (text.charAt(i) === char) {
count++;
} else if (text.charAt(i) === closingBrackets[char]) {
count--;
if (count === 0) {
return i;
}
}
}
return -1;
};
const isBracket = (char) => {
return ['[', ']', '{', '}', '(', ')'].includes(char);
};
// 点击括号寻找对应另一半
export const handleClick = (event) => {
const textarea: any = document.getElementById('rightNum');
const { selectionStart, selectionEnd, value } = textarea;
const clickedChar = value.charAt(selectionStart);
if (isBracket(clickedChar)) {
const openingBracketIndex = findOpeningBracketIndex(value, selectionStart, clickedChar);
const closingBracketIndex = findClosingBracketIndex(value, selectionStart, clickedChar);
if (openingBracketIndex !== -1) {
textarea.setSelectionRange(openingBracketIndex, openingBracketIndex + 1);
} else if (closingBracketIndex !== -1) {
textarea.setSelectionRange(closingBracketIndex, closingBracketIndex + 1);
}
}
};
/*键盘事件*/
export function handleClickEnter(viewJsonStr, event) {
if (event.key == 'Enter') {
const textarea = event.target;
const cursorPosition: any = textarea.selectionStart; // 获取光标位置
const value = textarea.value;
if (
(value[cursorPosition - 1] === '{' && value[cursorPosition] == '}') ||
(value[cursorPosition - 1] === '[' && value[cursorPosition] == ']')
) {
textarea.value = value.slice(0, cursorPosition) + '\n' + value.slice(cursorPosition);
textarea.setSelectionRange(cursorPosition, cursorPosition);
viewJsonStr.value = textarea.value;
// 将光标移动到插入的空格后面
setTimeout(() => {
handleTabKey(syntheticEvent);
}, 30);
}
}
}
// 新建tab按键对象
const syntheticEvent = new KeyboardEvent('keydown', {
key: 'Tab',
});
// 按下tab键时的操作
export const handleTabKey = (event) => {
const textarea: any = document.getElementById('rightNum');
const { selectionStart, selectionEnd } = textarea;
const tabSpaces = ' '; // 4 spaces
event.preventDefault();
// 在当前光标位置插入4个空格
textarea.value =
textarea.value.substring(0, selectionStart) +
tabSpaces +
textarea.value.substring(selectionEnd);
// 将光标向右移动4个空格
textarea.selectionStart = selectionStart + tabSpaces.length;
textarea.selectionEnd = selectionStart + tabSpaces.length;
};
// 按下Backspace按键时
export function handleBackspace(viewJsonStr, event) {
const textarea = event.target;
const cursorPosition = textarea.selectionStart;
const textBeforeCursor = viewJsonStr.value.slice(0, cursorPosition);
const textAfterCursor = viewJsonStr.value.slice(cursorPosition);
if (
(textBeforeCursor.endsWith('"') && textAfterCursor.startsWith('"')) ||
(textBeforeCursor.endsWith("'") && textAfterCursor.startsWith("'")) ||
(textBeforeCursor.endsWith('[') && textAfterCursor.startsWith(']')) ||
(textBeforeCursor.endsWith('{') && textAfterCursor.startsWith('}')) ||
(textBeforeCursor.endsWith('(') && textAfterCursor.startsWith(')'))
) {
event.preventDefault(); // 阻止默认的删除行为
viewJsonStr.value = textBeforeCursor.slice(0, -1) + textAfterCursor.slice(1);
nextTick(() => {
textarea.selectionStart = cursorPosition - 1;
textarea.selectionEnd = cursorPosition - 1;
}).then((r) => {});
}
}
调用方法
<json-editor v-model:json-str="formData.sessionToolConfig" :rows="8" />
传参
v-model:json-str:双向绑定编辑器的值和使用的变量
rows:编辑器的行数