editorから検索語句を取得して補完windowに渡すテスト
editorへの文字入力を検索語句として認識させるテストですtakker.icon
やること
補完を開始する
[/]の内部で何か文字入力をすると補完が開始されるようにする
実装
.cursorの文字の位置から、リンクの中にいるのかどうかを計算する
補完を終了する
[/]の外にカーソルが動いた
候補を確定した
<Esc>を押した
検索語句を取得する
[/aaa]のaaaだけ取り出す
実装
補完が終了したら切断する
うまくコードをかけないや……takker.icon
MutationObserverが入れ子になっちゃった
コードをもう少し整理した方がよさそう
キーボード操作を汎用化する
<C-i>の形式で受け取れるようにする
検索する関数とアイテムを作る関数を分離する
あとPromiseでwrapしておく
直したい所
<Esc>などが渡ったら補完を中断する
<Tab>や<CR>をeditorに渡せるようにする
補完の状態変数を作る必要がありそう
複数の補完ソースを一度に使えるようにしたい
Web Workerを共通化できる
たくさんWeb Workerを作る必要がなくなる
処理の共通化ができる
疎結合になるよう促すことができる
見つかったバグ
2021-02-11
04:19:50
Web WorkerをPromiseで包んだ
キーボード操作の分岐を単純にした
05:55:00
微調節段階
07:20:43 多分一通り動く
2021-02-11 14:39:19 キー入力の挙動を調節した
window操作以外のキーが渡ってきたら、scrapboxにキー入力をそのまま渡す
code:js
import(/api/code/programming-notes/editorから検索語句を取得して補完windowに渡すテスト/main.js);
code:main.js
(async () => {
const projectName = 'programming-notes';
const pageTitle = 'editorから検索語句を取得して補完windowに渡すテスト';
const promises = [
import(/api/code/${projectName}/scrapbox-dom-accessor/script.js),
import(/api/code/${projectName}/scrapbox-cursor-position/script.js),
import(/api/code/${projectName}/${pageTitle}/worker-promise.js),
import(/api/code/${projectName}/${pageTitle}/test1-project-list.js),
import(/api/code/${projectName}/JSのkeyをVim_key_codeに変換するscript/script.js),
import(/api/code/${projectName}/${pageTitle}/asyncSingleton.js),
import(/api/code/${projectName}/scrapbox-keyboard-emulation/script.js),
import(/api/code/${projectName}/scrapbox-char-accessor/script.js),
//import(/api/code/${projectName}/scrapbox-suggest-container/test-dark-theme.js),
import(/api/code/${projectName}/scrapbox-suggest-container/script.js),
];
const worker = new Worker(/api/code/${projectName}/${pageTitle}/test1-worker.js);
// 入力補完windowを作る
const suggestBox = document.createElement('suggest-container');
scrapboxDOM.editor.append(suggestBox);
await postToWorker(worker, {type: 'fetch', projects});
// tabキーで選択する
scrapboxDOM.editor.addEventListener('keydown', e => {
if (suggestBox.hidden) return;
// programで生成したkeyboard eventは無視する
if (!e.isTrusted) return;
switch(js2vim(e)) {
case '<C-i>':
e.preventDefault();
e.stopPropagation();
(suggestBox.selectedItem ?? suggestBox.firstItem)?.click?.({}, true);
return;
case '<Tab>':
e.preventDefault();
e.stopPropagation();
suggestBox.selectNext({wrap: true});
return;
case '<S-Tab>':
e.preventDefault();
e.stopPropagation();
suggestBox.selectPrevious({wrap: true});
return;
case '<CR>':
e.preventDefault();
e.stopPropagation();
suggestBox.firstItem.click();
case undefined:
return;
// その他の入力はscrapboxにそのまま渡す
default:
scrapboxDOM.textInput.focus();
return;
}
});
// あいまい検索して、候補を入力補完windowに追加する
const search = async (word, index, {limit = 30, timeout = 10000,} = {}) => {
// 時間がかかるようであればLoading表示をする
const timer = setTimeout(() => {
const image = /paper-dark-dark|default-dark/
.test(document.head.parentElement.dataset.projectTheme) ?
suggestBox.pushFirst({text: 'Searching...', image,});
}, 1000);
const {links} = await postToWorker(worker, {type: 'search', word, limit, timeout});
clearTimeout(timer);
suggestBox.clear();
scrapboxDOM.textInput.focus();
suggestBox.push(...links.flat().map(link => {
return {
text: link,
link: https://scrapbox.io${link},
onClick: (e, icon) => {
if (e.ctrlKey) {
window.open(https://scrapbox.io${link});
return;
}
const text = icon ? [${link}.icon] : [${link}];
scrapboxDOM.textInput.focus();
press('Home');
press('Home');
for (let i = 0; i < index; i++) {
press('ArrowRight');
}
for (let i = 0; i < [${word}].length; i++) {
press('ArrowRight', {shiftKey: true});
}
insertText(text);
},
};
}));
};
const postSearch = asyncSingleton(search);
let prevSearch = '';
let state = 'input';
const observer = new MutationObserver(() =>{
const cursor_ = cursor();
const link = cursor_.left?.link ?? cursor_.right?.link;
console.log({clientLeft: link?.DOM?.clientLeft, clientTop: link?.DOM?.clientTop});
if (!link?.text?.startsWith?.('/')) {
suggestBox.mode = '';
suggestBox.hide();
return;
}
const firstIndex = link.headChar.index;
suggestBox.mode = 'auto';
const editorRect = scrapboxDOM.editor.getBoundingClientRect();
const {left, bottom} = link.DOM.getBoundingClientRect();
suggestBox.position({
top: bottom - editorRect.top,
left: left - editorRect.left,
});
console.log({prevSearch,text: link?.text});
if (prevSearch === link?.text) return;
prevSearch = link?.text;
postSearch(prevSearch, firstIndex);
});
observer.observe(scrapboxDOM.cursor, {attributes: true});
})();
code:main.js
function insertText(text) {
const cursor = document.getElementById('text-input');
cursor.focus();
cursor.value = text;
const uiEvent = document.createEvent('UIEvent');
uiEvent.initEvent('input', true, false);
cursor.dispatchEvent(uiEvent);
}
入力補完に使うscrapbox projects
code:test1-project-list.js
export const projects = [
'hub',
'villagepump',
];
web workerをPromiseで包んだやつ
code:worker-promise.js
export function postToWorker(worker, message) {
worker.postMessage(message);
return new Promise(resolve => worker.addEventListener('message',
({data}) => resolve(data), {once: true}));
}
code:asyncSingleton.js
export function asyncSingleton(callback) {
if (typeof callback !== 'function') throw new Error('argument is not function.')
let queue = [];
let isRunning = false;
return (...parameters) => new Promise(async resolve => {
if (isRunning) {
queue.forEach(pair => pair.resolve({state: 'canceled'}));
return;
}
isRunning = true;
resolve({result: await callback(...parameters), state: 'fullfilled'});
if (queue.length > 0) queue.forEach(async pair =>
pair.resolve({result: await callback(...pair.parameters), state: 'fullfilled'}));
queue = [];
isRunning = false;
});
}
worker code
code:test1-worker.js
const pageTitle = 'editorから検索語句を取得して補完windowに渡すテスト';
self.importScripts('/api/code/programming-notes/WebWorker用asearch/script.js');
// 検索候補
const list = [];
self.addEventListener('message', ({data}) => {
switch (data.type) {
case 'search':
search(data);
break;
case 'fetch':
fetch_(data.projects);
break;
}
});
async function search({word, limit, timeout}) {
//_log(start searching for ${word}...: limit = ${limit});
const result = fuzzySearch({
query: word.split('/').join(' '),
source: list,
limit, timeout,
});
//_log('finished: %o', result);
self.postMessage({links: result,});
}
async function fetch_(projects) {
_log('Loading links from %o', projects);
const result = (await Promise.all(projects
.map(project => fetchExternalLinks(project))
)).flat();
_log(Finish loading ${result.length} links from %o, projects);
list.push(...result);
// 終了したことを知らせる
self.postMessage({});
}
async function fetchExternalLinks(project) {
let followingId = null;
let temp = [];
_log(Start loading links from ${project}...);
do {
_log(Loading links from ${project}: followingId = ${followingId});
const json = await (!followingId ?
fetch(/api/pages/${project}/search/titles) :
fetch(/api/pages/${project}/search/titles?followingId=${followingId})
).then(res => {
followingId = res.headers.get('X-Following-Id');
return res.json();
});
} while(followingId);
_log(Loaded ${links.length} links from /${project});
return links;
}
// debug用
function _log(msg, ...objects) {
console.log([search-worker @${pageTitle}/test1-worker.js] ${msg}, ...objects);
}