onClick()
の中で onCompletionEnd()
を呼べば解決する? click
eventが発火する前にDOMの変化を検知してしまっているjsimport(`/api/code/programming-notes/editorから検索語句を取得して補完windowに渡すテストその2/main.js`);
main.jsimport {scrapboxDOM} from '/api/code/programming-notes/scrapbox-dom-accessor/script.js';
import {cursor} from '/api/code/programming-notes/scrapbox-cursor-position/script.js';
import {char as c} from '/api/code/programming-notes/scrapbox-char-accessor/script.js';
import {press} from '/api/code/programming-notes/scrapbox-keyboard-emulation/script.js';
import {create as createEngine} from '/api/code/programming-notes/複数の補完ソースを切り替えて検索できるUserScript/script.js';
import {projects} from '/api/code/programming-notes/editorから検索語句を取得して補完windowに渡すテストその2/project-list.js';
import {js2vim} from '/api/code/programming-notes/JSのkeyをVim_key_codeに変換するscript/script.js';
import '/api/code/programming-notes/scrapbox-suggest-container-2/script.js';
import {create} from '/api/code/programming-notes/scrapbox-suggest-container-2/item.js';
import {
createExternalData,
createEmojiData,
} from '/api/code/programming-notes/editorから検索語句を取得して補完windowに渡すテストその2/loader.js';
import {
keyBindings,
personalKeyBindings,
emojiKeyBindings,
} from '/api/code/programming-notes/editorから検索語句を取得して補完windowに渡すテストその2/settings.js';
main.jsconst suggestBox = document.createElement('suggest-container');
scrapboxDOM.editor.append(suggestBox);
main.jslet 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}`,
source: await createExternalData(['takker', 'takker-memex', 'takker-private']),
limit: 30,
ambig: 4,
}),
createEngine({
converter: ({project, title}) => `${project} ${title}`,
source: await createEmojiData(['icons', 'icons2', 'emoji']),
limit: 10,
}),
]);
const config = [{
search: word => searchEngine(engines[0], 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(engines[1], word.slice(1)), // trigger文字を除外する
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(engines[2], 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();
},
});
},
}];
main.jsscrapboxDOM.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} = config[mode].keyMappings.find(({key}) =>
typeof key === 'string' ? key === vimKeyCode : key.includes?.(vimKeyCode))
?? {command: undefined};
if (!command) return;
e.preventDefault();
e.stopPropagation();
command(suggestBox, onCompletionEnd);
});
main.jsfunction onCompletionEnd() {
suggestBox.mode = '';
suggestBox.hide();
completionend = true;
}
main.js_disabledconst observer2 = new MutationObserver(() =>{
});
observer2.observe(scrapboxDOM.cursor, {attributes: true});
main.jslet 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 = config[i].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 = config[mode].search(link.text);
// 時間がかかるようであればLoading表示をする
const timer = setTimeout(() => {
const image = /paper-dark-dark|default-dark/
.test(document.head.parentElement.dataset.projectTheme) ?
'https://img.icons8.com/ios/180/FFFFFF/loading.png' :
'https://img.icons8.com/ios/180/loading.png';
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, config[mode].limit - length)
.map(data => config[mode].convert(data, replacer, onCompletionEnd))
);
});
observer.observe(scrapboxDOM.lines, {childList: true, subtree: true});
_log('Ready to completion.');
})();
main.jsfunction getLink() {
// のみ補完を開始する
const cursor_ = cursor();
//_log(cursor_.left?.text, cursor_.right?.text, cursor_);
const [lLink, rLink] = [cursor_.left?.link, cursor_.right?.link];
if (!lLink || lLink?.DOM !== rLink?.DOM) return undefined;
return lLink; // rLinkを返してもいい
}
main.jsasync 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(...(linksData[i] ?? []));
}
}
return links;
};
main.jsfunction 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);
}
main.jsfunction _log(msg, ...objects) {
const title = 'editorから検索語句を取得して補完windowに渡すテストその2';
if (typeof msg !== 'object') {
console.log(`[main.js@${title}] ${msg}`, ...objects);
} else {
console.log(`[main.js@${title}] `, msg, ...objects);
}
}
settings.jsexport 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 = [
{
key: ['<C-i>', '<CR>'],
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(),
},
];
loader.jsimport {
getAllLinks,
getAllIcons,
} from '/api/code/programming-notes/Scrapbox_APIの取得結果をcacheするscript/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);
[result[k], result[i - 1]] = [result[i - 1], result[k]];
}
return result;
}
project-list.jsexport const projects = [
'hub',
'shokai',
'nishio',
'masui',
'motoso',
'villagepump',
'rashitamemo',
];