generated at
emoji selector

2018/9/25
コードブロックの頭とコードブロック中は動作しないようにupdate
foldrrさんが作ったものを反映させていただいた
不満が残るところの一つだったので非常に嬉しい
ありがとうございます!

説明
導入方法参考 /shokai/UserScript
:aaaa: を打つと絵文字(scrapbox上のアイコン)が入力できるようになるUserScript
入力した文字に曖昧マッチングした候補が下に出てくる
上下キーを押して選択しEnterを押すか、クリックすると絵文字の文字列 [something.icon] に置換できる
選択
クリック

:yutaro: => yutaro
完全に入力すると、自動で置き換える
:yuta + Enter => yutaro
入力途中でEnterを押すと一番上の候補が入力される


右のemoji selectorボタンから load emojis from /emoji をクリックすると/emoji/にあるアイコンも使えるようになる
(デフォルトではロードしたプロジェクト内の半角英数字, _ , 空白文字, "+-"で構成されているアイコンのみがページがデータセットとして読み込まれる)
:+1: => +1
:parrot: => parrot


余談
個人的に一番欲しい機能だったので作ってみた
同期的なコミュニケーションのキラーコンテンツは絵文字とリアクションだと思う
まとまった文章より伝えやすくて楽しい
jQueryあんまり書いたことないからいい感じにかけてるかどうか全くわからない...
全体的にかなり無理やり実装しているので、色々バグがあるかも...

参考にしたページ
検索にはここに書いてあるやつを雑に実装して使ってみた
ドロップダウンメニューは画面上部の検索窓とほぼ同じもの
cssとかをそのまま使っている
Asearch はここから取ってきた
文字を置き換える部分で使っている

サンプルと解説記事がいっぱいあると組み合わせるだけで色々作れて楽しい!!

