generated at
external-completion
外部プロジェクトのリンクを入力補完出来るようにするUserScriptです
↑マルチスレッドプログラミングを採用したので、こっちの方がスムーズに動きます

操作説明
[/] の中にカーソルを置くと補完が開始される。



[] の内側の文字であいまい検索した候補が表示される

or Tab キーで前候補を選択
or Shift+Tab キーで次候補を選択
Enter or クリックで候補を確定

入力候補が一つになると自動で置き換える選択していれば置き換える
focusなしで自動置換すると、userが文字削除操作をしているときにconflictしてしまうのでやめた


入力途中で Enter を押すと、最初の入力候補が入力される




導入方法
以下を自分のページのscript.jsに貼り付ける
import.js
import {startSuggestingExternalProjectLinks} from '/api/code/customize/external-completion/script.js'; // 入力候補に入れたいprojectを書く startSuggestingExternalProjectLinks([ 'shokai', 'hub', 'customize', 'scrapboxlab']);
補完したい外部プロジェクトの名前のリストを startSuggestingExternalProjectLinks の変数に渡す
参加しているprivate projectも入れられる

初期状態では、入力候補は最大30件まで表示される。
これを変更したい場合は、 startSuggestingExternalProjectLinks の第2引数に最大表示件数を渡す。

入力補完windowが長すぎる場合は、 <ul> のstyleに max-height: "calc(50vh - 100px)" を追加するといい感じになる

注意
使いすぎると思考停止に陥る恐れがあります
外部プロジェクトを補完する前に自分の言葉で十分書けているか確認したほうがいいでしょう。
もしくは自分のプロジェクトのみを補完候補に入れるのも手です

参考にしたもの
これをベースに作成した
キー入力部分は根っこから変えた
元コードではキー入力を監視し、それをstackに積むことでuserの入力を取得している
しかしそれだと全角文字を取得できないという欠点があった
「日本語」に変換される前の「nihongo」しか取得できない
そこで、scrapboxのeditorをDOM操作して入力文字を取得する方針に変えた
座標計算で結構つまずいた
offsetLeft の基準座標がGoogleChromefirefoxで違う
focusの順番を変えるとcursorの位置が一文字分ずれる
入力候補選択で参考にした
当初は全てコードで書くつもりだったが、うまく動かなかったので↑の通りに <a> タグを使うことにした
CoffeeScriptで書かれたクラスをほぼそのままjavascriptに書き換えただけ
/yutaro/emoji selectorでは実装されていなかった機能
空白区切りで入力した文字列の順番に関係なくマッチできる

既知の問題
補完が開始するまで少し待つ必要がある
外部プロジェクトのページ情報をfetchしている
補完windowの表示でややもたつくかも
何かキーを入力しないと出てこなかったり
[/全角文字] はdefaultの入力補完windowが表示される場合もある
項目が一つだけになったときの自動入力がなんかおかしい
別な項目が挿入される事がある?
Scrapbox標準のpopup menuが出ていると、置換に失敗する?
emoji-selectorと干渉する
候補選択のキー入力でぶつかる
2020/8/26 02:09 修正済み

hr
以下本体のコード

ESModuleのインポート
script.js
import {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.js
export 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.js
async 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.js
function 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.js
function 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.js
function 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.js
function 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.js
function 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); }

UserScript