Json edit

JSON editor dialog (intended as a library for userscripts)

此腳本不應該直接安裝,它是一個供其他腳本使用的函式庫。欲使用本函式庫,請在腳本 metadata 寫上: // @require https://update.greasyfork.org/scripts/441035/1250389/Json%20edit.js

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==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: "&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};
})();