script.js
const Asearch = (function() { var INITPAT, INITSTATE, MAXCHAR; INITPAT = 0x80000000; MAXCHAR = 0x100; INITSTATE = [INITPAT, 0, 0, 0]; Asearch.prototype.isupper = function(c) { return (c >= 0x41) && (c <= 0x5a); }; Asearch.prototype.islower = function(c) { return (c >= 0x61) && (c <= 0x7a); }; Asearch.prototype.tolower = function(c) { if (this.isupper(c)) { return c + 0x20; } else { return c; } }; Asearch.prototype.toupper = function(c) { if (this.islower(c)) { return c - 0x20; } else { return c; } }; function Asearch(source) { var c, i, j, len, mask, ref, ref1; this.source = source; this.shiftpat = []; this.epsilon = 0; this.acceptpat = 0; mask = INITPAT; for (c = i = 0, ref = MAXCHAR; 0 <= ref ? i < ref : i > ref; c = 0 <= ref ? ++i : --i) { this.shiftpat[c] = 0; } ref1 = this.unpack(this.source); for (j = 0, len = ref1.length; j < len; j++) { c = ref1[j]; if (c === 0x20) { this.epsilon |= mask; } else { this.shiftpat[c] |= mask; this.shiftpat[this.toupper(c)] |= mask; this.shiftpat[this.tolower(c)] |= mask; mask >>>= 1; } } this.acceptpat = mask; return this; } Asearch.prototype.state = function(state, str) { var c, i, i0, i1, i2, i3, len, mask, ref; if (state == null) { state = INITSTATE; } if (str == null) { str = ''; } i0 = state[0]; i1 = state[1]; i2 = state[2]; i3 = state[3]; ref = this.unpack(str); for (i = 0, len = ref.length; i < len; i++) { c = ref[i]; mask = this.shiftpat[c]; 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]; }; Asearch.prototype.match = function(str, ambig) { var s; if (ambig == null) { ambig = 0; } s = this.state(INITSTATE, str); if (!(ambig < INITSTATE.length)) { ambig = INITSTATE.length - 1; } return (s[ambig] & this.acceptpat) !== 0; }; Asearch.prototype.unpack = function(str) { var bytes, c, code, i, len, ref; bytes = []; ref = str.split(''); for (i = 0, len = ref.length; i < len; i++) { c = ref[i]; code = c.charCodeAt(0); if (code > 0xFF) { bytes.push((code & 0xFF00) >>> 8); } bytes.push(code & 0xFF); } return bytes; }; return Asearch; })(); const projectName = scrapbox.Project.name; let emojis = []; 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); fetch(`/api/pages/${projectName}?limit=10000`, { credentials: 'same-origin'}) .then( res => res.text()) .then( text => { const data = JSON.parse( text ); const pages = data.pages; 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' }) scrapbox.PageMenu('emoji').addItem({ title: "load emojis from /emoji", onClick: () => { fetch('/api/pages/emoji?limit=10000') .then( res => res.text()) .then( text => { const data = JSON.parse( text ); const pages = data.pages; pages.filter( page => (page.image !== null && page.title.match(/^[\w\s\-\+]+$/))) .forEach( page => { for( let emoji of emojis ) { if( emoji.name === page.title )return; } emojis.push({ name: page.title, path: '/emoji/' + page.title, icon: `/api/pages/emoji/${page.title}/icon`, }) }) }) } }) // TODO: 様々な文字列が来る場合を考慮する const taberareloo = ( word, list ) => { const targetWord = word.replace(':', ''); const regStr = targetWord.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; let result = []; for(let i = 0; i <= limitCount; i++){ let matched = list.filter( item => a.match( item.name, i)); let notExisted = matched.filter( item => { for( let r of result){ if(r.name === item.name){ return false; } } return true; }) result = [ ...result, ...notExisted]; } return result; } const fizzSearch = ( word, list ) => { const a = asearched( word, list ); const b = taberareloo( word, list ); const c = b.filter( item => { for( let r of a ){ if( r.name == item.name){ return false; } } return true; }) return [...a, ...c]; } let stack = ""; const editor = $('#editor'); const open = () => container.addClass("open"); const close = () => { stack = ""; container.removeClass("open"); } const replaceText = (text, cursor, emojiPath) => { cursor.focus(); setTimeout(()=>{ for(let i = 0; i < text.length; i++){ var ke1 = document.createEvent("Events"); ke1.initEvent("keydown", true, true); ke1.keyCode = ke1.which = 8; // Backspace cursor.dispatchEvent(ke1); } document.execCommand('insertText',null, `[${emojiPath}.icon]` ); close(); }, 50) } editor.keydown( e => { const key = e.key; if(key === undefined ) return; if( stack === "" && key !== ":"){ close(); return }; if ($('.cursor-line').text().trim() == 'code:' || $('.cursor-line .code-block').length == 1) { close() return; } if( key === ':' && stack.length !== 0){ let name = stack.replace(':', ''); for(let emoji of emojis){ if( emoji.name === name ){ let cursor = $('#text-input')[0]; replaceText(stack + ":", cursor, emoji.path); return; } } close() return; } const cursor = $('#text-input')[0]; if( key.match(/^[\w\s\-\:\+]$/) ){ stack += e.key; let focused = $(':focus'); if(focused.is(items.find('li > a'))){ cursor.focus(); } } if( stack.length === 2 ){ if( key === " " ){ stack = ""; return; } open(); } switch(key){ case 'Backspace': stack = stack.slice(0, stack.length - 1); if(stack.length === 0){ close(); return; } break; case 'ArrowUp': let 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': let 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; } let 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 || !key.match(/^[\w\s\:\-\+]$|Backspace/)) return; const matchedEmoji = fizzSearch(stack, emojis) if( matchedEmoji.length === 0){ close(); return; } const newItems = $('<ul>').addClass('dropdown-menu'); matchedEmoji.forEach( ( emoji, index) => { if( index > 30 ) return; 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(' ', '').replace('px', '')); css[props[0]] = props[1]; }); box.css({ top: `${parseInt(css.top) + parseInt(css.height) + 3}px`, left: `${css.left}px`, }); })