generated at
editorから検索語句を取得して補完windowに渡すテスト
editorへの文字入力を検索語句として認識させるテストですtakker
コードは補完windowのonClickテストをほぼそのまま使う
やること
補完を開始する
[/] の内部で何か文字入力をすると補完が開始されるようにする
実装
.cursor MutationObserverで監視する
.cursor の文字の位置から、リンクの中にいるのかどうかを計算する
位置はscrapbox-cursor-positionで取得する
補完を終了する
[/] の外にカーソルが動いた
候補を確定した
<Esc> を押した
検索語句を取得する
[/aaa] aaa だけ取り出す
実装
[/] の中に .cursor が入った段階で、 [/] MutationObserverを登録する
補完が終了したら切断する

うまくコードをかけないや……takker
MutationObserverが入れ子になっちゃった
コードをもう少し整理した方がよさそう
キーボード操作を汎用化する
<C-i> の形式で受け取れるようにする
検索する関数とアイテムを作る関数を分離する
あとPromiseでwrapしておく

直したい所
<Esc> などが渡ったら補完を中断する
<Tab> <CR> をeditorに渡せるようにする
補完の状態変数を作る必要がありそう
複数の補完ソースを一度に使えるようにしたい
Web Workerを共通化できる
たくさんWeb Workerを作る必要がなくなる
処理の共通化ができる
疎結合になるよう促すことができる
名前は(WIP)awesome-suggestionにするか

見つかったバグ

2021-02-11
04:19:50
Web WorkerをPromiseで包んだ
キーボード操作の分岐を単純にした
05:55:00
微調節段階
scrapbox-suggest-containerの位置がおかしいな
07:20:43 多分一通り動く
2021-02-11 14:39:19 キー入力の挙動を調節した
window操作以外のキーが渡ってきたら、scrapboxにキー入力をそのまま渡す

js
import(`/api/code/programming-notes/editorから検索語句を取得して補完windowに渡すテスト/main.js`);
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`); const [{scrapboxDOM}, {cursor}, {postToWorker}, {projects}, {js2vim}, {asyncSingleton}, {press}, {char: c}] = await Promise.all(promises); // 入力補完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) ? 'https://img.icons8.com/ios/180/FFFFFF/loading.png' : 'https://img.icons8.com/ios/180/loading.png'; 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(); console.log('[test:editorから検索語句を取得して補完windowに渡すテスト] ', cursor_.left?.text, cursor_.right?.text, 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}); })();

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
test1-project-list.js
export const projects = [ 'hub', 'villagepump', ];

web workerをPromiseで包んだやつ
worker-promise.js
export function postToWorker(worker, message) { worker.postMessage(message); return new Promise(resolve => worker.addEventListener('message', ({data}) => resolve(data), {once: true})); }

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'})); queue = [{parameters, resolve}]; 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
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(); }); temp.push(...json.flatMap(page => [...page.links, page.title]).map(link => `/${project}/${link}`)); } while(followingId); const links = [...new Set(temp)]; // 重複を取り除く _log(`Loaded ${links.length} links from /${project}`); return links; } // debug用 function _log(msg, ...objects) { console.log(`[search-worker @${pageTitle}/test1-worker.js] ${msg}`, ...objects); }