external-completion-3-beta
実装したいこと
Classに整理
これはrelease版でやる
/icons/done.icon入力補完の切り替え
まずは、hard codingしている部分を設定変数に切り出す必要がある
必要なものは
key bindings
補完開始の条件
検索語句
検索エンジン
検索語句を入れると、検索結果がPromiseの配列で帰ってくる
検索結果の表示方法
検索結果が送られて来次第どんどん表示する
全てのworkerの計算が終了するのを待たない
やめた
処理を共通化するのが難しい
2021-02-20 00:33:11 大体できたのでreleaseする
2021-02-20 01:43:33 クリックが効かなくなってしまった
少し前までは使えてた
なんでだ?
01:46:22 cursorがリンクから外れた瞬間に補完が終了する処理を入れたことが原因だ
onClick()の中でonCompletionEnd()を呼べば解決する?
多分無理
click eventが発火する前にDOMの変化を検知してしまっている
行けるっぽい?
条件がわからんtakker.icon
小さめのprototypeを作って挙動を確認するしかなさそう
02:08:00 何とか直した
code:js
import(/api/code/takker/external-completion-3-beta/main.js);
dependencies
code:main.js
import {scrapboxDOM} from '../scrapbox-dom-accessor/script.js';
import {cursor} from '../scrapbox-cursor-position-5/script.js';
import {char as c} from '../scrapbox-char-accessor/script.js';
import {press} from '../scrapbox-keyboard-emulation-2/script.js';
import {create as createEngine} from '../asearch-engine/script.js';
import {projects} from './project-list.js';
import {js2vim} from '../Vim-keymap-converter-2/script.js';
import '../scrapbox-suggest-container/script.js';
import {create} from '../scrapbox-suggest-container/item.js';
import {
createExternalData,
createEmojiData,
} from './loader.js';
import {
keyBindings,
personalKeyBindings,
emojiKeyBindings,
} from './settings.js';
//import {completionObserver} from '../scrapbox-suggest-observer/script.js';
入力補完windowを作る
code:main.js
const suggestBox = document.createElement('suggest-container');
scrapboxDOM.editor.append(suggestBox);
設定
code: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},
limit: 30,
ambig: 4,
}),
createEngine({
converter: ({project, title}) => ${project} ${title},
limit: 10,
}),
]);
const config = [{
search: word => searchEngine(engines0, 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(engines1, word.slice(1)), // 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(engines2, 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();
},
});
},
}];
キーバインドの設定
code: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} = configmode.keyMappings.find(({key}) => typeof key === 'string' ? key === vimKeyCode : key.includes?.(vimKeyCode))
?? {command: undefined};
if (!command) return;
e.preventDefault();
e.stopPropagation();
command(suggestBox, onCompletionEnd);
});
補完終了時の処理
code:main.js
function onCompletionEnd() {
suggestBox.mode = '';
suggestBox.hide();
completionend = true;
}
code:main.js_disabled
const observer2 = new MutationObserver(() =>{
});
observer2.observe(scrapboxDOM.cursor, {attributes: true});
補完開始の制御
単純にDOMの更新だけで調べてしまうと、記法がむき出しになっただけで検知してしまう
まあ妥協するか。takker.icon
そこまで邪魔にならないかもしれない
2021-02-20 01:54:39 結構面倒だ
候補を確定したときの入力と、ユーザのキー入力との区別をつけることができない
いくつかのフラグを組み合わせるか
入力補完後のに立てたフラグは、キーボード操作をしないと倒れないとか
code:main.js_(js)
// リンク内補完
completionObserver.register({
judge() {
const c = cursor();
const link = (c.left ?? c.right)?.DOM?.closest?.('.page-link');
return link?.type === 'hashTag' ?
link?.textContent?.slice?.(1) :
link?.textContent?.slice?.(1, -1) ??
undefined;
},
oncompletion(linkText) {
console.log(linkText);
},
});
// 数式補完
completionObserver.register({
judge() {
const c = cursor();
const formula = (c.left ?? c.right)?.DOM?.closest?.('.formula');
return formula?.textContent?.slice?.(3, -1) ?? undefined;
},
oncompletion(formulaText) {
console.log(formulaText);
},
});
// <C-Space>で強制補完開始
scrapboxDOM.editor.addEventListener('keydown', e => {
if(!e.ctrlKey || e.key !== ' ') return;
e.preventDefault();
e.stopPropagation();
completionObserver.startCompletion();
});
code: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 = configi.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 = configmode.search(link.text); // 時間がかかるようであればLoading表示をする
const timer = setTimeout(() => {
const image = /paper-dark-dark|default-dark/
.test(document.head.parentElement.dataset.projectTheme) ?
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, configmode.limit - length) .map(data => configmode.convert(data, replacer, onCompletionEnd)) );
});
observer.observe(scrapboxDOM.lines, {childList: true, subtree: true});
_log('Ready to completion.');
})();
cursorがいるリンクを返す
cursorの両隣の文字が同じリンクの中にあるときのみ、cursorがそのリンクの中にいると判断する
code:main.js
function getLink() {
// のみ補完を開始する
const cursor_ = cursor();
//_log(cursor_.left?.text, cursor_.right?.text, cursor_);
if (!lLink || lLink?.DOM !== rLink?.DOM) return undefined;
return lLink; // rLinkを返してもいい
}
検索用関数
code: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(...(linksDatai ?? [])); }
}
return links;
};
テキスト置換用関数
code: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用
code:main.js
function _log(msg, ...objects) {
const title = 'external-completion-3-beta';
if (typeof msg !== 'object') {
console.log([main.js@${title}] ${msg}, ...objects);
} else {
console.log([main.js@${title}] , msg, ...objects);
}
}
code: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 = [
{
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(),
},
];
補完ソースを読み込む
code:loader.js
import {
getAllLinks,
getAllIcons,
} from '/api/code/takker/scrapbox-cache-fetch/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);
}
return result;
}
code:project-list.js
export const projects = [
'hub',
'shokai',
'nishio',
'masui',
'motoso',
'villagepump',
'rashitamemo',
];