external-completion@0.4.0
js(async () => {
await import('/api/code/programming-notes/external-completion@0.4.0/index.js');
})();
shdeno run --allow-net --allow-read --allow-write --allow-run --allow-env --unstable https://scrapbox.io/api/code/takker/UserScriptをbundleするDeno_script/build.ts "https://scrapbox.io/api/code/programming-notes/external-completion@0.4.0/index.js" --bundle --minify --charset=utf8 | xsel
index.jsimport {mount} from '../external-completion@0.4.0/script.js';
const watchListIds = Object.keys(JSON.parse(localStorage.getItem('projectsLastAccessed')));
const iconIds = [
'57b3fe09ec2b330f00f15382', // /icons Icons
'5ebf80b491582c001e38c967', // /Icons2 Icons2
'5adc2250d5caf30014910a83', // /emoji emoji
];
// watchListから現在projectとicon用project除く
const res = await fetch(`/api/projects/${scrapbox.Project.name}`);
const {id} = await res.json();
const projectIds = watchListIds
.filter(_id => _id !== id && !iconIds.some(iconId => iconId === id));
// 起動
mount({
internal: [], // []の補完に使うproject idのlist
external: projectIds, // [/]に使うprojectのid list
emoji: iconIds, // [:]に使うprojectのid list
});
変更点
実装したいこととか
現在プロジェクトを補完ソースから除く
これはuser側でproject idsを指定するときに各自で除外してほしい
初回補完時に固まるのをなんとかしたい
databaseの初期化処理をweb workerに逃せばいい
worker codeをbundleできるようにしたい
設定を分離したい
key bind
補完の挙動
リンク内部にカーソルが入ると即座に検索が開始されるのはそのままにする
いちいち文字を入力しなくても、そのリンクに関連するリンクがsuggestされるので便利
iconも補完できるようにしたい
とりあえずhookとかcomponentを整理したいな
component以外の部分を一つのhookにまとめてしまおう
useCompletion
にする
keyboard操作は抜く
具体的な検索機能以外を一旦分離させても良い気がする
いや、無理そう
loading
と searching
が密に関わっている
とりあえずkeyboard以外をまとめてみるか。
open
と enable
の役割が逆な気がするまあいいか
既知の問題
検索結果の反映に何故かタイムラグが有る
renderが走っていない?
ここで、違うprojectの同名リンクも削ってしまっていた
違うprojectに同名リンクがあることは知りたいので、削っちゃダメ
そもそもこの機能いらないな。消そう。
もともと補完候補に自分のprojectが混じっていたときに、それを除外する設定として入れた
使う人の設定に任せる
補完候補をリンクとしてdrag & dropしたり新しいタブで開いたりできなくなった
mobileだと文字入力を認識できない?
input
eventが送られていないような挙動をする
keyup
eventは正常に動いていた
そのうちremote debugして確かめる
cursorがリンクの外に出ても、文字を入力しない限りWindowが閉じない
mobileでクリックがなんか変
入力先の行が見えている状態でクリックしないと補完が確定しない
2021-06-27
2021-06-25
09:09:51 カーソルが []
の外に出たら補完を取りやめる
やったこと
共通UIを Component
に切り出した
project idを外部から指定できるようにした
[]
の中身の条件を正規表現で指定するように変えた
/^[^\/:]/
を使う必要が生じたので
2021-06-24
23:11:45 補完開始条件と、テキスト変化の検知方法を大幅に変えた
drag & dropもできるようになった
21:45:55 keyboard event listenerの登録先を #editor
から #text-input
に変更した
2021-06-21
20:24:43 tag
を追加した
[/xxx]
の /
を変更できる
[:xxx]
や [xxx]
にもできる
09:35:25 候補確定を待ってから補完を一時的に切るようにした
実装できていなかった
09:24:14 とりあえずkeyboard機能以外のhookを一つのcustom hook useExternalCompletion
に切り出した
神クラスになっているので、ここからさらに機能分割したい
項目選択は別にできるかな
2021-06-20
23:36:26 ↓を実装できてなかったのを直した
23:28:31 <C-space>
で補完を手動で開始できるようにした
勿論補完できなければ何もしない
見たことないエラーだ
原因はわかった
23:35:27 直した
↓のバグ発生条件がわからないことには対策のしようがない
とりあえずこのまま使ってみる
smartphoneでもクリックできることは確認した
バグるかどうかまでは確認できていない
2021-06-24 21:48:18 以前一部の項目がバグる
22:17:00 クリック操作がやはりうまく行かない
通常クリック
あるリンクだけクリックするとそのページに飛んでしまう
event listenerを外すと、時々クリックしただけでwindowが消えてしまう
修飾キーつきクリック
問題なさそう
drag & drop
うごかない
原因
<a>
をクリックすると .cursor
の形状が変わる
形状が変わると座標の当たり判定が変わる
結果 .page-link
中にcursorがないと判定されて、windowが閉じてしまう
対策
コードがより煩雑にならないか?
#text-input
の入力を監視する
補完の開始条件を、テキストの入力開始に変更する
input
eventが送信されるたびに、カーソルが .page-link
の中にあるかどうかを判定する
.cursor
の変化ではなく実際の入力状態の変化を監視することで、補完候補をクリックしても補完状態を変更しないようにできる
22:06:19
<Esc>
で補完を一時的にcancelできるようにした
その後のcursor移動操作でリンク内部にいたらまた補完を開始する
入力確定直後は補完を一時的に切るようにした
cursor移動後にリンク内部にいたらまた補完を開始する
21:18:31 フラグ処理をミスっているので整理&修正
21:12:13 項目がなくても読み込み中メッセージと検索中メッセージを表示する
21:09:56 <a>
の横幅を最大まで広げる
click eventが発生しない領域をなくす
21:17:09 多分これで↓が直った
21:42:40 直っていなかった
エラーが出ないときもある
その時はそもそも <a>
のclick eventが発生していない
21:47:23 直した
click
が発生する前に
MutationObserverが
.cursor
の座標移動を検知してしまい、先にwindowを閉じてしまっているのではないかと推測した
ただ今度はリンクのドラッグや別タブで開く操作ができなくなってしまった
21:09:28 (WIP) クリック操作で候補を確定できていなかったのを直す
まだうまく行っていない
押せたり押せなかったりする
19:56:55 refactor: Hookの順序を入れ替えた
19:55:19 検索語句と同名のリンクの除外処理を削った
19:05:44 検索中の時、検索語句を表示するようにした
19:01:03 補完候補がないときはwindowを閉じる
これでリンクの ]
の右側にカーソルが置いてある状態で Enter
できない問題をだいたい解決できた
2021-06-19
21:05:44 Componentに表示する文字列やパスを修正
20:54:25 何故か results
が配列ではないと言われる
具体的にどんな値になっているのか調べる
11:59:00 <C-Space>
で補完の有効無効を切り替えられるようにした
<Esc>
は止めた
10:48:45 query
と同じ候補は除く
候補がなかった場合との区別がつかないか
除かない代わりに、同じ候補しかなかった場合はwindowを表示しないようにしたほうがいいかな
候補がなかったら Not found
と表示する
別に区別を付けなくてもいいか
09:48:52 入力確定処理を実装する
10:39:44 実装完了
一部のケースだけだがテストもできた
09:30:10 テスト開始
09:33:06 open
とは別に開閉可能かどうかを示す enable
を追加する必要がある
でないと <C-Space>
で一瞬windowが表示されてしまう
{enable: open}
が原因か
{enable: enable && open}
にしよう
09:46:02 直った
dependencies
script.jsimport {html, render} from '../htm@3.0.4%2Fpreact/script.js';
import {useState, useMemo, useCallback, useEffect} from '../preact@10.5.13/hooks.js';
import {useCrossSearch} from '../use-cross-search@0.1.0/script.js';
import {useSelect} from '../use-select@0.1.0/script.js';
import {useKeyboard} from '../use-keyboard@0.1.0/script.js';
import {DropdownMenu} from '../dropdown-container@0.1.1/script.js';
import {toLc} from '../scrapbox-titleLc/script.js';
import {replaceLink} from '../external-completion@0.4.0%2FreplaceLink/script.js';
import {scrapboxDOM} from '../scrapbox-dom-accessor/script.js';
const Completion = ({position, item, loading, searching, query, list, icon}) => html`
<${DropdownMenu} position="${position}" selected="${item?.key}"
messages="${html`<span>${loading ? 'Loading...' : searching ? `Searching for "${query}"...` : 'Ready to search.'}</span>`}">
${list.map(({key, href, project, onClick}) => html`
<a href="${href}"
key="${key}"
tabindex="-1"
onClick="${onClick}">
${icon && html`<img src="/api/pages${href}/icon" />`}
${key}
</a>
`)}
<//>
`;
const CSS = `
:host {
--dropdown-text-color: var(--page-text-color, #333);
--dropdown-bg: var(--page-bg, #fff);
--dropdown-border-color: var(--body-bg, rgba(0,0,0,0.15));
--dropdown-shadow-color: rgba(0,0,0,0.175);
--dropdown-item-hover-text-color: var(--page-text-color, #333);
--dropdown-item-hover-bg: var(--card-hover-bg, #f5f5f5);
--dropdown-item-select-border-color: #66afe999;
}
`;
const InternalCompletion = ({projectIds}) => {
const {
item,
list,
confirm,
confirmIcon,
start,
cancel, // 一時的に補完を止める
isOpen,
isEnableKeyboard,
loading,
searching,
query,
selectPrev,
selectNext,
position,
} = useExternalCompletion('internal-completion', projectIds, {internal: true, filter: /^[^\/:]/});
const textInput = scrapboxDOM.textInput;
useKeyboard(textInput, 'Escape', cancel, {enable: isEnableKeyboard}, [isEnableKeyboard]);
useKeyboard(textInput, {key: ' ', ctrlKey: true}, start, {stopPropagation: false});
return isOpen && html`<${Completion}
position="${position}"
list="${list}"
item="${item}"
query="${query}"
loading="${loading}"
searching="${searching}" />`;
};
const ExternalCompletion = ({projectIds}) => {
const {
item,
list,
confirm,
confirmIcon,
start,
cancel, // 一時的に補完を止める
isOpen,
isEnableKeyboard,
loading,
searching,
query,
selectPrev,
selectNext,
position,
} = useExternalCompletion('external-completion', projectIds, {filter: /^\//});
const textInput = scrapboxDOM.textInput;
useKeyboard(textInput, {key: 'Tab', shiftKey: true}, selectPrev, {enable: isEnableKeyboard}, [isEnableKeyboard]);
useKeyboard(textInput, {key: 'Tab', shiftKey: false}, selectNext, {enable: isEnableKeyboard}, [isEnableKeyboard]);
useKeyboard(textInput, {key: 'Enter', ctrlKey: true}, confirmIcon, {enable: isEnableKeyboard}, [isEnableKeyboard]);
useKeyboard(textInput, 'Enter', confirm, {enable: isEnableKeyboard}, [isEnableKeyboard]);
useKeyboard(textInput, 'Escape', cancel, {enable: isEnableKeyboard}, [isEnableKeyboard]);
useKeyboard(textInput, {key: ' ', ctrlKey: true}, start, {stopPropagation: false});
return isOpen && html`<${Completion}
position="${position}"
list="${list}"
item="${item}"
query="${query}"
loading="${loading}"
searching="${searching}" />`;
};
const EmojiCompletion = ({projectIds}) => {
const {
item,
list,
confirm,
confirmIcon,
start,
cancel, // 一時的に補完を止める
isOpen,
isEnableKeyboard,
loading,
searching,
query,
selectPrev,
selectNext,
position,
} = useExternalCompletion('emoji-completion', projectIds, {icon: true, filter: /^:/});
const textInput = scrapboxDOM.textInput;
useKeyboard(textInput, {key: 'Tab', shiftKey: true}, selectPrev, {enable: isEnableKeyboard}, [isEnableKeyboard]);
useKeyboard(textInput, {key: 'Tab', shiftKey: false}, selectNext, {enable: isEnableKeyboard}, [isEnableKeyboard]);
useKeyboard(textInput, 'Enter', confirmIcon, {enable: isEnableKeyboard}, [isEnableKeyboard]);
useKeyboard(textInput, 'Escape', cancel, {enable: isEnableKeyboard}, [isEnableKeyboard]);
useKeyboard(textInput, {key: ' ', ctrlKey: true}, start, {stopPropagation: false});
return isOpen && html`<${Completion}
position="${position}"
list="${list}"
item="${item}"
query="${query}"
loading="${loading}"
searching="${searching}"
icon="${true}" />`;
};
export async function mount(props) {
const app = document.createElement('div');
app.dataset.userscriptName= 'external-completion';
document.getElementById('editor').append(app);
app.attachShadow({mode: 'open'});
render(html`
<style>${CSS}</style>
${(props.internal?.length ?? 0) > 0 && html`<${InternalCompletion} projectIds="${props.internal}" />`}
${(props.external?.length ?? 0) > 0 && html`<${ExternalCompletion} projectIds="${props.external}" />`}
${(props.emoji?.length ?? 0) > 0 && html`<${EmojiCompletion} projectIds="${props.emoji}" />`}
`, app.shadowRoot);
}
custom hooks
script.jsimport {position as caretPos} from '../scrapbox-cursor-position-2/script.js';
function useExternalCompletion(name, projectIds, {icon, filter, internal} = {}) {
const [query, setQuery] = useState('');
const [open, setOpen] = useState(false);
const [enable, setEnable] = useState(true);
const cancel = useCallback(() => setOpen(false), []);
// 検索結果
const {loading, searching, results} = useCrossSearch(name, query, {icon, projectIds});
//確定時の処理
const onClick = useCallback(async (key, e) => {
e.stopPropagation();
// 修飾キーが押されていたら何もしない
if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return;
e.preventDefault();
await replaceLink(key);
cancel(); // 補完を終了する
}, [cancel]);
// UI用に加工する
const list = useMemo(
() => results
.map(({project, title}) => ({
key: `/${project}/${title}`,
href: `/${project}/${toLc(title)}`,
project,
onClick: e => onClick(internal ? title : icon ? `/${project}/${title}.icon` : `/${project}/${title}`, e),
})),
[results, query, internal]
);
// キー操作の有効かどうかのflag
const isEnableKeyboard =
useMemo(() => enable && open && list.length > 0, [enable, open, list.length]);
// 補完windowを開くかどうかのflag
const isOpen = useMemo(
() => enable && open && (list.length > 0 || loading || searching),
[enable, open, list.length, loading, searching]
);
// 項目選択
const {item, selectPrev, selectNext} = useSelect({
list,
defaultSelected: 0,
});
//確定時の処理
const confirm = useCallback(async () => {
await replaceLink(item.key);
cancel();
}, [item, cancel]);
const confirmIcon = useCallback(async () => {
await replaceLink(`${item.key}.icon`);
cancel();
}, [item, cancel]);
const [position, setPosition] = useState({top: 0, left: 0}); // popupの位置
// .cursorが.page-linkの中にいたら補完を開始する
const startCompletion = useCallback(async () => {
const {char} = caretPos();
const link = char?.closest?.('.page-link[type="link"]'); // hash tagは除外する
if (!link) {
cancel();
return;
}
// .cursor-lineになっているはずなので、[]で囲まれているとみなしていい
const text = link.textContent.slice(1, -1);
// 外部project link記法のみを対象とする
if (!filter.test(text)) return;
setOpen(true);
setQuery(text.slice(1));
// leftはlinkの左端に合わせる
const top = parseInt(scrapboxDOM.cursor.style.top);
const height = parseInt(scrapboxDOM.cursor.style.height);
const bottom = top + height;
const {left: eLeft} = scrapboxDOM.editor.getBoundingClientRect();
const left = link.getBoundingClientRect().left;
setPosition({top: bottom, left: Math.round(left - eLeft)});
}, [filter]);
useOnTextChange(startCompletion);
// 手動で補完を有効にする
const start = useCallback(async () => {
setEnable(true);
await startCompletion();
}, [startCompletion]);
return {
item,
list,
confirm,
confirmIcon,
start,
cancel,
isOpen,
isEnableKeyboard,
query,
loading,
searching,
selectPrev,
selectNext,
position,
}
}
script.jsimport {useEventListener} from '../use-event-listener/script.js';
import {useMutationObserver} from '../useMutationObserver/script.js';
import {throttle} from '../custom-throttle/script.js';
function useOnTextChange(callback, delay) {
const onInput = useMemo(() => throttle(callback, delay ?? 100), [callback, delay]);
const onKeyUp = useCallback(e => {
if (e.key !== 'Delete' && e.key !== "Backspace" && e.key !== 'Enter') return;
onInput(e);
}, [onInput]);
useEventListener(scrapboxDOM.textInput, 'input', onInput);
useEventListener(scrapboxDOM.textInput, 'keyup', onKeyUp); // 文字削除キーを検知する
useMutationObserver([{current: scrapboxDOM.textInput}], ([mutation]) => callback(mutation), {attributes: true, attributeFilter: ['style']});
}