generated at
emoji-selector
マルチスレッド&日本語入力対応版
hr
絵文字を入力補完できるUserScript
/yutaro/emoji selectorをfirefoxでも使用できるように修正した
ScrapScriptのコード を参考にした

本家との違い
/iconsを自動で候補に読み込んでいる
tab キーを使って入力候補を選択できるように変更した
その他、ES6記法に一部書き換えた

課題
日本語入力にも対応したい

更新とか
2020/8/18 テーブル記法の頭で動作しないようにした
2020/8/13 /icons2をoptionに追加した

あいまい検索用class
script.js
// based on https://scrapbox.io/yutaro/emoji_selector // emojiを簡単に入力する class Asearch { get INITPAT(){return 0x80000000;} get MAXCHAR(){return 0x100;} get INITSTATE(){return [this.INITPAT, 0, 0, 0];} constructor(source) { this.source = source; this.shiftpat = Array(this.MAXCHAR).map(_ => 0); this.epsilon = 0; let mask = this.INITPAT; const ref = this.unpack(this.source); for (const item of ref) { // 0x20 is a space if (item === 0x20) { this.epsilon |= mask; } else { this.shiftpat[item] |= mask; this.shiftpat[this.toupper(item)] |= mask; this.shiftpat[this.tolower(item)] |= mask; mask >>>= 1; } } this.acceptpat = mask; return this; } isupper(c) { // 0x41 = A, 0x5a = Z return (c >= 0x41) && (c <= 0x5a); } islower(c) { // 0x61 = a, 0x7a = z return (c >= 0x61) && (c <= 0x7a); } tolower(c) { return this.isupper(c) ? c + 0x20 : c; } toupper(c) { return this.islower(c) ? c - 0x20 : c; } state(state = this.INITSTATE, str = '') { let i0 = state[0]; let i1 = state[1]; let i2 = state[2]; let i3 = state[3]; const ref = this.unpack(str); for (const item of ref) { const mask = this.shiftpat[item]; i3 = (i3 & this.epsilon) | ((i3 & mask) >>> 1) | (i2 >>> 1) | i2; i2 = (i2 & this.epsilon) | ((i2 & mask) >>> 1) | (i1 >>> 1) | i1; i1 = (i1 & this.epsilon) | ((i1 & mask) >>> 1) | (i0 >>> 1) | i0; i0 = (i0 & this.epsilon) | ((i0 & mask) >>> 1); i1 |= i0 >>> 1; i2 |= i1 >>> 1; i3 |= i2 >>> 1; } return [i0, i1, i2, i3]; }; match(str, ambig = 0) { const s = this.state(this.INITSTATE, str); if (!(ambig < this.INITSTATE.length)) { ambig = this.INITSTATE.length - 1; } return (s[ambig] & this.acceptpat) !== 0; } unpack(str) { let bytes = []; const codes = str.split('') .map(item => item.charCodeAt(0)); for (const code of codes) { if (code > 0xFF) { bytes.push((code & 0xFF00) >>> 8); } bytes.push(code & 0xFF); } return bytes; } }

諸々の設定
script.js
const projectName = scrapbox.Project.name; const box = $('<div>').addClass('form-group').css("position", "absolute"); const container = $('<div>').addClass('dropdown'); box.append(container); let items = $('<ul>').addClass('dropdown-menu'); container.append(items); $('#editor').append(box);

入力候補をload
script.js
let emojis=[]; fetch(`/api/pages/${projectName}?limit=10000`, {credentials: 'same-origin'}) .then(res => res.text()) .then(text => JSON.parse(text).pages .filter(page => (page.image !== null && page.title.match(/^[\w\s\-\+]+$/))) .forEach(page => emojis.push({ name: page.title, path: page.title, icon: `/api/pages/${projectName}/${page.title}/icon`, }) ) ); scrapbox.PageMenu.addMenu({ title: 'emoji', image: 'https://gyazo.com/d57fea8a143650375af1c8bba1fc1370/raw' }); function importExternalIcons(projectName) { fetch(`/api/pages/${projectName}?limit=10000`) .then(res => res.text()) .then(text => { JSON.parse(text).pages .filter(page => (page.image !== null && page.title.match(/^[\w\s\-\+]+$/))) .filter(page => !emojis.some(emoji => emoji.name === page.title)) .forEach(page => emojis.push({ name: page.title, path: `/${projectName}/${page.title}`, icon: `/api/pages/${projectName}/${page.title}/icon`, }) ); }); } function addImportMenu(projectName) { scrapbox.PageMenu('emoji').addItem({ title: `load emojis from /${projectName}`, onClick: () => importExternalIcons(projectName), }); }

importするprojectを変更したい場合は↓をいじる
script.js
addImportMenu('emoji'); addImportMenu('icons2'); importExternalIcons('icons');

