generated at
external-completion-2
最新(の多少は)安定(してる)版:/programming-notes/external-completion@0.4.0

hr
warning開発版につき、破壊的変更を行う可能性あり
ドッグフーディングして、不具合を大体解消できたら、/customizeにも掲載する。

optionsが増えた
検索がGUIをblockingしなくなった
検索が高速になった
[] でも入力補完できるようにした

2021-05-08 15:34:26 久々にコード見直したけど、雑っすね……takker

hr
外部プロジェクトのリンクを入力補完出来るようにするUserScriptです

操作説明
[/] の中にカーソルを置くと補完が開始される。



[] の内側の文字であいまい検索した候補が表示される
Tab キーで入力候補の選択開始
or Tab キーで前候補を選択
or Shift+Tab キーで次候補を選択
Enter or クリックで候補を確定

入力候補が一つになると自動で置き換える


入力途中で Enter を押すと、最初の入力候補が入力される

導入方法
impot.jsをコピーし、自分のページのscript.jsに貼り付ける
import.js
import {ExternalCompletion} from '/api/code/takker/external-completion-2/script.js'; const externalCompletion = new ExternalCompletion({ projects: ['shokai', 'hub', 'customize', 'scrapboxlab']
Optionの説明
projects : ここに列挙したprojectsのページを入力候補とする。
必須変数
projects_load : 任意のタイミングで読み込むprojects
PageMenuを押したときに読み込まれる
default: []
includeYourProject : このUserScriptを使用するprojectのpageも入力候補に加えるかどうか
default: false
falseの場合、 projects に入っている該当projectも除外する
maxSuggestionNum : 一度に表示する入力候補の最大数
default: 30
maxHeight : 入力補完windowの最大の高さ
この値に入力候補が入り切らなかった場合は、スクロール表示に切り替わる
default: calc(50vh - 100px)
import.js
// includeYourProject: false, // maxSuggestionNum: 30, // maxHeight: `calc(50vh - 100px)` }); externalCompletion.start();
[] でも入力補完したいときの設定例
id は既存の要素と衝突しないやつならなんでも良いです
disableKeybindings true にすることを推奨します
でないとScrapboxの入力補完が死にます
js
import {ExternalCompletion} from '/api/code/takker/external-completion-2/script.js'; const externalCompletion2 = new ExternalCompletion({ id: 'external-completion-for-personal', projects: ['takker', 'villagepump'], trigger: /^[^\/:]/, disableKeybindings: true, maxSuggestionNum: 10, }); externalCompletion2.start();


更新情報
2021-05-08
15:28:18 コピペしやすいように相対パスに変えた
2021-01-07 19:42:38 ロゴがリンク切れになったので別の画像に差し替えた
2020-12-03
00:58:45 入力補完を無効にするボタンを追加した
00:33:37 遅延読み込みするprojectsがないときは読み込みボタンを表示しないようにした
00:19:48 fetch処理を compute.js に委譲した
一部のindentをspace2つに変えた
2020-12-02
22:57:19 いらない説明を削除した
22:53:05 遅延読み込みのボタン2度押しを検知するようにした
22:51:54 external-completionから移行

既知の問題
項目が一つだけになったときの自動入力がなんかおかしい
別な項目が挿入される事がある?
2020-09-17 09:34:51 再現不能
2020-12-03 01:08:57 入力補完windowをリンクの先頭に常に表示するようにしたい
現状はマウスに追随する
入力するたびに動くので、正直使いづらい気もする

実装したいこと
先頭の文字によって入力補完sourceを切り替える
現状は、複数の ExternalCompletion を作ることで対応している
これを、一つの ExternalCompletion で切り替えられるようにしたい
利点
[/j ] とうつとJavascript系projectから補完できるようにするなど、補完sourceの柔軟な切り替えが可能になる
……書いていて何だが、そこまでほしいと思わない気もしてきた……

以下、本体のscript
hr

以下をimportする
script.js
import {ICompletion} from '../ICompletion/script.js';

メイン関数
script.js
export class ExternalCompletion extends ICompletion { constructor({ id = 'external-completion', projects, projects_lazy = [], includeYourProject = false, maxSuggestionNum = 30, maxHeight = 'calc(50vh - 100px)', trigger = /^\//, disableKeybindings = false, } = {}) { super({ id: id, projects: projects, projects_lazy: projects_lazy, includeYourProject: includeYourProject, maxSuggestionNum: maxSuggestionNum, maxHeight: maxHeight, trigger: trigger, disableKeybindings: disableKeybindings, makeRaw: string => string.substr(1, string.length - 2), searchWorkerCode: '../external-completion-2/searchWorker.js', }); this._loadedLazyProjects = false; // 遅延読み込みを実行したかどうか this._id = id; }

外部プロジェクトリンクを非同期に読み込む
WebWorkerからはmain threadのobjectを使用できないので、 scrapbox.Project などにアクセスできない
script.js
async _importDataList() { // 処理を分けると複雑になるので、自分のprojectであってもAPIからリンクを取得する this.searchWorker.postMessage({loading: true, projects: this.projects}); // 設定ボタンを追加する scrapbox.PageMenu.addMenu({ title: this._id, image: 'https://i.gyazo.com/7057219f5b20ca8afd122945b72453d3.png', onClick: () =>{}, }); // 補完の有効無効を切り替えるボタンを入れる scrapbox.PageMenu(this._id).addItem({ title: `toggle the disable/enable state of ${this._id}`, onClick: () => this._disableCompletion = !this._disableCompletion, }); // 遅延読み込み用PageMenuを追加する // なければ何もしない if (this.projects_lazy.length === 0) return; scrapbox.PageMenu(this._id).addItem({ title: `load external links from ${this.projects_lazy.length} projects`, onClick: () => { if (this._loadedLazyProjects) return; this.searchWorker.postMessage({loading: true, projects: this.projects_lazy}); this._loadedLazyProjects = true; } }); }
入力候補を更新する関数
WebWorkerの処理が終わるたびに実行される
script.js
//補完windowに表示する項目を作成する _createGuiList(matchedList) { const cursor = document.getElementById('text-input'); return matchedList .map(link => { const div = document.createElement('div'); div.textContent = link; return new Object({ elements: [div], onComfirm: () => { this._log(`clicked [${link}]`); this._comfirm(cursor, `[${link}]`);}, }); }); }

WebWorkerに検索を依頼する
script.js
_postMessage(word) { this.searchWorker.postMessage({word: word, list: this.links, maxSuggestionNum: this.maxSuggestionNum}); } }

WebWorkerが処理するscript

役割
searchWorker
与えられた仕事を分割して並列処理用workerに振り分ける
返ってきた結果を統合してmain threadに返す
computer
並列処理用worker
処理が分散しているの良くないな……
仕事内容も searchWorker で作って投げるようにするか?
global変数をいじるところがあるから、そうも行かないか。

searchWorker.js
const range = n => [...Array(n).keys()]; // [0,1,...,n-1]を作る函数 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; } // workerに処理を投げて、値が返ってくるのを待つPromiseを作る async function delegateWork({worker, message}) { const job = new Promise((resolve, reject) => worker.addEventListener('message', message => { resolve(message.data); })); worker.postMessage(message); return await job; } const workerNum = navigator.hardwareConcurrency; const workers = range(workerNum).map(_ => new Worker( '../external-completion-2/compute.js'));
backgroundでfizzSearchを行う
並列で検索を行う
searchWorker.js
// 曖昧検索をする function searchWord(message) { const word = message.data.word; _log(`start searching for ${word}...`); const jobs = workers .map(worker => delegateWork({ worker, message: {type: 'search', word: word, limit: message.data.maxSuggestionNum} })); // 処理が帰って来たら結果をPOSTする Promise.all(jobs).then(results => { // まず、listの順番を復元する const temp = results.sort((a, b) => a.index - b.index) .map(result => result.linksData); // 曖昧度順に並べ直す const max = temp.map(linksData => linksData.length).reduce((r,c) => Math.max(r,c)); const matchedLinksData = range(max) .flatMap(i => temp.flatMap(linksData => linksData[i] ?? [])); _log(`Found ${matchedLinksData.length} links: %o`, matchedLinksData); self.postMessage(matchedLinksData.slice(0, message.data.maxSuggestionNum)); }); }

外部projectからリンク候補を読み込む
searchWorker.js
async function addLinks(projects) { _log(`start loading projects...`); // 処理を投げる const projectNum = Math.floor(projects.length/workerNum) + 1; let projectChunks =workers.map(_ => []); let jobs = []; let count = 0; for (const i of range(workerNum)) { if (projects.length <= i * projectNum) break; // 全てのprojectのfetchを指示し終わったらおしまい const dividedList = projects.slice(i * projectNum,(i + 1) * projectNum); const worker = workers[i]; jobs.push(delegateWork({ worker, // indexはこの段階ではいらないが、debugを見やすくするために渡しておく message: {type: 'fetch', projects: dividedList, index: i} })); } // 読み込み結果をまとめる const results = await Promise.all(jobs); //_log('',results); const externalLinks = shuffle(results.flatMap(result => result.linksData));

全ページを読み込み終わったら、入力候補をshuffleして/motoso/Serendipityを上げる
searchWorker.js
_log('Finish loading all links: %o', externalLinks); // workerの数だけ分割して渡す const skip = Math.floor(externalLinks.length/workerNum) + 1; for (const i of range(workerNum)) { const dividedList = externalLinks.slice(i * skip,(i + 1) * skip); workers[i].postMessage({ type: 'append', list: dividedList, index: i, // listの順番を保存する }); } // 処理が終わったことを伝える self.postMessage(undefined); }

WebWorkerの本体処理
parameterに応じて、読み込みを行うか検索を行うかを切り替える
searchWorker.js
self.addEventListener('message', async message => { if (message.data.loading) { addLinks(message.data.projects); return; } searchWord(message); }); // debug用 function _log(msg, ...objects) { console.log(`[search-worker@external-completion] ${msg}`, ...objects); }

CPUごとの計算script
compute.js
self.importScripts('../fizzSearch/script.js'); // 検索候補 const list = []; let index = 0; self.addEventListener('message', message => { switch (message.data.type) { case 'search': _log(index, `start searching for ${message.data.word}...`); const matchedLinksData = fizzSearch({word: message.data.word.split('/').join(' '), list: list, limit: message.data.limit}); _log(index, 'finished: %o', matchedLinksData); // index: externalLinksの並び順 self.postMessage({linksData: matchedLinksData, index: index}); break; case 'fetch': index = message.data.index; _log(index, 'Loading links from %o', message.data.projects); Promise.all(message.data.projects .flatMap(project => fetchExternalLinks(project))) .then(lists => self.postMessage({linksData: lists.flat()})); break; case 'append': index = message.data.index; _log(index, `Adding new ${message.data.list.length} suggestion items...`); list.push(...message.data.list); _log(index, 'Added.'); break; } }); async function fetchExternalLinks(project) { let followingId = null; let temp = []; _log(index, `Start loading links from ${project}...`); do { _log(index, `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(index, `Loaded ${links.length} links from /${project}`); return links; } // debug用 function _log(number, msg, ...objects) { console.log(`[search-worker ${number}@external-completion] ${msg}`, ...objects); }

#2021-07-18 19:43:28
#2021-05-08 15:28:10
#2020-12-03 00:19:58
#2020-11-19 08:32:00
#2020-11-12 03:41:32
#2020-10-16 04:43:54