339 lines
11 KiB
HTML
339 lines
11 KiB
HTML
<!doctype html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
|
<title>📒JSON编辑器</title>
|
|
|
|
<!-- CodeMirror 样式与 Lint 样式 -->
|
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/codemirror@5/lib/codemirror.css" />
|
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/codemirror@5/theme/neo.css" />
|
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/codemirror@5/addon/lint/lint.css" />
|
|
|
|
<style>
|
|
:root {
|
|
--toolbar-h: 64px;
|
|
--radius: 14px;
|
|
--bg-start: #dff7e0; /* 淡绿色 */
|
|
--bg-end: #f2ffd2; /* 淡黄绿色 */
|
|
--brand: #4caf50; /* 绿色主题色 */
|
|
--brand-weak: #7bd07f;
|
|
--text: #103510;
|
|
--muted: #366b36;
|
|
--danger: #e53935;
|
|
--shadow: 0 10px 25px rgba(16, 53, 16, 0.08);
|
|
}
|
|
|
|
html, body {
|
|
height: 100%;
|
|
background: linear-gradient(180deg, var(--bg-start) 0%, var(--bg-end) 100%);
|
|
color: var(--text);
|
|
font-family: system-ui, -apple-system, Segoe UI, Roboto, PingFang SC, Microsoft YaHei, "Noto Sans", Helvetica, Arial, sans-serif;
|
|
-webkit-font-smoothing: antialiased;
|
|
-moz-osx-font-smoothing: grayscale;
|
|
overflow: auto; /* 保留滚动 */
|
|
}
|
|
/* 隐藏滚动条同时保留滚动效果 */
|
|
html, body { scrollbar-width: none; }
|
|
html::-webkit-scrollbar, body::-webkit-scrollbar { width: 0; height: 0; }
|
|
|
|
.page {
|
|
min-height: calc(var(--vh, 1vh) * 100);
|
|
display: flex;
|
|
flex-direction: column;
|
|
padding: 14px;
|
|
gap: 12px;
|
|
}
|
|
|
|
.toolbar {
|
|
height: var(--toolbar-h);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
}
|
|
.title {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
font-weight: 700;
|
|
font-size: 18px;
|
|
color: var(--muted);
|
|
letter-spacing: .2px;
|
|
}
|
|
.title .dot {
|
|
width: 8px; height: 8px; border-radius: 99px; background: var(--brand);
|
|
box-shadow: 0 0 0 3px rgba(76, 175, 80, .18);
|
|
}
|
|
|
|
.actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
flex-wrap: wrap;
|
|
}
|
|
.btn {
|
|
-webkit-tap-highlight-color: transparent;
|
|
user-select: none;
|
|
outline: none;
|
|
border: none;
|
|
padding: 10px 14px;
|
|
border-radius: 10px;
|
|
font-size: 15px;
|
|
font-weight: 600;
|
|
color: #0f3210;
|
|
background: linear-gradient(180deg, #e6ffe6, #d8ffd8);
|
|
box-shadow: var(--shadow);
|
|
transition: transform .06s ease, box-shadow .2s ease;
|
|
}
|
|
.btn:active { transform: scale(.98); }
|
|
.btn.primary { background: linear-gradient(180deg, #d7ffda, #caffcf); color: #0c2b0d; }
|
|
.btn.danger { background: linear-gradient(180deg, #ffe6e6, #ffdcdc); color: #5f1b1b; }
|
|
|
|
.editor-wrap {
|
|
position: relative;
|
|
flex: 1;
|
|
background: rgba(255,255,255,.55);
|
|
backdrop-filter: saturate(150%) blur(6px);
|
|
border-radius: var(--radius);
|
|
box-shadow: var(--shadow);
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* CodeMirror 基本样式与滚动条隐藏 */
|
|
.CodeMirror {
|
|
height: calc(var(--vh, 1vh) * 100 - var(--toolbar-h) - 40px);
|
|
font-size: 15px;
|
|
line-height: 1.6;
|
|
padding: 12px 0;
|
|
background: transparent;
|
|
}
|
|
.CodeMirror-scroll { scrollbar-width: none; }
|
|
.CodeMirror-scroll::-webkit-scrollbar { width: 0; height: 0; }
|
|
.CodeMirror-gutters { background: transparent; border-right: none; }
|
|
|
|
.cm-error-line { background: rgba(229, 57, 53, .08); }
|
|
|
|
.statusbar {
|
|
position: absolute; right: 12px; bottom: 12px;
|
|
display: inline-flex; align-items: center; gap: 8px;
|
|
background: rgba(255,255,255,.75);
|
|
border-radius: 999px; padding: 8px 12px; box-shadow: var(--shadow);
|
|
font-size: 13px; color: var(--muted);
|
|
}
|
|
.status-dot { width: 8px; height: 8px; border-radius: 99px; background: #a0a0a0; box-shadow: 0 0 0 3px rgba(0,0,0,.08); }
|
|
.statusbar.good .status-dot { background: #27ae60; box-shadow: 0 0 0 3px rgba(39,174,96,.18); }
|
|
.statusbar.bad .status-dot { background: var(--danger); box-shadow: 0 0 0 3px rgba(229,57,53,.18); }
|
|
|
|
@media (max-width: 640px) {
|
|
:root { --toolbar-h: 76px; }
|
|
.title { font-size: 16px; }
|
|
.btn { padding: 11px 14px; font-size: 15px; }
|
|
.actions { gap: 6px; }
|
|
}
|
|
</style>
|
|
|
|
<!-- CodeMirror 与 JSONLint 脚本 -->
|
|
<script src="https://cdn.jsdelivr.net/npm/codemirror@5/lib/codemirror.js"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/codemirror@5/mode/javascript/javascript.js"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/codemirror@5/addon/lint/lint.js"></script>
|
|
<!-- JSONLint 提供更完善的错误定位(行/列) -->
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/jsonlint/1.6.0/jsonlint.min.js"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/codemirror@5/addon/lint/json-lint.js"></script>
|
|
</head>
|
|
<body>
|
|
<div class="page">
|
|
<header class="toolbar">
|
|
<div class="title">
|
|
<span class="dot"></span>
|
|
<span>📒JSON 编辑器</span>
|
|
</div>
|
|
<div class="actions">
|
|
<button class="btn" id="pasteBtn" title="从剪贴板粘贴">粘贴</button>
|
|
<button class="btn" id="copyBtn" title="复制到剪贴板">复制</button>
|
|
<button class="btn primary" id="formatBtn" title="格式化为缩进">格式化</button>
|
|
<button class="btn" id="minifyBtn" title="压缩为单行">压缩</button>
|
|
<button class="btn danger" id="clearBtn" title="清空内容">清空</button>
|
|
</div>
|
|
</header>
|
|
|
|
<section class="editor-wrap">
|
|
<textarea id="jsonInput" spellcheck="false">{
|
|
"hello": "world",
|
|
"list": [1, 2, 3],
|
|
"nested": { "a": true, "b": null }
|
|
}</textarea>
|
|
<div id="statusbar" class="statusbar" aria-live="polite">
|
|
<span class="status-dot"></span>
|
|
<span id="statusText">就绪</span>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<script>
|
|
// 解决移动端 100vh 问题,保证竖屏高度正确
|
|
function setVH() {
|
|
const vh = window.innerHeight * 0.01;
|
|
document.documentElement.style.setProperty('--vh', `${vh}px`);
|
|
}
|
|
setVH();
|
|
window.addEventListener('resize', setVH);
|
|
window.addEventListener('orientationchange', setVH);
|
|
|
|
// 初始化 CodeMirror
|
|
const cm = CodeMirror.fromTextArea(document.getElementById('jsonInput'), {
|
|
mode: { name: 'javascript', json: true },
|
|
theme: 'neo',
|
|
lineNumbers: true,
|
|
lint: true,
|
|
gutters: ['CodeMirror-lint-markers', 'CodeMirror-linenumbers'],
|
|
tabSize: 2,
|
|
indentUnit: 2,
|
|
lineWrapping: false,
|
|
autofocus: true,
|
|
});
|
|
|
|
const statusbar = document.getElementById('statusbar');
|
|
const statusText = document.getElementById('statusText');
|
|
let lastErrorLine = null;
|
|
|
|
function updateStatusOK() {
|
|
statusbar.classList.add('good');
|
|
statusbar.classList.remove('bad');
|
|
statusText.textContent = 'JSON 有效';
|
|
}
|
|
function updateStatusError(msg, line, col) {
|
|
statusbar.classList.remove('good');
|
|
statusbar.classList.add('bad');
|
|
statusText.textContent = msg;
|
|
if (typeof line === 'number') {
|
|
if (lastErrorLine != null) {
|
|
cm.removeLineClass(lastErrorLine - 1, 'background', 'cm-error-line');
|
|
}
|
|
lastErrorLine = line;
|
|
cm.addLineClass(line - 1, 'background', 'cm-error-line');
|
|
}
|
|
}
|
|
|
|
function clearErrorHighlight() {
|
|
if (lastErrorLine != null) {
|
|
cm.removeLineClass(lastErrorLine - 1, 'background', 'cm-error-line');
|
|
lastErrorLine = null;
|
|
}
|
|
}
|
|
|
|
const debounce = (fn, wait = 300) => {
|
|
let t; return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), wait); };
|
|
};
|
|
|
|
const validate = () => {
|
|
const txt = cm.getValue();
|
|
if (!txt.trim()) {
|
|
clearErrorHighlight();
|
|
statusbar.classList.remove('good','bad');
|
|
statusText.textContent = '空内容';
|
|
return;
|
|
}
|
|
try {
|
|
// 使用 jsonlint 以获得行列信息
|
|
window.jsonlint.parse(txt);
|
|
clearErrorHighlight();
|
|
updateStatusOK();
|
|
} catch (e) {
|
|
// e.message 形如:"Parse error on line 3:\n...\nExpected 'STRING', got 'undefined'"
|
|
let line, col;
|
|
const lineMatch = /line\s+(\d+)/i.exec(e.message || '');
|
|
if (lineMatch) line = parseInt(lineMatch[1], 10);
|
|
const colMatch = /column\s+(\d+)/i.exec(e.message || '');
|
|
if (colMatch) col = parseInt(colMatch[1], 10);
|
|
const msg = (e.message || 'JSON 解析错误').split('\n')[0];
|
|
updateStatusError(msg, line, col);
|
|
}
|
|
};
|
|
|
|
cm.on('change', debounce(validate, 250));
|
|
// 首次校验
|
|
validate();
|
|
|
|
// 按钮逻辑
|
|
const copyBtn = document.getElementById('copyBtn');
|
|
const pasteBtn = document.getElementById('pasteBtn');
|
|
const formatBtn = document.getElementById('formatBtn');
|
|
const minifyBtn = document.getElementById('minifyBtn');
|
|
const clearBtn = document.getElementById('clearBtn');
|
|
|
|
async function copyToClipboard(text) {
|
|
try {
|
|
await navigator.clipboard.writeText(text);
|
|
statusText.textContent = '已复制到剪贴板';
|
|
statusbar.classList.add('good'); statusbar.classList.remove('bad');
|
|
} catch (err) {
|
|
// 作为回退方案
|
|
const ta = document.createElement('textarea');
|
|
ta.value = text; document.body.appendChild(ta);
|
|
ta.select(); document.execCommand('copy');
|
|
document.body.removeChild(ta);
|
|
statusText.textContent = '复制完成(回退方案)';
|
|
statusbar.classList.add('good'); statusbar.classList.remove('bad');
|
|
}
|
|
}
|
|
|
|
async function readFromClipboard() {
|
|
try {
|
|
const text = await navigator.clipboard.readText();
|
|
return text;
|
|
} catch (err) {
|
|
const text = window.prompt('浏览器限制了读取剪贴板,请粘贴到此:');
|
|
return text || '';
|
|
}
|
|
}
|
|
|
|
copyBtn.addEventListener('click', () => copyToClipboard(cm.getValue()));
|
|
|
|
pasteBtn.addEventListener('click', async () => {
|
|
const text = await readFromClipboard();
|
|
if (text) {
|
|
cm.setValue(text);
|
|
cm.focus();
|
|
}
|
|
});
|
|
|
|
formatBtn.addEventListener('click', () => {
|
|
const txt = cm.getValue();
|
|
try {
|
|
const obj = JSON.parse(txt);
|
|
cm.setValue(JSON.stringify(obj, null, 2));
|
|
cm.focus();
|
|
updateStatusOK();
|
|
} catch (e) {
|
|
updateStatusError('格式化失败:内容不是有效 JSON');
|
|
}
|
|
});
|
|
|
|
minifyBtn.addEventListener('click', () => {
|
|
const txt = cm.getValue();
|
|
try {
|
|
const obj = JSON.parse(txt);
|
|
cm.setValue(JSON.stringify(obj));
|
|
cm.focus();
|
|
updateStatusOK();
|
|
} catch (e) {
|
|
updateStatusError('压缩失败:内容不是有效 JSON');
|
|
}
|
|
});
|
|
|
|
clearBtn.addEventListener('click', () => {
|
|
cm.setValue('');
|
|
statusbar.classList.remove('good','bad');
|
|
statusText.textContent = '已清空';
|
|
clearErrorHighlight();
|
|
cm.focus();
|
|
});
|
|
|
|
// 触控体验优化:增大点击区域与取消按钮文本选择
|
|
document.querySelectorAll('.btn').forEach(btn => {
|
|
btn.style.touchAction = 'manipulation';
|
|
});
|
|
</script>
|
|
</body>
|
|
</html> |