generated at
editorから検索語句を取得して補完windowに渡すテストその2

実装したいこと
Classに整理
これはrelease版でやる
done入力補完の切り替え
まずは、hard codingしている部分を設定変数に切り出す必要がある
必要なものは
key bindings
補完開始の条件
検索語句
検索エンジン
検索語句を入れると、検索結果がPromiseの配列で帰ってくる
検索結果の表示方法
検索結果をscrapbox-suggest-container-2に入れるアイテムに変換する
検索結果が送られて来次第どんどん表示する
全てのworkerの計算が終了するのを待たない
やめた
処理を共通化するのが難しい

2021-02-20 00:33:11 大体できたのでreleaseする
2021-02-20 01:43:33 クリックが効かなくなってしまった
少し前までは使えてた
なんでだ?
01:46:22 cursorがリンクから外れた瞬間に補完が終了する処理を入れたことが原因だ
onClick() の中で onCompletionEnd() を呼べば解決する?
多分無理
click eventが発火する前にDOMの変化を検知してしまっている
行けるっぽい?
条件がわからんtakker
小さめのprototypeを作って挙動を確認するしかなさそう
02:08:00 何とか直した

2021-02-20 12:29:11 cacheの寿命をいい加減に決めてたの忘れてたtakker
あとcacheのreloadボタンもつけてなかった

js
import(`/api/code/programming-notes/editorから検索語句を取得して補完windowに渡すテストその2/main.js`);

dependencies
main.js
import {scrapboxDOM} from '/api/code/programming-notes/scrapbox-dom-accessor/script.js'; import {cursor} from '/api/code/programming-notes/scrapbox-cursor-position/script.js'; import {char as c} from '/api/code/programming-notes/scrapbox-char-accessor/script.js'; import {press} from '/api/code/programming-notes/scrapbox-keyboard-emulation/script.js'; import {create as createEngine} from '/api/code/programming-notes/複数の補完ソースを切り替えて検索できるUserScript/script.js'; import {projects} from '/api/code/programming-notes/editorから検索語句を取得して補完windowに渡すテストその2/project-list.js'; import {js2vim} from '/api/code/programming-notes/JSのkeyをVim_key_codeに変換するscript/script.js'; import '/api/code/programming-notes/scrapbox-suggest-container-2/script.js'; import {create} from '/api/code/programming-notes/scrapbox-suggest-container-2/item.js'; import { createExternalData, createEmojiData, } from '/api/code/programming-notes/editorから検索語句を取得して補完windowに渡すテストその2/loader.js'; import { keyBindings, personalKeyBindings, emojiKeyBindings, } from '/api/code/programming-notes/editorから検索語句を取得して補完windowに渡すテストその2/settings.js';

入力補完windowを作る
main.js
const suggestBox = document.createElement('suggest-container'); scrapboxDOM.editor.append(suggestBox);

設定
main.js
let mode = undefined; let completionend = false; // 入力確定後に再び入力補完が走るのを防ぐ (async () => { const engines = await Promise.all([ createEngine({ converter: ({project, title}) => `${project}/${title}`, source: await createExternalData(projects), limit: 30, ambig: 4, }), createEngine({ converter: ({project, title}) => `${project} ${title}`, source: await createExternalData(['takker', 'takker-memex', 'takker-private']), limit: 30, ambig: 4, }), createEngine({ converter: ({project, title}) => `${project} ${title}`, source: await createEmojiData(['icons', 'icons2', 'emoji']), limit: 10, }), ]); const config = [{ search: word => searchEngine(engines[0], word.slice(1)), // trigger文字を除外する trigger: /^\//, limit: 30, keyMappings: keyBindings, convert: ({project, title}, replacer, oncompeletionend) => { const link = `/${project}/${title}`; return create({ text: link, link: `https://scrapbox.io${link}`, onClick: ({ctrlKey, icon}) => { if (ctrlKey) { window.open(`https://scrapbox.io${link}`); return; } replacer(icon ? `[${link}.icon]` : `[${link}]`); oncompeletionend(); }, }); }, }, { search: word => searchEngine(engines[1], word.slice(1)), // trigger文字を除外する trigger: /^[^:\/]/, limit: 30, keyMappings: personalKeyBindings, convert: ({project, title}, replacer, oncompeletionend) => { const link = `/${project}/${title}`; return create({ text: link, link: `https://scrapbox.io${link}`, onClick: ({ctrlKey, icon}) => { if (ctrlKey) { window.open(`https://scrapbox.io${link}`); return; } replacer(icon ? `[${link}.icon]` : `[${link}]`); oncompeletionend(); }, }); }, }, { search: word => searchEngine(engines[2], word.slice(1)), // trigger文字を除外する trigger: /^:/, limit: 10, keyMappings: emojiKeyBindings, convert: ({project, title}, replacer, oncompeletionend) => { const link = `/${project}/${title}`; return create({ text: link, image: `https://scrapbox.io/api/pages${link}/icon`, link: `https://scrapbox.io${link}`, onClick: ({ctrlKey}) => { if (ctrlKey) { window.open(`https://scrapbox.io${link}`); return; } replacer(`[${link}.icon]`); oncompeletionend(); }, }); }, }];

