MediaWiki:PreDiphicTyper.js

来自HUIJIA FUN

注意:在发布之后,您可能需要清除浏览器缓存才能看到所作出的更改的影响。

  • Firefox或Safari:按住Shift的同时单击刷新,或按Ctrl-F5或Ctrl-R(Mac为⌘-R)
  • Google Chrome:按Ctrl-Shift-R(Mac为⌘-Shift-R)
  • Edge:按住Ctrl的同时单击刷新,或按Ctrl-F5。
/* MediaWiki:PreDiphicTyper.js
 * Pre-Diphic打字页(移植自PreDiphic_Typer.html;Pre-Diphic Mono 1.1 / Keyman pre_diphic 1.1)
 * 只在页面里有 <div id="pd-typer"></div> 时运行,由MediaWiki:Common.js按需加载。
 * 字体由MediaWiki:Common.css的@font-face提供,这里不内嵌字体。
 */
(function () {
  'use strict';

  /* ===== 转换逻辑:与PreDiphic_Typer.html第67–101行一致。改规则时两边同步。 ===== */
  var V = 'aeoiu', VO = [0xEE20, 0xEE21, 0xEE22, 0xEE23, 0xEE24];
  var CK = 'pbtdkgrnfvszlmqjxywhc',
    CO = [0xEE00, 0xEE01, 0xEE02, 0xEE03, 0xEE04, 0xEE05, 0xEE09, 0xEE0A, 0xEE0B, 0xEE0C, 0xEE0D, 0xEE0E, 0xEE13, 0xEE14, 0xEE15, 0xEE16, 0xEE17, 0xEE18, 0xEE19, 0xEE1A, 0xEE1B];
  var KO = { r: 0xEE25, l: 0xEE26, n: 0xEE27, m: 0xEE28 };
  var PK = ',.!?-', PO = [0xEE30, 0xEE31, 0xEE32, 0xEE33, 0xEE34];
  var DIG = '0123456789';
  var NULLC = 0xEE1F, RV = 0xEE29;
  var DI = {};
  DI[0xEE02 + ':s'] = 0xEE11; DI[0xEE03 + ':z'] = 0xEE12; DI[0xEE0D + ':t'] = 0xEE0F; DI[0xEE0E + ':d'] = 0xEE10;
  DI[0xEE0A + ':g'] = 0xEE1D; DI[0xEE1B + ':h'] = 0xEE06; DI[0xEE0E + ':h'] = 0xEE07; DI[0xEE0D + ':h'] = 0xEE08;
  function isV(c) { return VO.indexOf(c) !== -1; }

  function convert(src) {
    var o = [], syl = false;
    for (var ch of src) {
      var last = o.length ? o[o.length - 1] : null;
      if (ch === ' ') { if (syl) { o.push(0x20); syl = false; } else syl = true; continue; }
      if (ch === '\n') { o.push(10); syl = false; continue; }
      var wasSyl = syl; syl = false;
      if (V.indexOf(ch) !== -1) {
        var v = VO[V.indexOf(ch)];
        if (!wasSyl && last !== null && ((last >= 0xEE00 && last <= 0xEE1D) || isV(last))) o.push(v);
        else o.push(NULLC, v);
        continue;
      }
      if (PK.indexOf(ch) !== -1) { o.push(PO[PK.indexOf(ch)]); continue; }
      if (DIG.indexOf(ch) !== -1) { o.push(0xEE50 + DIG.indexOf(ch)); continue; }
      if (wasSyl) { if (CK.indexOf(ch) !== -1) o.push(CO[CK.indexOf(ch)]); else o.push(ch.codePointAt(0)); continue; }
      if (ch === "'") { if (last !== null && isV(last)) o.push(last); continue; }
      if (ch === '^') { if (last !== null && last >= 0xEE00 && last <= 0xEE1D) o.push(RV); continue; }
      if (ch === 'h' && o.length >= 2 && o[o.length - 2] === NULLC && last === 0xEE21) { o.pop(); o.pop(); o.push(0xEE1C); continue; } // eh
      if (last !== null && DI[last + ':' + ch] !== undefined) { o[o.length - 1] = DI[last + ':' + ch]; continue; }
      if (KO[ch] !== undefined && last !== null && (isV(last) || last === RV)) { o.push(KO[ch]); continue; }
      if (CK.indexOf(ch) !== -1) { o.push(CO[CK.indexOf(ch)]); continue; }
      o.push(ch.codePointAt(0));
    }
    return String.fromCodePoint.apply(null, o);
  }

  /* 罗马字:音节之间用连字符,词之间用空格(Yo-li Di-pi-ka) */
  function romanize(line) {
    return line.trim().split(/ {2,}/).map(function (w) { return w.trim().split(/ +/).join('-'); }).join(' ');
  }
  function escHtml(s) { return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }

  /* ===== 界面 ===== */
  function el(tag, cls, text) {
    var e = document.createElement(tag);
    if (cls) e.className = cls;
    if (text != null) e.textContent = text;
    return e;
  }
  function btn(label, onclick) {
    var b = el('button', null, label);
    b.type = 'button';
    b.onclick = onclick;
    return b;
  }

  function build(root) {
    root.textContent = '';
    var src = '';

    var out = el('textarea', 'pd pd-typer-out');
    out.spellcheck = false;
    out.placeholder = '在这里打字…';
    out.setAttribute('autocomplete', 'off');
    out.setAttribute('autocapitalize', 'off');
    out.setAttribute('aria-label', 'Pre-Diphic输入框');

    var msg = el('span', 'pd-typer-msg');
    var srcEl = el('div', 'pd-typer-src');

    /* ===== 编辑模型 =====
     * 框里显示的是转换后的Pre-Diphic字,真正保存的是你打的字母(src)。
     * 选区、光标位置都先换算回字母位置,再修改src,所以框选删除、在中间插字、Ctrl+Z都能用。 */
    var undoStack = [], redoStack = [];

    function render(caret) {
      out.value = convert(src);
      srcEl.textContent = src.replace(/ /g, '␣') || '(空)';
      var len = out.value.length;
      if (caret == null || caret > len) caret = len;
      out.setSelectionRange(caret, caret);
      if (caret === len) out.scrollLeft = out.scrollWidth;
    }

    // 框里的位置 → 字母位置。只认「转换结果正好是框里内容开头」的字母前缀。
    // 选区起点取不短于pos的最短前缀;选区终点取不超过pos的最长前缀(连带吃掉后面的空格)
    function prefixes() {
      var full = out.value, list = [];
      for (var k = 0; k <= src.length; k++) {
        var f = convert(src.slice(0, k));
        if (full.slice(0, f.length) === f) list.push([k, f.length]);
      }
      return list;
    }
    function startPos(list, pos) {
      for (var i = 0; i < list.length; i++) if (list[i][1] >= pos) return list[i][0];
      return src.length;
    }
    function endPos(list, pos) {
      var best = 0;
      for (var i = 0; i < list.length; i++) if (list[i][1] <= pos) best = list[i][0];
      return best;
    }

    // 记住上次编辑后光标在字母里的位置:光标没被移动过时直接用它,避免空格(不占位置)造成歧义
    var lastOut = { a: 0, b: 0 }, lastSrc = { a: 0, b: 0 };
    var lastEdit = { kind: '', time: 0 };

    function snapshot() {
      var a = out.selectionStart, b = out.selectionEnd, same = a === lastOut.a && b === lastOut.b;
      return { src: src, a: a, b: b, sa: same ? lastSrc.a : null, sb: same ? lastSrc.b : null };
    }
    function restore(st) {
      src = st.src; render();
      out.setSelectionRange(st.a, st.b);
      if (st.sa != null) { lastOut = { a: st.a, b: st.b }; lastSrc = { a: st.sa, b: st.sb }; }
      else { lastOut = { a: -1, b: -1 }; }
      lastEdit = { kind: '', time: 0 };
    }
    function undo() { if (!undoStack.length) return; redoStack.push(snapshot()); restore(undoStack.pop()); }
    function redo() { if (!redoStack.length) return; undoStack.push(snapshot()); restore(redoStack.pop()); }

    // 用text替换当前选区;没有选区时,dir=-1删前一个字母,dir=1删后一个字母
    function replaceSel(text, dir) {
      var a = out.selectionStart, b = out.selectionEnd, ka, kb;
      if (a === lastOut.a && b === lastOut.b) {
        ka = lastSrc.a; kb = lastSrc.b;
      } else if (a === 0 && b === out.value.length) {
        ka = 0; kb = src.length;                       // 全选
      } else if (a === b && a === out.value.length) {
        ka = kb = src.length;                          // 光标在最后
      } else {
        var list = prefixes();
        ka = startPos(list, a);
        kb = a === b ? ka : Math.max(ka, endPos(list, b));
      }
      if (ka === kb && dir < 0) ka = Math.max(0, ka - 1);
      if (ka === kb && dir > 0) kb = Math.min(src.length, kb + 1);
      if (ka === kb && !text) return;

      // 连续打字合并成一步撤销;遇到空格、换行、删除或停顿超过1秒就另起一步
      var kind = text ? (/[\s]/.test(text) || text.length > 1 ? 'other' : 'type') : 'delete';
      var now = Date.now();
      var merge = kind !== 'other' && kind === lastEdit.kind && now - lastEdit.time < 1000 &&
        a === lastOut.a && b === lastOut.b;
      if (!merge) {
        undoStack.push(snapshot());
        if (undoStack.length > 500) undoStack.shift();
      }
      redoStack = [];
      lastEdit = { kind: kind, time: now };

      src = src.slice(0, ka) + text + src.slice(kb);
      var caretSrc = ka + text.length;
      var caretOut = convert(src.slice(0, caretSrc)).length;
      render(caretOut);
      lastOut = { a: out.selectionStart, b: out.selectionEnd };
      lastSrc = { a: caretSrc, b: caretSrc };
    }
    function add(s) { replaceSel(s, 0); }

    function flash(t) { msg.textContent = t; setTimeout(function () { msg.textContent = ''; }, 1800); }
    function clip(t, m) {
      var done = function () { flash(m); };
      if (navigator.clipboard && navigator.clipboard.writeText) {
        navigator.clipboard.writeText(t).then(done, function () { fallback(t); done(); });
      } else { fallback(t); done(); }
    }
    function fallback(t) {
      var ta = el('textarea'); ta.value = t; ta.style.position = 'fixed'; ta.style.opacity = '0';
      document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove();
    }
    function lines() { return src.split('\n').filter(function (l) { return l.trim(); }); }

    out.addEventListener('keydown', function (e) {
      if (!(e.ctrlKey || e.metaKey) || e.altKey) return;
      var k = e.key.toLowerCase();
      if (k === 'z' && !e.shiftKey) { e.preventDefault(); undo(); }
      else if (k === 'y' || (k === 'z' && e.shiftKey)) { e.preventDefault(); redo(); }
    });
    out.addEventListener('beforeinput', function (e) {
      var t = e.inputType;
      e.preventDefault();
      if (t === 'insertText' || t === 'insertCompositionText' || t === 'insertReplacementText') {
        if (e.data) add(e.data.toLowerCase());
      } else if (t === 'insertLineBreak' || t === 'insertParagraph') {
        add('\n');
      } else if (t === 'insertFromDrop' || t === 'insertFromPaste') {
        add(((e.dataTransfer && e.dataTransfer.getData('text')) || '').toLowerCase());
      } else if (t === 'historyUndo') {
        undo();
      } else if (t === 'historyRedo') {
        redo();
      } else if (t.indexOf('delete') === 0) {
        // 有选区:删除整个选区(框选删除、剪切);没有选区:Backspace删前一个字母,Delete删后一个
        replaceSel('', /Forward$/.test(t) ? 1 : -1);
      }
    });
    out.addEventListener('paste', function (e) { e.preventDefault(); add((e.clipboardData.getData('text') || '').toLowerCase()); });

    var size = el('input'); size.type = 'range'; size.min = 24; size.max = 120; size.value = 48;
    size.oninput = function () { out.style.fontSize = size.value + 'px'; };
    var sizeLab = el('label', null, '字号 '); sizeLab.appendChild(size);

    var row = el('div', 'pd-typer-row');
    row.appendChild(sizeLab);
    row.appendChild(btn('复制文字', function () { clip(out.value, '已复制文字'); }));
    row.appendChild(btn('复制为模板', function () {
      clip(lines().map(function (l) {
        return '{{PD|内容=' + l.trim() + '}}';
      }).join('<br>\n'), '已复制{{PD}}模板');
    }));
    row.appendChild(btn('复制为HTML', function () {
      clip(lines().map(function (l) {
        return '<span class="pd pd-v" title="' + escHtml(romanize(l)) + '">' + escHtml(convert(l)) + '</span>';
      }).join('<br>\n'), '已复制HTML');
    }));
    row.appendChild(btn('下载.txt', function () {
      var a = document.createElement('a');
      a.href = URL.createObjectURL(new Blob([out.value], { type: 'text/plain;charset=utf-8' }));
      a.download = 'pre-diphic.txt'; a.click();
    }));
    row.appendChild(btn('清空', function () { out.select(); replaceSel('', 0); out.focus(); }));
    row.appendChild(msg);

    var kbC = el('div', 'pd-typer-kb'), kbV = el('div', 'pd-typer-kb');
    function key(host, items) {
      items.forEach(function (it) {
        var b = btn('', function () { add(it[1]); out.focus(); });
        b.appendChild(el('span', 'pd', String.fromCodePoint(it[2])));
        b.appendChild(el('small', null, it[0]));
        host.appendChild(b);
      });
    }
    var cons = ['p', 'b', 't', 'd', 'k', 'g', 'ch', 'zh', 'sh', 'r', 'n', 'f', 'v', 's', 'z', 'st', 'zd', 'ts', 'dz', 'l', 'm', 'q', 'j', 'x', 'y', 'w', 'h', 'c', 'eh', 'ng'];
    key(kbC, cons.map(function (c, i) { return [c, c, 0xEE00 + i]; }));
    key(kbV, Array.from('aeoiu').map(function (v, i) { return [v, v, 0xEE20 + i]; })
      .concat(Array.from('rlnm').map(function (v, i) { return [v + '(尾)', v, 0xEE25 + i]; }))
      .concat([['^', '^', 0xEE29]]));
    kbV.appendChild(btn("长音 '", function () { add("'"); out.focus(); }));
    kbV.appendChild(btn('空格 ␣', function () { add(' '); out.focus(); }));
    kbV.appendChild(btn('换词 ␣␣', function () { add('  '); out.focus(); }));

    root.appendChild(out);
    root.appendChild(row);
    root.appendChild(el('div', 'pd-typer-label', '你打的字母:'));
    root.appendChild(srcEl);
    root.appendChild(el('div', 'pd-typer-h', '点击输入'));
    root.appendChild(kbC);
    root.appendChild(kbV);
    render();
  }

  function init($content) {
    var root = ($content && $content[0] && $content[0].querySelector('#pd-typer')) || document.getElementById('pd-typer');
    if (root && !root.getAttribute('data-ready')) {
      root.setAttribute('data-ready', '1');
      build(root);
    }
  }
  if (window.mw && mw.hook) mw.hook('wikipage.content').add(init);
  else init();
}());