generated at
external-completion@0.4.0
js
(async () => { await import('/api/code/programming-notes/external-completion@0.4.0/index.js'); })();
sh
deno 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.js
import {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 });

変更点

実装したいこととか
fail現在プロジェクトを補完ソースから除く
これはuser側でproject idsを指定するときに各自で除外してほしい
初回補完時に固まるのをなんとかしたい
databaseの初期化処理をweb workerに逃せばいい
worker codeをbundleできるようにしたい
esbuildでformatをiifeにすればいけそう
設定を分離したい
key bind
補完の挙動
リンク内部にカーソルが入ると即座に検索が開始されるのはそのままにする
いちいち文字を入力しなくても、そのリンクに関連するリンクがsuggestされるので便利
iconも補完できるようにしたい
とりあえずhookとかcomponentを整理したいな
数式をpreviewするUserScriptと構造が似てるんだよなあ
component以外の部分を一つのhookにまとめてしまおう
useCompletion にする
keyboard操作は抜く
use-cross-search@0.1.0も含める?
具体的な検索機能以外を一旦分離させても良い気がする
数式をpreviewするUserScriptとも併用できるし
いや、無理そう
loading searching が密に関わっている
とりあえずkeyboard以外をまとめてみるか。
done open enable の役割が逆な気がする
まあいいか

既知の問題
done検索結果の反映に何故かタイムラグが有る
renderが走っていない?
use-cross-search@0.1.0の問題か、external-completion@0.4.0の問題かもすらわからない
19:51:22 external-completion@0.4.0の問題だった
external-completion@0.4.0#60cdcdf41280f000008f04e5 query に一致するタイトルの補完候補を削っていた
ここで、違うprojectの同名リンクも削ってしまっていた
違うprojectに同名リンクがあることは知りたいので、削っちゃダメ
そもそもこの機能いらないな。消そう。
もともと補完候補に自分のprojectが混じっていたときに、それを除外する設定として入れた
しかし自分のprojectを入れるかどうかはexternal-completion@0.4.0の責務ではない
使う人の設定に任せる
done補完候補をリンクとしてdrag & dropしたり新しいタブで開いたりできなくなった
mobileだと文字入力を認識できない?
input eventが送られていないような挙動をする
keyup eventは正常に動いていた
そのうちremote debugして確かめる
donecursorがリンクの外に出ても、文字を入力しない限りWindowが閉じない
use-cursor-observerをやめた弊害だ
text-inputを監視すれば直るだろうか
mobileでクリックがなんか変
入力先の行が見えている状態でクリックしないと補完が確定しない

2021-06-27
04:24:14 update to dropdown-container@0.1.1
2021-06-25
09:09:51 カーソルが [] の外に出たら補完を取りやめる
03:04:41 emoji-completionの候補をクリックしたときにアイコン記法で挿入できていなかった
02:03:22 emoji-completion [] 用入力補完 InternalCompletion を実装した
やったこと
共通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> で補完を手動で開始できるようにした
勿論補完できなければ何もしない
見たことないエラーだ
原因はわかった
.cursor のrenderが終わる前にscrapbox-cursor-position-2 position() を呼び出してしまったため、 style.top などが NaN と判定されてしまった
23:35:27 直した
22:58:12 use-cursor-observer#60cf48891280f00000e0d2f3を当てて様子見する
↓のバグ発生条件がわからないことには対策のしようがない
とりあえずこのまま使ってみる
smartphoneでもクリックできることは確認した
バグるかどうかまでは確認できていない
2021-06-24 21:48:18 以前一部の項目がバグる
22:17:00 クリック操作がやはりうまく行かない
通常クリック
あるリンクだけクリックするとそのページに飛んでしまう
event listenerを外すと、時々クリックしただけでwindowが消えてしまう
修飾キーつきクリック
問題なさそう
drag & drop
うごかない
原因
<a> をクリックすると .cursor の形状が変わる
形状が変わると座標の当たり判定が変わる
結果 .page-link 中にcursorがないと判定されて、windowが閉じてしまう
対策
click eventに戻して、代わりにMutationObserverによるcursorの位置更新をその間だけ停止するようにすれば解決するかもしれない
コードがより煩雑にならないか?takker
#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 直っていなかった
scrapbox-access-nodes@0.1.0を呼び出すところでエラーが時々出る
エラーが出ないときもある
その時はそもそも <a> のclick eventが発生していない
21:47:23 直した
click が発生する前にMutationObserver .cursor の座標移動を検知してしまい、先にwindowを閉じてしまっているのではないかと推測した
なのでclickの前に発生するpointerdownの発火をclick代わりに使ってみたらうまく行った
ただ今度はリンクのドラッグや別タブで開く操作ができなくなってしまった
21:09:28 (WIP) クリック操作で候補を確定できていなかったのを直す
まだうまく行っていない
押せたり押せなかったりする
20:01:59 external-completion@0.4.0#60cf1e961280f00000e0d215でUI用リストの生成処理まで削ってしまっていた
19:56:55 refactor: Hookの順序を入れ替えた
19:55:19 検索語句と同名のリンクの除外処理を削った
19:16:41 dropdown-container@0.1.0の配色を決めた
19:05:44 検索中の時、検索語句を表示するようにした
19:01:03 補完候補がないときはwindowを閉じる
これでリンクの ] の右側にカーソルが置いてある状態で Enter できない問題をだいたい解決できた
2021-06-19
22:56:53 外部プロジェクトのリンクのみ補完するようにした
21:05:44 Componentに表示する文字列やパスを修正
20:54:25 何故か results が配列ではないと言われる
具体的にどんな値になっているのか調べる
11:59:00 <C-Space> で補完の有効無効を切り替えられるようにした
<Esc> は止めた
11:58:17 external-completion@0.4.0/replaceLinkに切り出した
10:48:45 query と同じ候補は除く
候補がなかった場合との区別がつかないか
除かない代わりに、同じ候補しかなかった場合はwindowを表示しないようにしたほうがいいかな
候補がなかったら Not found と表示する
別に区別を付けなくてもいいか
09:48:52 入力確定処理を実装する
/takker/scrapbox-motion-emulationを移植してもいいんだけど、正直めんどくさいな……
scrapbox-keyboard-emulationscrapbox-insert-text-2を組み合わせて自前で作ってしまうか
10:39:44 実装完了
一部のケースだけだがテストもできた
09:21:57 use-search@0.1.0を使ってとりあえずテストしてみる
09:30:10 テスト開始
09:33:06 open とは別に開閉可能かどうかを示す enable を追加する必要がある
でないと <C-Space> で一瞬windowが表示されてしまう
windowが閉じていてもuse-keyboard@0.1.0にキー操作を奪われている
{enable: open} が原因か
{enable: enable && open} にしよう
09:46:02 直った

dependencies
script.js
import {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.js
import {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.js
import {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']}); }