generated at
external-completion-3の設計

必要な機能
補完の開始判定
検索
やること
アイテムデータから実際のUI部品を作る
検索結果が返ってくるまで時間がかかるときは、検索中を表すメッセージを出す
委託すること
実際の検索処理
補完ソースの読み込み
入力補完にあまり関係ない機能だったことに気づいた
e.g. 外部project linkの場合
検索処理:advanced-link-searcher
形式
補完対象の文字列とカーソルの情報から、scrapbox-suggest-container-3に表示するアイテムデータ items と、scrapbox-suggest-container-3の表示位置 position を作る
補完候補が0のときは [] を返す
何もしないときは items: undefined にする
入力確定時に本文を編集するときは、編集後の文字列を onClick で返す
ts
async function oncompletion(target: string, cursor: CursorInfo): { items?: { title: string; image?: string; description?: string; link?: string; onClick?: <T>(param: T) => string | undefined; }[] | undefined; position: { top: number; left: number; }; };
補完windowに表示するアイテムを作る
アイテムは外部に作らせる
DOMまで作らせるか、 {image:'', text:''} のようなobject dataだけを作らせるかは悩んでいる
DOMまで作らせるとUIの自由度は上がるが、その分設定と実装が複雑になる
object dataだけだと、凝ったUIを作ることは出来ないが、実装も設定も楽になる
object dataにするか
作り変えた
キーボード操作
操作用関数のみ公開し、実際のキーボード操作との結びつけは別のscriptに任せる
userが好きなkey bind userscriptを使えるようにする
もちろん、defaultのkey bind scriptは用意しておく
操作一覧
補完windowが表示されていないなどで操作が無効のときは false を返す
キーボード操作側で、 true のときのみ e.preventDefault() を実行するように設定する
selectPrev()
selectNext()
start()
補完を強制開始する
どの補完ソースの補完開始条件にもあわなければ開始されない
end()
補完を強制終了する
confirm()
confirm({mode: 'newTab'})
mode に応じて処理を変える
対応していない補完エンジンもある
e.g.
mode: 'newTab'
新しいタブでリンクを開く
mode: 'icon'
アイコン記法で挿入する

問題点
候補をクリックできなくなった
遅い
多少はマシになったか?
少なくともperformance tool上ではネックではなくなった
条件判定をasync-singletonで包んで、たくさん実行しないようにするのも手だ。

