external-completion
↑マルチスレッドプログラミングを採用したので、こっちの方がスムーズに動きます
操作説明
[/]
の中にカーソルを置くと補完が開始される。
[]
の内側の文字であいまい検索した候補が表示される
↓
or Tab
キーで前候補を選択
↑
or Shift+Tab
キーで次候補を選択
Enter
or クリックで候補を確定
入力候補が一つになると自動で置き換える選択していれば置き換える
focusなしで自動置換すると、userが文字削除操作をしているときにconflictしてしまうのでやめた
入力途中で Enter
を押すと、最初の入力候補が入力される
導入方法
import.jsimport {startSuggestingExternalProjectLinks}
from '/api/code/customize/external-completion/script.js';
// 入力候補に入れたいprojectを書く
startSuggestingExternalProjectLinks([
'shokai',
'hub',
'customize',
'scrapboxlab']);
補完したい
外部プロジェクトの名前のリストを
startSuggestingExternalProjectLinks
の変数に渡す
初期状態では、入力候補は最大30件まで表示される。
これを変更したい場合は、 startSuggestingExternalProjectLinks
の第2引数に最大表示件数を渡す。
入力補完windowが長すぎる場合は、
<ul>
のstyleに
max-height: "calc(50vh - 100px)"
を追加するといい感じになる
注意
使いすぎると思考停止に陥る恐れがあります
外部プロジェクトを補完する前に自分の言葉で十分書けているか確認したほうがいいでしょう。
もしくは自分のプロジェクトのみを補完候補に入れるのも手です
参考にしたもの
これをベースに作成した
キー入力部分は根っこから変えた
元コードではキー入力を監視し、それをstackに積むことでuserの入力を取得している
しかしそれだと全角文字を取得できないという欠点があった
「日本語」に変換される前の「nihongo」しか取得できない
そこで、scrapboxのeditorを
DOM操作して入力文字を取得する方針に変えた
座標計算で結構つまずいた
offsetLeft
の基準座標が
と
で違う
focusの順番を変えるとcursorの位置が一文字分ずれる
入力候補選択で参考にした
当初は全てコードで書くつもりだったが、うまく動かなかったので↑の通りに <a>
タグを使うことにした
CoffeeScriptで書かれたクラスをほぼそのままjavascriptに書き換えただけ
空白区切りで入力した文字列の順番に関係なくマッチできる
既知の問題
補完が開始するまで少し待つ必要がある
補完windowの表示でややもたつくかも
何かキーを入力しないと出てこなかったり
[/全角文字]
はdefaultの入力補完windowが表示される場合もある
項目が一つだけになったときの自動入力がなんかおかしい
別な項目が挿入される事がある?
標準のpopup menuが出ていると、置換に失敗する?
候補選択のキー入力でぶつかる
2020/8/26 02:09 修正済み
以下本体のコード
script.jsimport {suggestWindow} from '/api/code/customize/suggestWindow/script.js';
import fizzSearch from '/api/code/customize/fizzSearch/import.js';
import {externalLinkObserver} from '/api/code/customize/externalLinkObserver/script.js';
メイン関数
script.jsexport async function startSuggestingExternalProjectLinks(projectNames, maxSuggestionNum = 30) {
const suggestion=new suggestWindow();
const linkObserver = new externalLinkObserver(suggestion.editor);
const titles = await importExternalPageName(projectNames);
// windowの更新
// keyupとmouseupで作動する
const eventHandler = e => {
if (!e.key) return;
const cursor = document.getElementById('text-input');
// code blockの頭とcode blockの中身では動作しないようにする
if(suggestion.editor.getElementsByClassName('cursor-line').length != 0) {
if (suggestion.editor.getElementsByClassName('cursor-line')[0].textContent.trim() == 'code:'
|| suggestion.editor.getElementsByClassName('cursor-line code-block').length == 1) {
suggestion.close(cursor);
return;
}
}
// 監視対象である、userが入力中の外部プロジェクトリンクに変更があれば、入力候補を更新する
if (linkObserver.reload(cursor)) {
const suggestTitles = updateSuggestList(titles, linkObserver);
if(!suggestTitles) {
suggestion.close();
return;
}
suggestion.updateItems(
createGuiList(suggestTitles.slice(0, maxSuggestionNum)
, suggestion, linkObserver, cursor));
}
// 入力対象が外部プロジェクトリンクでなければ何もしない
if(!linkObserver.linkString) {
suggestion.close();
return;
}
// 候補が一つしかないときはそれを入力する
if(suggestion.getItems().length == 1 && suggestion.hasFocus()) {
comfirmSuggestion(suggestion, linkObserver, cursor
, suggestion.getItems()[0].textContent);
}
switch(e.key) {
case 'Escape':
case 'Home':
case 'End':
case 'PageUp':
case 'PageDown':
suggestion.close();
return;
}
// for debug
//if(!suggestion.hasFocus()){
// const cursorIndex = getCharPositionUnderCursor(suggestion.editor,cursor);
// console.log(`targetLink: ${linkObserver.linkString}`);
// console.log(`left: ${cursorIndex - linkObserver.leftCharIndex -1}, cursor: ${cursorIndex}, right: ${linkObserver.rightCharIndex - cursorIndex}`);
//}
suggestion.reDraw(cursor);
suggestion.open();
};
// default動作を握りつぶしたいキー入力は、keydownの段階で操作する
const eventHandler2 = e => {
if (!e.key) return;
// 入力補完windowが表示されていないときは何もしない
if(!suggestion.isOpen()) return;
// 一番先頭の候補から上に移動したときはwindowを閉じることにする
if((e.key == 'ArrowUp'
|| (e.key == 'Tab' && e.shiftKey))
&& (suggestion.isOpen() && suggestion.hasFirstItemFocus())) {
e.stopPropagation();
e.preventDefault();
suggestion.close();
return;
}
// focusをwindowに移す
if((e.key == 'ArrowDown' || e.key == 'Tab')
&& (suggestion.isOpen() && !suggestion.hasFocus())) {
e.stopPropagation();
e.preventDefault();
suggestion.selectFirstItem();
return;
}
// 一番先頭の候補で確定する
if(e.key == 'Enter'
&& suggestion.isOpen()
&& !suggestion.hasFocus()) {
e.stopPropagation();
e.preventDefault();
const cursor = document.getElementById('text-input');
comfirmSuggestion(suggestion, linkObserver, cursor
, suggestion.getItems()[0].textContent);
return;
}
};
suggestion.editor.addEventListener('keydown', eventHandler2);
suggestion.editor.addEventListener('keyup', eventHandler);
suggestion.editor.addEventListener('mouseup', eventHandler);
}
projectの全ページ数に応じて skip
の値を変えられるようにしたい
あとUserScriptをloadしたあとも非同期にprojectのページを読み込めるようにできるとなおよい
script.jsasync function importExternalPageName(projectNames) {
let promises = [];
for (const projectName of projectNames) {
for (let index = 0; index < 10; index++) {
promises.push(fetch(`/api/pages/${projectName}/?limit=1000&skip=${index*1000}`)
.then(res => res.json())
.then(json => json.pages.map(page => `/${projectName}/${page.title}`)));
}
}
const temp = await Promise.all(promises);
const result = temp.reduce((result, current) => [...result, ...current]);
//console.log(`Got ${result.length} titles:`)
//result.slice(0,30)
// .forEach( (title, index) => console.log(`\tNo.${index}: ${title}`));
//console.log('And more...');
return result;
}
入力候補を更新する関数
script.jsfunction updateSuggestList(sourceList, linkObserver){
if(!linkObserver.linkString) return undefined;
const tergetString = linkObserver.linkString.slice(1,linkObserver.linkString.length - 1);
const suggestTitles = fizzSearch(tergetString, sourceList);
if(suggestTitles.length == 0) return undefined;
// 補完候補と既に入力されたリンクが同じであれば何もしない
if(suggestTitles.length == 1 && suggestTitles[0] == tergetString) {
return undefined;
}
return suggestTitles;
}
カーソルがある []
の内部を insertText
で置換する
script.jsfunction comfirmSuggestion(suggestion, linkObserver, cursor, insertText) {
cursor.focus();
// '['に当たるまでカーソルを動かす
const cursorLine = () => suggestion.editor.getElementsByClassName('cursor-line')[0]
.getElementsByClassName(`c-${getCharPositionUnderCursor(suggestion.editor,cursor)}`)[0].textContent;
while(cursorLine() != '[') {
//console.log(`char: ${cursorLine()}`);
cursor.dispatchEvent(new KeyboardEvent('keydown',
{bubbles: true, cancelable: true, keyCode: 37}));
}
replaceText(cursor,linkObserver.linkString, `[${insertText}]`);
suggestion.close();
}
入力補完windowに表示する項目を作成する
script.jsfunction createGuiList(suggestTitles, suggestion, linkObserver, cursor) {
return suggestTitles
.map(title => {
const a = document.createElement('a');
a.setAttribute('tabindex', '0');
a.setAttribute('role', 'menuitem');
const div = document.createElement('div');
div.textContent = title;
a.appendChild(div);
a.onclick = () => comfirmSuggestion(suggestion, linkObserver, cursor, title);
a.onkeypress = e => {
if (e.key !== "Enter") return;
e.stopPropagation();
e.preventDefault();
comfirmSuggestion(suggestion, linkObserver, cursor, title);
};
return a;
});
}
cursor直下の文字が行先頭から数えて何番目に位置しているかを返す関数
script.jsfunction getCharPositionUnderCursor(editor,cursor){
const cursorLine = editor.getElementsByClassName('cursor-line')[0];
const text = cursorLine.textContent;
if (text === '' ) return undefined;
// 要素の左端の相対座標を取得する
const getLeftPosition = target => target.getBoundingClientRect().left - cursorLine.getBoundingClientRect().left;
// テキストから、それぞれの文字の座標を入れた配列を作る
const charPostions = text.split('').map((char,index) =>
[getLeftPosition(cursorLine.getElementsByClassName(`c-${index}`)[0]), index]);
const charDistances = charPostions
.map(pos => [Math.abs(parseInt(cursor.style.left) - pos[0]), pos[1]])
.sort((a,b) => a[0] - b[0]);
const targetIndex = charDistances[0][1];
return targetIndex;
}
oldText
の文字数だけ Delete
を実行した後に、 insertText
を挿入する関数
script.jsfunction replaceText(cursor,oldText, newText) {
const range = n => [...Array(n).keys()];
const isFirefox = () => {
const userAgent = window.navigator.userAgent.toLowerCase();
if (userAgent.indexOf('firefox') != -1) {
return true;
}
return false;
};
setTimeout(() => {
// Deleteを実行
for(const _ of range(oldText.length)) {
cursor.dispatchEvent(new KeyboardEvent('keydown',
{bubbles: true, cancelable: true, keyCode: 46}));
}
if (isFirefox()) {
const start = cursor.selectionStart; // in this case maybe 0
cursor.setRangeText(newText);
cursor.selectionStart = cursor.selectionEnd = start + newText.length;
const uiEvent = document.createEvent('UIEvent');
uiEvent.initEvent('input', true, false);
cursor.dispatchEvent(uiEvent);
} else {
document.execCommand('insertText', false, newText);
}
}, 50);
}