external-completion-3の設計
https://kakeru.app/1e9be50acf50486d4fc876acee589703 https://i.kakeru.app/1e9be50acf50486d4fc876acee589703.svg
必要な機能
補完の開始判定
検索
やること
アイテムデータから実際のUI部品を作る
検索結果が返ってくるまで時間がかかるときは、検索中を表すメッセージを出す
委託すること
実際の検索処理
補完ソースの読み込み
入力補完にあまり関係ない機能だったことに気づいた
e.g. 外部project linkの場合
形式
補完候補が0のときは[]を返す
何もしないときはitems: undefinedにする
入力確定時に本文を編集するときは、編集後の文字列をonClickで返す
code: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上ではネックではなくなった
code
code:js
(async () => {
import('/api/code/programming-notes/external-completion-3の設計/sample.js'),
import('/api/code/programming-notes/scrapbox-link-database/list.js'),
]);
execute({
externals: projects,
});
})();
code: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
code: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';
code: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) ?
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);
}
各種検索機能の設定
code: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,
},
};
}