items
と、scrapbox-suggest-container-3の表示位置 position
を作る []
を返す items: undefined
にする onClick
で返すtsasync 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;
};
};
{image:'', text:''}
のようなobject dataだけを作らせるかは悩んでいる false
を返す true
のときのみ e.preventDefault()
を実行するように設定する selectPrev()
selectNext()
start()
end()
confirm()
confirm({mode: 'newTab'})
mode
に応じて処理を変える mode: 'newTab'
mode: 'icon'
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.jsimport {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}),
);
}
script.jsimport {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.jsclass 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.jsimport {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,
},
};
}