code
js
(async () => { const [{execute}, {projects}] = await Promise.all([ import('/api/code/programming-notes/external-completion-3の設計/sample.js'), import('/api/code/programming-notes/scrapbox-link-database/list.js'), ]); execute({ externals: projects, siblings: ['takker', 'takker-memex', 'takker-private'], icons: ['icons', 'icons2', 'emoji'], }); })();
sample.js
import {externalCompletion} from './script.js'; import { externalSetting, siblingSetting, iconSetting, } from './sources.js'; import {scrapboxDOM} from '../scrapbox-dom-accessor/script.js'; import {js2vim} from '../JSのkeyをVim_key_codeに変換するscript/script.js'; export function execute({externals, siblings, icons}) { const config = [ {key: '<S-Tab>', command: () => externalCompletion.selectPrev()}, {key: '<Tab>', command: () => externalCompletion.selectNext(),}, {key: '<C-Space>', command: () => externalCompletion.start(), oncompleting: false}, {key: '<CR>', command: () => externalCompletion.confirm(),}, {key: '<C-i>', command: () => externalCompletion.confirm({mode: 'icon'}),}, ]; scrapboxDOM.editor.addEventListener('keydown', e => { if (!e.isTrusted) return; // programで生成したkeyboard eventは無視する if (e.isComposing) return; const key = js2vim(e); const pair = config.find(pair => pair.key === key); if (!pair) return; if ((pair.oncompleting ?? true) && !externalCompletion.completing) return; e.preventDefault(); e.stopPropagation(); pair.command(); }); externalCompletion.push( externalSetting(externals), siblingSetting(siblings), iconSetting(icons, {limit: 10}), ); }

dependencies
script.js
import {scrapboxDOM} from '../scrapbox-dom-accessor/script.js'; import {completionObserver} from '../external-completion-3%2FcompletionObserver-2/script.js'; import '../scrapbox-suggest-container-3/script.js';

script.js
class ExternalCompletion { constructor() { this._suggestBox = document.createElement('suggest-container'); scrapboxDOM.editor.append(this._suggestBox); this._settingIds = [] this._enable = true; this._searching = false; } on() { this._enable = true; } off() { this._enable = false; } push(...settings) { for (const oncompletion of settings) { this._settingIds.push(completionObserver.register({ oncompletionstart: (c, replace) => this._oncompletion(oncompletion(c, replace)), oncompletionupdate: (c, replace) => this._oncompletion(oncompletion(c, replace)), oncompletionend: () => this._suggestBox.clear(), })); } } get completing() { return completionObserver.completing; } selectPrev() { this._suggestBox.selectPrevious({wrap: true}); } selectNext() { this._suggestBox.selectNext({wrap: true}); } start() { completionObserver.start(); } end() { completionObserver.end(); this._suggestBox.hide(); } confirm({mode} = {}) { (this._suggestBox.selectedItem ?? this._suggestBox.firstItem).click({mode}); } async _oncompletion(result) { const pending = result; // 時間がかかるようであればSearching表示をする const timer = setTimeout(() => { if (this._searching) return; const image = /paper-dark-dark|default-dark/ .test(document.documentElement.dataset.projectTheme) ? 'https://img.icons8.com/ios/180/FFFFFF/loading.png' : 'https://img.icons8.com/ios/180/loading.png'; this._suggestBox.pushFirst({title: 'Searching...', image,}); this._searching = true; }, 1000); const pair = await pending; // Searching表示を消す clearTimeout(timer); if (this._searching) { this._suggestBox.pop(0); this._searching = false; } if (!pair) return false; const {items, position} = pair; // アイテムを追加してwindowを開く if (items) { this._suggestBox.replace(...items); } this._suggestBox.position(position); this._suggestBox.show(); return true; } } export const externalCompletion = new ExternalCompletion(); function _log(msg, ...objects) { const title = 'external-completion-3-beta'; console.log(`[main.js@${title}] `, msg, ...objects); }

各種検索機能の設定
sources.js
import {SearchEngine} from '../advanced-link-searcher/script.js'; //import {char as c} from '../scrapbox-char-accessor/script.js'; import {scrapboxDOM} from '../scrapbox-dom-accessor/script.js'; export const externalSetting = (projects, {timeout = 5000, limit = 30} = {}) => { const engine = new SearchEngine(projects.filter(project => project !== scrapbox.Project.name)); return async (cursor, replace) => { const link = (cursor.left ?? cursor.right)?.link; if (!link || link.type !== 'link' || !link.text.startsWith('/')) return undefined; console.log(`[external] search query "${link.text.slice(1)}"`); const {result} = await engine.search(link.text.slice(1), {timeout, limit}); console.log(`[external] finish:`, result); return convert(result, { dom: cursor.line.char(link.index).DOM, actions: [ { mode: 'newTab', command: (path) => window.open(`https://scrapbox.io${path}`), }, { mode: 'icon', command: path => replace( `[${path}.icon]`, link.index, link.text.length + 2, cursor, ), }, { mode: 'default', command: path => replace( `[${path}]`, link.index, link.text.length + 2, cursor, ), } ] }); }; } export const siblingSetting = (projects, {timeout = 5000, limit = 30} = {}) => { const engine = new SearchEngine(projects.filter(project => project !== scrapbox.Project.name)); return async (cursor, replace) => { const link = (cursor.left ?? cursor.right)?.link; if (!link || /^\/|:/.test(link.text)) return undefined; console.log(`[sibling] search query "${link.type === 'link' ? link.text : link.text.replace('_', ' ')}"`); const {result} = await engine.search(link.type === 'link' ? link.text : link.text.replace('_', ' '), {timeout, limit}); console.log(`[sibling] finish`, result); return convert(result, { dom: cursor.line.char(link.index).DOM, actions: [ { mode: 'newTab', command: (path) => window.open(`https://scrapbox.io${path}`), }, { mode: 'icon', command: path => replace( `[${path}.icon]`, link.index, link.text.length + (link.type === 'link' ? 2 : 1), cursor, ), }, { mode: 'default', command: path => replace( `[${path}]`, link.index, link.text.length + (link.type === 'link' ? 2 : 1), cursor, ), } ] }); }; }; export const iconSetting = (projects, {timeout = 5000, limit = 30} = {}) => { const engine = new SearchEngine(projects.filter(project => project !== scrapbox.Project.name), {icon: true}); return async (cursor, replace) => { const link = (cursor.left ?? cursor.right)?.link; if (!link || link.type !== 'link' || !link.text.startsWith(':')) return undefined; console.log(`[icon] search query "${link.text.slice(1)}"`); const {result} = await engine.search(link.text.slice(1), {timeout, limit}); console.log(`[icon] finish`, result); return convert(result, { dom: cursor.line.char(link.index).DOM, image: path => `/api/pages${path}/icon`, actions: [ { mode: 'newTab', command: (path) => window.open(`https://scrapbox.io${path}`), }, { mode: 'icon', command: path => replace( `[${path}.icon]`, link.index, link.text.length + 2, cursor, ), }, { mode: 'default', command: path => replace( `[${path}.icon]`, link.index, link.text.length + 2, cursor, ), } ] }); }; }; function convert(searchedTexts, {dom, image, actions}) { // リンクの先頭文字に補完windowの位置を合わせる const editorRect = scrapboxDOM.editor.getBoundingClientRect(); const {left, bottom} = dom.getBoundingClientRect(); return { items: searchedTexts?.map?.(path => { return { title: path, ...(image ? {image: image(path)} : {}), onClick: ({mode = 'default'} = {}) => { const {command} = actions.find(action => mode === action.mode) ?? {}; if (command) command(path); return; }, }; // 変更する必要がないときはundefinedを返す }) ?? undefined, position: { top: bottom - editorRect.top, left: left - editorRect.left, }, }; }

#2021-03-18 10:52:00
#2021-03-16 09:44:12
#2021-03-14 23:58:08
#2021-03-08 06:23:20