キーバインドの設定
main.js
scrapboxDOM.editor.addEventListener('keydown', e => { if (!e.isTrusted // programで生成したkeyboard eventは無視する || mode === undefined) return; if (completionend) completionend = false; // windowの有無に関わらず判断する if (suggestBox.hidden) return; const vimKeyCode = js2vim(e); // linkの中にcursorが存在しなければ終了する if (!getLink()) { onCompletionEnd(); return; } // keyは配列or文字列 const {command} = config[mode].keyMappings.find(({key}) => typeof key === 'string' ? key === vimKeyCode : key.includes?.(vimKeyCode)) ?? {command: undefined}; if (!command) return; e.preventDefault(); e.stopPropagation(); command(suggestBox, onCompletionEnd); });

補完終了時の処理
main.js
function onCompletionEnd() { suggestBox.mode = ''; suggestBox.hide(); completionend = true; }
main.js_disabled
const observer2 = new MutationObserver(() =>{ }); observer2.observe(scrapboxDOM.cursor, {attributes: true});

補完開始の制御
単純にDOMの更新だけで調べてしまうと、記法がむき出しになっただけで検知してしまう
まあ妥協するか。takker
そこまで邪魔にならないかもしれない
2021-02-20 01:54:39 結構面倒だ
候補を確定したときの入力と、ユーザのキー入力との区別をつけることができない
いくつかのフラグを組み合わせるか
入力補完後のに立てたフラグは、キーボード操作をしないと倒れないとか

main.js
let state = 'input'; const observer = new MutationObserver(async () =>{ if (completionend) return; // cursorのいるリンクを取得する const link = getLink(); if (!link) { //mode = undefined; //suggestBox.hide(); return; } //_log({clientLeft: link?.DOM?.clientLeft, clientTop: link?.DOM?.clientTop}); // 補完開始のトリガーの識別 mode = undefined; for (let i = 0; i < config.length; i++) { const trigger = config[i].trigger; if (trigger.test(link.text)) { mode = i; break; } } _log({text: link.text, mode}); if (mode === undefined) { suggestBox.mode = ''; suggestBox.hide(); return; } suggestBox.mode = 'auto'; // リンクの先頭文字に補完windowの位置を合わせる const editorRect = scrapboxDOM.editor.getBoundingClientRect(); const {left, bottom} = link.DOM.getBoundingClientRect(); suggestBox.position({ top: bottom - editorRect.top, left: left - editorRect.left, }); // 検索を実行する const searchResultPending = config[mode].search(link.text); // 時間がかかるようであれば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(create({text: 'Searching...', image,})); }, 1000); const list = await searchResultPending; const replacer = (text) => replaceText(text, link.index, `[${link.text}]`.length); clearTimeout(timer); suggestBox.clear(); suggestBox.push(...list.slice(0, config[mode].limit - length) .map(data => config[mode].convert(data, replacer, onCompletionEnd)) ); }); observer.observe(scrapboxDOM.lines, {childList: true, subtree: true}); _log('Ready to completion.'); })();

cursorがいるリンクを返す
cursorの両隣の文字が同じリンクの中にあるときのみ、cursorがそのリンクの中にいると判断する
main.js
function getLink() { // のみ補完を開始する const cursor_ = cursor(); //_log(cursor_.left?.text, cursor_.right?.text, cursor_); const [lLink, rLink] = [cursor_.left?.link, cursor_.right?.link]; if (!lLink || lLink?.DOM !== rLink?.DOM) return undefined; return lLink; // rLinkを返してもいい }

検索用関数
main.js
async function searchEngine(engine, query) { _log(`Search for "${query}"`); const promises = engine.search(query).map(async (promise, index) => { const {result, state} = await promise; if (state === 'canceled') { _log(`Worker ${index} was canceled.`); return; } _log(`Worker ${index}: `, result .map(searchedList => searchedList.map(({project, title}) => `/${project}/${title}`))); return result; }); const data = (await Promise.all(promises)).filter(linksData => linksData); _log(`Search result:`, data); // 転置してあいまい度順に並び替える const links = []; for (let i = 0; i < 4; i++) { for (const linksData of data) { links.push(...(linksData[i] ?? [])); } } return links; };

テキスト置換用関数
main.js
function replaceText(text, index, length) { scrapboxDOM.textInput.focus(); press('Home'); press('Home'); for (let i = 0; i < index; i++) { press('ArrowRight'); } for (let i = 0; i < length; i++) { press('ArrowRight', {shiftKey: true}); } scrapboxDOM.textInput.value = text; const uiEvent = document.createEvent('UIEvent'); uiEvent.initEvent('input', true, false); scrapboxDOM.textInput.dispatchEvent(uiEvent); }

log用
main.js
function _log(msg, ...objects) { const title = 'editorから検索語句を取得して補完windowに渡すテストその2'; if (typeof msg !== 'object') { console.log(`[main.js@${title}] ${msg}`, ...objects); } else { console.log(`[main.js@${title}] `, msg, ...objects); } }

settings.js
export const keyBindings = [ { key: '<C-i>', command: suggestBox => (suggestBox.selectedItem ?? suggestBox.firstSelectableItem)?.click?.({icon: true}), }, { key: '<Tab>', command: suggestBox => suggestBox.selectNext({wrap: true}), }, { key: '<S-Tab>', command: suggestBox => suggestBox.selectPrevious({wrap: true}), }, { key: '<CR>', command: suggestBox => (suggestBox.selectedItem ?? suggestBox.firstSelectableItem)?.click?.(), }, { key: '<Esc>', command: (_, onCompletionEnd) => onCompletionEnd(), }, ]; export const personalKeyBindings = [ { key: '<Esc>', command: (_, onCompletionEnd) => onCompletionEnd(), }, ]; export const emojiKeyBindings = [ { key: ['<C-i>', '<CR>'], command: suggestBox => (suggestBox.selectedItem ?? suggestBox.firstSelectableItem)?.click?.(), }, { key: '<Tab>', command: suggestBox => suggestBox.selectNext({wrap: true}), }, { key: '<S-Tab>', command: suggestBox => suggestBox.selectPrevious({wrap: true}), }, { key: '<Esc>', command: (_, onCompletionEnd) => onCompletionEnd(), }, ];

補完ソースを読み込む
loader.js
import { getAllLinks, getAllIcons, } from '/api/code/programming-notes/Scrapbox_APIの取得結果をcacheするscript/script.js'; export async function createExternalData(projects) { const links = (await Promise.all(projects .filter(project => project !== scrapbox.Project.name) .map(async project => { const {results} = await getAllLinks(project, {maxAge: 300,}); const titles = [...new Set(results.flatMap(({links, title}) => [title, ...links]))]; return titles.map(title => {return {project, title};}); })) ).flat(); return shuffle(links); } export async function createEmojiData(projects) { return (await Promise.all(projects .map(async project => { const {results} = await getAllIcons(project, {maxAge: 300,}); return results.map(title => {return {project, title};}); }) )) .flat() // 辞書順に並べ替える .sort((a, b) => a.title.length === b.title.length ? a.title.localeCompare(b.name) : a.title.length - b.title.length); } function shuffle(array) { let result = array; for (let i = result.length; 1 < i; i--) { const k = Math.floor(Math.random() * i); [result[k], result[i - 1]] = [result[i - 1], result[k]]; } return result; }

project-list.js
export const projects = [ 'hub', 'shokai', 'nishio', 'masui', 'motoso', 'villagepump', 'rashitamemo', ];