Skip to content

Instantly share code, notes, and snippets.

@agregen
Last active March 7, 2022 00:22
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save agregen/7d104325e5cc42bd7c6a4b4f274ef168 to your computer and use it in GitHub Desktop.
Save agregen/7d104325e5cc42bd7c6a4b4f274ef168 to your computer and use it in GitHub Desktop.
Multiline JSON/plain text editor dialog with support for syntax highlighting, pretty-formatting and JSON validation
<!DOCTYPE html>
<html><head>
<meta charset="UTF-8"/>
<meta name="viewport" content="initial-scale=1, maximum-scale=.75"/>
<title>JSON editor demo</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.27.0/prism.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.27.0/components/prism-json.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.27.0/plugins/match-braces/prism-match-braces.min.js"></script>
<!--script src="json-edit.js"></script-->
<script>
var $modal, $text;
function init () {
$preview.innerText = $text =
`{\n "foo": "bar baz",\n "groot": "I am Groot",\n "bool": [true, false, [[[null]]]],\n "answer": 42\n}`;
document.body.append($modal = $jsonEdit.createEditorModal('MODAL', {maxWidth: '1024px'}));
document.head.querySelector('style').innerHTML += $jsonEdit.theme('main');
redraw();
}
function update (value, {mode}={}) {
$modal.render($preview, $text = value, mode);
}
function pformat (value) {
return $jsonEdit.pformat(value, {sparse: $sparse.checked, compact: $compact.checked});
}
function redraw () {
let o = $jsonEdit.parseJson($text);
(o instanceof Error) || update(pformat(o), {mode: 'json'});
}
// can't load JS from gist the normal way
fetch("https://gist.githubusercontent.com/agregen/7d104325e5cc42bd7c6a4b4f274ef168/raw/json-edit.user.js")
.then(x => x.text()).then(script => {
let e = document.createElement('script');
e.append( document.createTextNode(script) );
document.head.append(e);
setTimeout(init);
});
</script>
<style>
main pre {padding: 4px; overflow: auto}
main .toolbar {position: sticky; top: 0; padding: 1ex; background: white}
</style>
</head><body><!-- onload="init()"-->
<main>
<div class="toolbar">
<button onclick="$modal.editJson($text).then(pformat).then(update)">Edit</button>
<button onclick="$modal.editText($text).then(update)">Edit as text</button>
<label><input id="$sparse" type="checkbox" onchange="redraw()"/> Sparse</label>
<label><input id="$compact" type="checkbox" onchange="redraw()"/> Compact</label>
</div>
<pre><code id="$preview" class="preview language-json match-braces"></code></pre>
</main>
</body></html>
// ==UserScript==
// @name Json edit
// @namespace https://agregen.gitlab.io/
// @version 0.0.1
// @description JSON editor dialog (intended as a library for userscripts)
// @author agreg
// @license MIT
// @match http://localhost:*
// @require https://cdnjs.cloudflare.com/ajax/libs/prism/1.27.0/prism.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/prism/1.27.0/components/prism-json.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/prism/1.27.0/plugins/match-braces/prism-match-braces.min.js
// ==/UserScript==
var Prism, $jsonEdit = (() => {
const [TAB, NEWLINE, AMPERSAND, LESS_THAN, NONBREAKING_SPACE] = ['\t', '\n', "&amp;", "&lt;", "&nbsp;"];
let {isArray} = Array, isColl = x => x && (typeof x === 'object'); // ignoring non-JSON types
let spaces = n => Array.from({length: n}, _ => " ").join("");
let compactItems = (items, {width, indent}) => items.slice(1).reduce((ss, s) => {
(ss[0].length + 2 + s.length > width ? ss.unshift(s) : (ss[0] += ", " + s));
return ss;
}, [items[0]]).reverse().join(`,\n${indent}`);
let pformat = (x, {indent="", width=80, sparse=false, compact=false}={}) => {
if (!isColl(x))
return JSON.stringify(x);
let [bLeft, bRight] = (isArray(x) ? ["[", "]"] : ["{", "}"]);
let _indent = isArray(x) ? (k => spaces(sparse ? 0 : 1)) : (k => spaces(sparse ? 2 : 3+k.length));
let kvs = Object.entries(x).map(([k, v]) => [JSON.stringify(!isArray(x) ? k : Number(k)), v]);
let items = kvs.map(([k, v]) => [k, pformat(v, {width, sparse, compact, indent: indent+_indent(k)})])
.map(([k, v]) => (isArray(x) ? v : `${k}: ${v}`));
let len = indent.length + items.map(s => 2+s.length).reduce((a, b) => a+b, 0);
let flat = !items.some(s => s.includes(NEWLINE));
let _wrap = (s, wrap=s.includes(NEWLINE) && !(isArray(x) && !flat)) => !wrap ? s : `\n${indent} ${s}\n${indent}`;
let _compact = (sep, {_sparse=sparse, _indent=indent+" ", _width=width-_indent.length+1}={}) =>
(_sparse ? _wrap(_compact(sep, {_sparse: false, _width: _width-2, _indent: (flat ? _indent+" " : indent)})) :
!compact || !flat ? items.join((sparse && !flat) || (flat && (len <= width)) ? ", " : sep) :
compactItems(items, {width: _width, indent: _indent}));
return bLeft + (flat && (items.length < 2) ? items[0] || "" :
!sparse ? _compact(`,\n ${indent}`) :
isArray(x) ? _compact(`,\n ${indent}`) :
_wrap(items.join(`,\n ${indent}`), true)) + bRight;
};
class ParseError extends Error {
constructor(msg, {position, ...options}) {super(msg, options); this.position = Number(position)}
}
let _humanize = s => JSON.stringify( `${s||""}`.replace(/^[a-z]/, c => c.toUpperCase()) ).slice(1, -1);
let parseJson = s => {
try {
return JSON.parse(s);
} catch (e) {
let match, _msg = s => _humanize( `${s||""}`.replaceAll(TAB, "\tab").replaceAll(NEWLINE, "\newline") ); // [sic]
if (match = e.message.match(/^([^]*) in JSON at position ([0-9]+)$|^(Unexpected end of JSON input)$/)) {
let [_, msg, pos, altMsg] = match;
return new ParseError(_msg(msg||altMsg), {cause: e, position: pos||s.length});
} else if (match = e.message.match(/^JSON\.parse: ([^]*) at line ([0-9]+) column ([0-9]+) of the JSON data$/)) {
let [_, msg, row, col] = match;
return new ParseError(_msg(msg), {cause: e, position: Number(col) + (row == 1 ? -1 : s.split('\n').slice(0, row-1).join('\n').length)});
} else {
console.warn(e);
return new ParseError(_msg(e.message), {cause: e, position: 0});
}
}
};
let captureTab = editor => event => {
if (event.key == "Tab") {
event.preventDefault();
let before = editor.value.slice(0, editor.selectionStart);
let after = editor.value.slice(editor.selectionEnd, editor.value.length);
let pos = editor.selectionEnd + 1;
editor.value = before + TAB + after;
editor.selectionStart = editor.selectionEnd = pos;
update(editor.value);
}
}
let $e = (tag, attrs, ...children) => {
let e = Object.assign(document.createElement(tag), attrs);
children.forEach(child => e.append(typeof child != 'string' ? child : document.createTextNode(child)));
return e;
};
let theme = selector => `${selector} pre {color:white; background:black}
${selector} .token.string {color:slateblue}
${selector} .token.property {color:orange}
${selector} .token.number {color:green}
${selector} .token:is(.boolean, .null) {color:deeppink}
${selector} .token.operator {color:yellowgreen}
${selector} .token.punctuation {color:grey}
${selector} .token:is(.brace-level-2, .brace-level-6, .brace-level-10) {color:#388}
${selector} .token:is(.brace-level-3, .brace-level-7, .brace-level-11) {color:#838}
${selector} .token:is(.brace-level-4, .brace-level-8, .brace-level-12) {color:#883}`;
let createEditorModal = (id, {maxWidth='90%'}={}) => {
const ID = '#'+id;
let style = $e('style', {},
`${ID} {position:fixed; height:calc(90vh - 2em); top:5vh; width:calc(90vw - 2em);
max-width:${maxWidth}; left:0; right:0; margin:0 auto; z-index:1000}
${ID}, ${ID} .window {display:flex; flex-direction:column}
${ID} .title {padding:0 1em; background:lightgrey; font-weight:bold; font-size:larger}
${ID} .window {position: relative; padding:1em; padding-top:0; background:grey}
${ID} :is(.title, .window > *) {flex:0} ${ID} :is(.window, .editor) {flex-grow:1}
${ID} .editor {position:relative; height:calc(100% - 6em)}
${ID} .editor > * {position:absolute; top:0; left:0; width:calc(100% - 6px); height:100%; overflow:auto; margin:0}
${ID} .editor :is(textarea, pre) {font-family:monospace; font-size:15pt; line-height:20pt;
border-radius:5px; white-space:pre; hyphens:none}
${ID}.text .editor :is(textarea, pre) {white-space:pre-wrap; word-wrap:break-word}
${ID} .editor textarea {resize:none; z-index:2; background:transparent; color:transparent; caret-color:white}
${ID} .editor pre {z-index:1; margin:0; overflow:auto; padding:3px}
@-moz-document url-prefix() {${ID} .editor pre {padding:4px}}
${theme(ID)}
${ID} :is(.toolbar, .buttons) {margin-top:1em; display:flex; justify-content:space-evenly}
${ID} .toolbar input[type=number] {width:4em; background:white}
${ID} .error {color:yellow; font-weight:bold; font-family:monospace}`);
setTimeout(() => document.head.append(style));
let modal, title, error, editor, overlay, content, toolbar, sparse, compact, width, redraw, cancel, ok;
modal = $e('div', {id, className: 'modal', style: "display:none", mode: 'json'},
title = $e('div', {className: 'title'}, ""),
$e('div', {className: 'window'},
$e('div', {className: 'error', innerHTML: "&ZeroWidthSpace;"}, error = $e('span')),
$e('div', {className: 'editor'},
editor = $e('textarea'),
overlay = $e('pre', {}, content = $e('code', {className: "highlighting language-json match-braces"}))),
toolbar = $e('div', {className: 'toolbar'},
$e('label', {title: "Don't inline dicts"},
sparse = $e('input', {type: 'checkbox', className: 'sparse'}),
" Sparse"),
$e('label', {title: "Compact long lists"},
compact = $e('input', {type: 'checkbox', className: 'compact'}),
" Compact"),
$e('label', {title: "Width limit (0 = unlimited)"},
"Width ",
width = $e('input', {type: 'number', className: 'width', value: 100, min: 0})),
redraw = $e('button', {className: 'redraw'}, "Check / Reformat")),
$e('div', {className: 'buttons'},
cancel = $e('button', {className: 'cancel'}, "Cancel"),
ok = $e('button', {className: 'ok'}, "OK"))));
let _isValid, _width = Number(width.value)||Infinity;
let render = (e, text, mode=modal.mode) => {
e.innerHTML = text.replace(/&/g, AMPERSAND).replace(/</g, LESS_THAN); // can't use innerText here
(mode === 'json') && Prism && Prism.highlightElement(e);
};
let update = text => render(content, text + (text.slice(-1) != NEWLINE ? "" : " "));
let syncScroll = () => {[overlay.scrollTop, overlay.scrollLeft] = [editor.scrollTop, editor.scrollLeft]};
let detectWidth = () => {
let style = "font-family:monospace; font-size:15pt; line-height:20pt; position:fixed; top:0; left:0";
let e = $e('span', {style, innerHTML: NONBREAKING_SPACE});
modal.append(e);
width.value = _width = Math.floor(editor.clientWidth / e.clientWidth);
e.remove();
};
let reformat = (s = editor.value, o = parseJson(s)) => {
if (o instanceof Error) {
error.innerText = o.message || "Syntax error";
console.warn(o, {position: o.position});
editor.focus();
editor.selectionStart = editor.selectionEnd = o.position||0;
} else {
error.innerText = "";
editor.value = pformat(o, {sparse: sparse.checked, compact: compact.checked, width: _width});
editor.selectionStart = editor.selectionEnd = 0;
update(editor.value);
syncScroll();
}
};
let _visible = () => modal.style.display !== 'none';
let toggle = (visible=!_visible()) => {modal.style.display = (visible ? '' : 'none')};
let _resolve, [editJson, editText] = ['json', 'text'].map(mode => (value=editor.value, options={}) => new Promise(resolve => {
[modal.mode, _resolve, _isValid] = [mode, resolve, options.validator];
toolbar.style.display = (mode == 'json' ? '' : 'none');
modal.classList[mode == 'json' ? 'remove' : 'add']('text');
[error.innerText, editor.value, title.innerText] = ["", value, options.title||`Enter ${mode}`];
render(content, value);
toggle(true);
detectWidth();
editor.focus();
}));
let editAsJson = (value, options={}) => (setTimeout(reformat), editJson(JSON.stringify(value), options));
let resolve = (value=editor.value) => (toggle(false), _resolve && _resolve(value));
document.addEventListener('keydown', ({key}) => (key == 'Escape') && toggle(false));
cancel.onclick = () => toggle();
ok.onclick = () => {
if (modal.mode != 'json') resolve(); else {
let s = editor.value, o = parseJson(s);
if (!(o instanceof Error))
resolve(o)
else {
try {if (_isValid(s)) return resolve()} catch (e) {}
reformat(s, o)
}
}
};
editor.oninput = () => {update(editor.value); syncScroll()};
editor.onscroll = syncScroll;
editor.onkeydown = captureTab(editor);
redraw.onclick = sparse.onchange = compact.onchange = () => reformat();
width.onchange = () => {
if (!width.value || !Number.isInteger( Number(width.value) ))
alert(`Invalid width value: ${width.value}`);
else {
_width = Number(width.value)||Infinity;
reformat();
}
};
return Object.assign(modal, {toggle, editText, editJson, editAsJson, render});
};
return {pformat, ParseError, parseJson, theme, createEditorModal};
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment