emoji-selector
マルチスレッド&日本語入力対応版
/icons/hr.icon
本家との違い
tabキーを使って入力候補を選択できるように変更した
その他、ES6記法に一部書き換えた
課題
日本語入力にも対応したい
更新とか
2020/8/18 テーブル記法の頭で動作しないようにした あいまい検索用class
code:script.js
// emojiを簡単に入力する
class Asearch {
get INITPAT(){return 0x80000000;}
get MAXCHAR(){return 0x100;}
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.shiftpatitem |= 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 = '') {
const ref = this.unpack(str);
for (const item of ref) {
const mask = this.shiftpatitem; 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;
}
};
match(str, ambig = 0) {
const s = this.state(this.INITSTATE, str);
if (!(ambig < this.INITSTATE.length)) {
ambig = this.INITSTATE.length - 1;
}
return (sambig & 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;
}
}
諸々の設定
code: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
code: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',
});
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を変更したい場合は↓をいじる
code:script.js
addImportMenu('emoji');
addImportMenu('icons2');
importExternalIcons('icons');
本体
code:script.js
function fizzSearch(word,list) {
// TODO: 様々な文字列が来る場合を考慮する
code: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;
.map(i =>list.filter(item => a.match(item.name, i)))
.reduce((result,cur) =>
}
const a = asearched(word, list);
const b = taberareloo(word, list).filter(item => !a.some(r => r.name === item.name));
}
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用回避策
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);
}
キーボード入力の処理
code: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;
}
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/, ''));
});
box.css({
top: ${parseInt(css.top) + parseInt(css.height) + 3}px,
left: ${css.left}px,
});
});
UserScript.icon