-
-
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
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==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', "&", "<", " "]; | |
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: "​"}, 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