external-completion-2
開発版につき、破壊的変更を行う可能性あり
optionsが増えた
検索がGUIをblockingしなくなった
検索が高速になった
[]
でも入力補完できるようにした
2021-05-08 15:34:26 久々にコード見直したけど、雑っすね……
操作説明
[/]
の中にカーソルを置くと補完が開始される。
[]
の内側の文字であいまい検索した候補が表示される
Tab
キーで入力候補の選択開始
↓
or Tab
キーで前候補を選択
↑
or Shift+Tab
キーで次候補を選択
Enter
or クリックで候補を確定
入力候補が一つになると自動で置き換える
入力途中で Enter
を押すと、最初の入力候補が入力される
導入方法
import.jsimport {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
この値に入力候補が入り切らなかった場合は、スクロール表示に切り替わる
default: calc(50vh - 100px)
import.js // includeYourProject: false,
// maxSuggestionNum: 30,
// maxHeight: `calc(50vh - 100px)`
});
externalCompletion.start();
[]
でも入力補完したいときの設定例
id
は既存の要素と衝突しないやつならなんでも良いです
disableKeybindings
は true
にすることを推奨します
でないとScrapboxの入力補完が死にます
jsimport {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度押しを検知するようにした
既知の問題
項目が一つだけになったときの自動入力がなんかおかしい
別な項目が挿入される事がある?
2020-09-17 09:34:51 再現不能
2020-12-03 01:08:57 入力補完windowをリンクの先頭に常に表示するようにしたい
現状はマウスに追随する
入力するたびに動くので、正直使いづらい気もする
実装したいこと
先頭の文字によって入力補完sourceを切り替える
現状は、複数の ExternalCompletion
を作ることで対応している
これを、一つの ExternalCompletion
で切り替えられるようにしたい
利点
[/j ]
とうつとJavascript系projectから補完できるようにするなど、補完sourceの柔軟な切り替えが可能になる
……書いていて何だが、そこまでほしいと思わない気もしてきた……
以下、本体のscript
以下をimportする
script.jsimport {ICompletion} from '../ICompletion/script.js';
メイン関数
script.jsexport 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;
}
});
}
入力候補を更新する関数
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}]`);},
});
});
}
script.js _postMessage(word) {
this.searchWorker.postMessage({word: word, list: this.links, maxSuggestionNum: this.maxSuggestionNum});
}
}
役割
searchWorker
与えられた仕事を分割して並列処理用workerに振り分ける
返ってきた結果を統合してmain threadに返す
computer
並列処理用worker
処理が分散しているの良くないな……
仕事内容も searchWorker
で作って投げるようにするか?
global変数をいじるところがあるから、そうも行かないか。
searchWorker.jsconst 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'));
並列で検索を行う
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));
});
}
searchWorker.jsasync 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));
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.jsself.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.jsself.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);
}