emoji-selector
マルチスレッド&日本語入力対応版
本家との違い
tab
キーを使って入力候補を選択できるように変更した
その他、ES6記法に一部書き換えた
課題
日本語入力にも対応したい
更新とか
2020/8/18
テーブル記法の頭で動作しないようにした
あいまい検索用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.jsconst 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.jslet 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.jsaddImportMenu('emoji');
addImportMenu('icons2');
importExternalIcons('icons');
本体
script.jsfunction 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.jseditor.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`,
});
});