本体
script.js
function fizzSearch(word,list) { // TODO: 様々な文字列が来る場合を考慮する

script.js
// wordの入力補完候補をlistから取り出す const taberareloo = (word, list) => { const regStr = word .replace(':', '') .split('') .reduce((pre, cur) => `${pre}${cur}.*`) .replace('+', '\\+'); const reg = RegExp(regStr, 'i'); return list.filter(item => item.name.match(reg)); } const asearched = (word, list) => { const targetWord = word.replace(':', ''); const a = new Asearch(targetWord); const limitCount = Math.floor(targetWord.length / 4) + 1; return [...Array(limitCount).keys()] .map(i =>list.filter(item => a.match(item.name, i))) .reduce((result,cur) => [...result,...cur.filter(item => !result.some(r => r.name === item.name))]) } const a = asearched(word, list); const b = taberareloo(word, list).filter(item => !a.some(r => r.name === item.name)); return [...a, ...b]; } let stack = ""; const editor = $('#editor'); const open = () => container.addClass("open"); const close = () => { stack = ""; container.removeClass("open"); } function replaceText(text, cursor, emojiPath) { const isFirefox = () => { const userAgent = window.navigator.userAgent.toLowerCase(); if (userAgent.indexOf('firefox') != -1) { return true; } return false; }; cursor.focus(); setTimeout(() => { // 文字を消す for (const _ of text) { // key: 'Backspace'は不可 // Backspace以外のkeyでは無効 cursor.dispatchEvent(new KeyboardEvent('keydown', {bubbles: true, cancelable: true, keyCode: 8})); } const result=`[${emojiPath}.icon]`; // firefox用回避策 // cf. https://github.com/daiiz/ScrapScripts/blob/c4cb6801fd530ebf0958fc2404698ff2a5d97076/src/scrapbox-io/paste-webpage-url.js#L61 if (isFirefox()) { const start = cursor.selectionStart; // in this case maybe 0 cursor.setRangeText(result); cursor.selectionStart = cursor.selectionEnd = start + result.length; const uiEvent = document.createEvent('UIEvent'); uiEvent.initEvent('input', true, false); cursor.dispatchEvent(uiEvent); } else { document.execCommand('insertText', false, result); } close(); }, 50); }

キーボード入力の処理
script.js
editor.keydown(e => { if (e.key === undefined) return; // ':'でemoji selectorを起動する if (stack === "" && e.key !== ":") { close(); return }; // code blockとtableの頭、code blockの中身では動作しないようにする if ($('.cursor-line').text().trim() == 'code:' || $('.cursor-line').text().trim() == 'table:' || $('.cursor-line .code-block').length == 1) { close(); return; } const cursor = $('#text-input')[0]; // 最後に:を押すと入力を確定する if (e.key === ':' && stack.length !== 0) { const name = stack.replace(':', ''); replaceText(stack + ":", cursor, emojis.find(emoji => emoji.name === name).path); close(); return; } if (e.key.match(/^[\w\s\-\:\+]$/)) { stack += e.key; if ($(':focus').is(items.find('li > a'))) { cursor.focus(); } } if (stack.length === 2) { if (e.key === " ") { stack = ""; return; } open(); } switch (e.key) { case 'Backspace': stack = stack.slice(0, stack.length - 1); if (stack.length === 0) { close(); return; } break; case 'ArrowUp': const focusedUp = $(':focus'); if (focusedUp.is(items.find('li > a').eq(0))) { e.stopPropagation(); cursor.focus(); } else if (!focusedUp.is(items.find('li > a'))) { close(); return; } break; case 'ArrowDown': case 'Tab': const focusedDown = $(':focus'); if (!focusedDown.is(items.find('li > a'))) { e.stopPropagation(); e.preventDefault(); items.find("li > a").eq(0).focus(); } break; case 'Escape': case 'ArrowLeft': case 'ArrowRight': case 'Home': case 'End': case 'PageUp': case 'PageDown': close(); break; case 'Enter': // ':'以外入力されていなければ終了 if (stack.length === 1) { close(); break; } const focused = $(':focus'); if (!focused.is(items.find('li > a'))) { e.stopPropagation(); e.preventDefault(); items.find('li > a').eq(0).click(); } break; } if (stack.length <= 1 || !e.key.match(/^[\w\s\:\-\+]$|Backspace/)) return; const matchedEmoji = fizzSearch(stack, emojis); if (matchedEmoji.length === 0) { close(); return; } // あいまい検索に引っかかったemojiをリストに入れる const newItems = $('<ul>').addClass('dropdown-menu'); matchedEmoji .slice(0,30) .forEach( emoji => { const li = $('<li>').addClass('dropdown-item'); const a = $('<a>').attr("tabindex", "0"); const img = $('<img>').attr("src", emoji.icon) .addClass("icon").css({height: "17px", float: "left"}); const nameTag = $('<div>').text(` :${emoji.name}:`); a.append(img); a.append(nameTag); li.append(a); newItems.append(li); a.on('click', () => { cursor.focus(); replaceText(stack, cursor, emoji.path); }); a.on('keypress', ev => { if (ev.key === "Enter") { ev.preventDefault(); ev.stopPropagation(); replaceText(stack, cursor, emoji.path); } }); }); items.replaceWith(newItems); items = newItems; let css = {}; cursor.style.cssText .split(';') .filter(text => text !== '') .forEach(text => { const props = text .split(':') .map(text => text.replace(/ |px/, '')); css[props[0]] = props[1]; }); box.css({ top: `${parseInt(css.top) + parseInt(css.height) + 3}px`, left: `${css.left}px`, }); });

UserScript