scrapbox-incremental-fulltext-search
特徴
https://gyazo.com/00686304ccf5d57dba1cf2637eb2f748
矢印キー、Tabで項目選択&閲覧
https://gyazo.com/e16f397e37a618c5f34ce09dc1339d8a
簡易横断検索
project名の入力欄上でマウスホイールを動かすと、projectが切り替わって自動的に再検索が走る project名の入力欄にfocusが当たっているときは、矢印キーやTabでも切り替えられる
https://gyazo.com/22593ef500f26d09bacc1197cc128ef8
探したいprojectを絞り込む
project名の入力欄で、検索したいprojectをあいまい検索できる
URLに使われるproject nameと表示名のどちらでも検索できる
https://gyazo.com/a24561ea8f0d76ba35ae36d266df032a
参加しているprojectから検索し、見つかったprojectだけを候補に残す
https://gyazo.com/f59a7ca667f2779351628084f6a5f12e
Search besides watch listにチェックを入れると、watch listにある全てのprojectに対して横断検索を実行する
https://gyazo.com/74187bf20b5d99e1e75f3f8f537ba265
どのくらい実行したら怒られるんでしょうかyosider.icon
そのくらいですねtakker.icon
watch listが100未満であれば2回APIを叩くだけで済むので、もう少し実行できるようになります
間隔は1時間くらい間隔を開ければまあ大丈夫でしょう
install
お試し
code:js
import('/api/code/programming-notes/scrapbox-incremental-fulltext-search/sample.js');
自分のprojectにコピペする時
code:sh
Preactをbundleしたくない時
code:sh
実装
UI
backgroundを押すか<Esc>を押すと終了する
入力欄の隣に、検索先projectを指定できる窓を用意する
検索方法
入力欄が更新されるたびに検索する
既知の問題
2021-06-25
02:30:47 色を当て忘れていた箇所があった
2021-06-24
09:42:32 項目をhoverした時の背景色を設定していなかった
初期projectの設定ができていないという問題はある
検索はできている
どのprojectなのか表示されないだけ
2021-06-08
15:31:04
どの要素にfocusが当たっていても、<Esc>でformを閉じれるようにした
refactor: componentの条件分岐を簡略化した
2021-06-04
Error messageを表示する
19:31:47 横断検索の範囲を参加しているprojectのみに絞れるようにした
2021-06-01
16:54:31 横断検索できるようにした
16:40:10 横断検索するHookを作った
watch listの検索がうまく行かない……
16:41:27 qを入れてなかった
入れたら直った
16:45:49
15:55:41 全文検索をCustom Hookに切り出した
検索機能は未実装
何らかのフラグの変更をuseEffect()で検知して検索するようにしようと思う
14:28:28 自分の参加しているprojectとwatch Listとで分けた
02:56:22 .infoの文字色を変えた
見にくかったので
02:49:14 handleKeydown()を.containerに移した
2021-05-31
14:28:36
読み込み中か検索件数かのどちらかのみを表示する
文字色を変えた
00:41:05 <style>を<App>の外に出した
00:37:10 検索件数が0のときは<ul>を消す
既知の問題
/icons/done.iconスタイルがずれてる?
https://gyazo.com/4363371e372c16dff8b493bad8dcb4ef
width: inherit;にしたら直った
例外処理を作っていない
1回の横断検索で、複数回APIを叩いているから、5,6回ボタンを押すだけですぐ上限に達してしまう
横断検索の範囲を絞ればいいんだろうけど、いちいち範囲選択するのはめんどくさい
code:json
{
"name": "UpdatingSearchServerError",
"message": "Updating search server. Please try again later."
}
少し待ってから検索したほうが良さそう
formを閉じにくい
画面が広いときは<Backgroud>を押せる余白が広いから良いけど
画面が狭いときやmobile端末だと<Background>が殆どないので閉じにくい
解決策:閉じるボタンを用意する
実装したいこと
/icons/done.icon横断検索
案1
最後までscroll or 検索件数が少ないときに自動実行
検索リストに加える
押すと検索先projectを押したやつに変える
(採用)案2
横断検索するボタンを用意する
自動では検索しない
APIの実行上限を超えてしまう
projectを上下キーで変えると、必ず1件以上の検索結果が返ってくる
キーワードに引っかかるprojectを手動で探さずに済む
検索語句が変わったらリセット
案3
検索実行後、1秒間検索語句に変化がなければ横断検索を実行する
project nameとproject display nameのどちらからでも検索できるようにする
曖昧検索で絞り込む
上下キーで選択肢iを変えられる
/icons/done.iconwheelでも変えられる /icons/done.icon自分が所属しているprojectが前の方に来るようにsort方法を変える
参加しているprojectを辞書順に並べた後、参加していないprojectをまた辞書順に並べる
2021-06-01 17:05:23 辞書順に並べるのは面倒なのでやっていない
/icons/fail.iconprojectの更新日時順に並べる?
最新情報がほしいわけではないのでいらない
/icons/done.icon新しいタブでページを開いたときは検索windowを閉じないようにする
meta keysを押しながら開いた時
a[target="_blank"]を押した時
context menuから開くのには対応しなくていい
そもそもclickとみなされないから検索windowが消えることはない
/icons/done.icon参加しているprojectだけを対象に横断検索するボタンを用意したい
check boxでもいいか
/icons/done.iconAPI rate limitに到達したなどでエラーが発生した時の処理を追加したい
開いているページのタイトルをデフォルトで入力しておく?
選択状態にしておいて、上書きできるようにしておく
APIが違うので特別な処理をかませる必要がありますが、できなくはないですねtakker.icon
いや、そんなに難しくもなさそう
こちらはあいまい検索で全てのprojectから絞り込めるという強力なメリットがある
code:sample.js
import {mount} from './script.js';
import {getProjectInfo} from '../scrapboxのproject情報を一括して取得するUserScript/script.js';
(async () => {
// watch listからもとってくる
const watchListIds = Object.keys(JSON.parse(localStorage.getItem('projectsLastAccessed')));
.map(({id, name, displayName}) => ({id, name, displayName}))
.sort((a, b) => a.displayName.localeCompare(b.displayName));
mount(watchList);
})();
dependencies
code:script.js
import {html, render} from '../htm@3.0.4%2Fpreact/script.js';
import {throttle} from '../custom-throttle/script.js';
import {FuzzySelect} from '../fuzzy-select-menu@0.1.0/script.js';
import {useState, useMemo, useEffect} from '../preact@10.5.13/hooks.js';
import {useLoader} from '../use-loader/script.js';
import {Background, CSS as BackgroundCSS} from '../modal-background@0.1.0/script.js';
import {ProjectSelector} from './selector.js';
import {ErrorMsg, CSS as ErrorCSS} from './error.js';
import {toLc} from '../scrapbox-titleLc/script.js';
const App = ({watchList}) => {
// ボタンの状態から、横断検索の開始を判定する
const queryForProject = useMemo(() => disabled ? query : '', disabled, query); const {loading, items} = useSearch({project, query}); // 全文検索する
const {searching, error, projects} = useProjectSearch({ // projectを横断検索する
query: queryForProject,
watchList: watchList,
includeWatchList,
});
// FuzzySelect用にデータを加工する
const list = useMemo(() =>
projects.map(({id, name, displayName}) => ({key: name, text: displayName})),
// 横断検索のAPI limitに引っかかっているときはボタンを無効化する
useEffect(() => error && setDisabled(true), error); // 入力欄の値を反映する
const onProjectChange = ({key}) => setProject(key);
const onInput = ({target: {value}}) => {
setDisabled(false);
setQuery(value);
};
// componentを閉じる
const close = () => setOpen(false);
const handleKeydown = ({key}) => {
if (key !== 'Escape') return;
close();
}
const onClick = ({ctrlKey, shiftKey, altKey, metaKey, target}) => {
if (target.target === '_blank' || ctrlKey || shiftKey || altKey || metaKey) return;
close();
};
// project横断検索を開始する
const onFilter = () => {
setDisabled(true);
}
// Page Menuから操作する
useEffect(() => scrapbox.PageMenu.addItem({
title: 'Fulltext Search',
onClick: () => setOpen(true),
}), []);
return open && html`
<${Background} onClose="${close}"/>
<div class="container" onKeydownCapture="${handleKeydown}">
<div class="search-form">
<${FuzzySelect}
list="${list}"
convert="${({key, text}) => ${key} ${text}}"
onSelect="${onProjectChange}" />
<input type="text" value="${query}" onInput="${onInput}" />
<button type="button" onClick="${onFilter}" disabled="${disabled}">
${disabled ?
${searching ? 'Searching...: ' : ''}Found ${projects.length} projects :
'Search for all projects'
}
</button>
<input
type="checkbox"
value="${!includeWatchList}"
onChange="${({target}) => setIncludeWatchList(target.value)}" />
<label>Search besides watch list</label>
<span class="info">
${loading ? Searching for ${query}... : ${items.length} results}
</span>
<${ErrorMsg} error="${error}" />
</div>
${items.length > 0 && html`
<ul class="dropdown">
${items.map(item => html`<li key="${item.title}">
<a href="/${item.project}/${toLc(item.title)}"
target="${item.project === scrapbox.Project.name ? '' : '_blank'}"
rel="${item.project === scrapbox.Project.name ? 'route' : 'noopener noreferrer'}"
onClick="${onClick}">
${item.title}
<div class="description">
${item.lines.map(line => html<span>${line}</span>)}
</div>
</a>
</li>`)}
</ul>
`}
</div>`;
};
Custom Hooks
全文検索するHook
code:script.js
function useSearch({project, query}) {
const search = useMemo(() => throttle(async (_project, _query) => {
if (_query === '' || _project === '') {
setItems([]);
return;
}
try {
const res = await fetch(/api/pages/${_project}/search/query?q=${encodeURIComponent(_query)});
const {pages} = await res.json();
setItems(pages.map(({title, words, lines}) => ({project: _project, title, words, lines})));
} catch(e) {
console.error(e);
setItems([]);
}
}, 500, {immediate: false}), []);
const {loading} = useLoader(
async () => await search(project, query),
{delay: 1500},
);
return {loading, items};
}
code:script.js
function useProjectSearch({query, watchList: _watchList, includeWatchList}) {
const watchList = useMemo(() => { // watchListから参加しているprojectを予め除いておく
const ids = joinedList.map(({id}) => id);
return _watchList.filter(({id}) => !ids.some(_id => _id === id));
// project listを初期化する
useEffect(() => (async () => {
// 参加しているprojectを取得する
const res = await fetch('/api/projects');
if (!res.ok) return [];
const json = await res.json();
setJoinedList(json.projects?.map?.
(({id, name, displayName}) => ({id, name, displayName})) ?? []);
})(), []);
// projectを横断検索する
useEffect(() => (async () => {
setError(undefined);
// 検索語句が空なら、defaultのproject listを返す
if (query === '') {
return;
}
setSearching(true);
setProjects([]); // 一旦クリア
try {
// 参加しているprojectから検索する
{
const res = await fetch(/api/projects/search/query?q=${query});
const json = await res.json();
if (!res.ok) throw Error(json.message);
setProjects(json.projects);
}
// watch listから検索する
if (includeWatchList) {
// 100件ずつ検索する
const chunkNum = Math.floor(watchList.length / 100) + 1;
for (let index = 0; index < chunkNum; index++) {
const params = new URLSearchParams();
params.append('q', query);
watchList.slice(index * 100, 100 + index * 100)
.forEach(({id}) => params.append('ids', id));
const res = await fetch(/api/projects/search/watch-list?${params.toString()});
const json = await res.json();
if (!res.ok) throw Error(json.message);
}
}
} catch(e) {
setError(e.toString());
} finally {
setSearching(false);
}
})(),
);
return {searching, error, projects};
}
CSS
code:script.js
const CSS = `
:host {
--dropdown-text-color: var(--incremental-fulltext-search-text-color, #333); --dropdown-bg: var(--incremental-fulltext-search-result-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(--incremental-fulltext-search-hover-text-color, #333); --dropdown-item-hover-bg: var(--incremental-fulltext-search-result-hover-bg, #f5f5f5); }
.container {
display: block;
position: fixed;
width: calc(100% - 20px);
top: 5vh;
left: 10px;
color: var(--incremental-fulltext-search-text-color, #4a4a4a); z-index: 90000;
}
span {
margin-right: .5em;
}
.search-form {
width: inherit;
border-radius: 5px;
padding: 0 10px;
border: transparent;
box-shadow: none;
font-size: 14px;
color: var(--search-form-text-color, rgba(255,255,255,0.35));
background-color: var(--search-form-bg, rgba(255,255,255,0.15));
}
.info {
display: block;
}
.dropdown {
max-height: 80vh;
flex-direction: column;
width: 100%;
padding: 5px 0;
margin: 2px 0 0;
list-style: none;
font-size: 14px;
font-weight: normal;
line-height: 28px;
text-align: left;
border: 1px solid rgba(0,0,0,0.15);
border-radius: 4px;
background-clip: padding-box;
background-color: var(--incremental-fulltext-search-result-bg, #fefefe); white-space: nowrap;
overflow-x: hidden;
overflow-y: auto;
text-overflow: ellipsis;
}
a {
display: block;
padding: 3px 20px;
clear: both;
align-items: center;
font-weight:normal;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
text-decoration: none;
text-overflow: ellipsis;
color: var(--incremental-fulltext-search-text-color, #262626); background-color: var(--incremental-fulltext-search-result-bg, #f5f5f5); }
a:hover {
text-decoration: none;
color: var(--incremental-fulltext-search-hover-text-color, #262626); background-color: var(--incremental-fulltext-search-result-hover-bg, #f5f5f5); }
a:focus {
color: var(--incremental-fulltext-search-hover-text-color, #262626); background-color: var(--incremental-fulltext-search-result-hover-bg, #f5f5f5); outline: 0;
box-shadow: 0 0px 0px 3px rgba(102,175,233,0.6);
transition: border-color ease-in-out 0.15s,box-shadow ease-in-out 0.15s;
}
.description {
display: block;
margin-top: 0.5em;
color: var(--incremental-fulltext-search-description-text-color, gray);
font-size: 12px;
line-height: 14px;
max-height: 28px;
overflow: hidden;
text-overflow: ellipsis;
}
${BackgroundCSS}
${ErrorCSS}
`;
Renderingする
code:script.js
export function mount(watchList) {
const app = document.createElement('div');
app.dataset.userscriptName = 'incremental-fulltext-search-form';
app.attachShadow({mode: 'open'});
document.body.append(app);
render(html`
<style>
:host {
--incremental-fulltext-search-text-color: var(--page-text-color, #4a4a4a); --incremental-fulltext-search-hover-text-color: var(--page-text-color, #4a4a4a); --incremental-fulltext-search-description-text-color: var(--card-description-color, gray);
--incremental-fulltext-search-result-bg: var(--page-bg, #fefefe); --incremental-fulltext-search-result-hover-bg: var(--card-hover-bg, #f5f5f5); }
${CSS}
</style>
<${App} watchList="${watchList}"/>
`, app.shadowRoot);
}
Components
(deprecated)project選択用select box
code:selector.js
import {html} from '../htm@3.0.4%2Fpreact/script.js';
import {useRef, useCallback} from '../preact@10.5.13/hooks.js';
export const ProjectSelector = ({projects, selectedProject, onSelect}) => {
const ref = useRef(null);
const handleWheel = useCallback(e => {
e.preventDefault();
e.stopPropagation();
ref.current.selectedIndex = e.deltaY < 0 ?
Math.max(ref.current.selectedIndex - 1, 0) :
Math.min(ref.current.selectedIndex + 1, ref.current.length);
onSelect?.(ref.current.value);
}, []);
return html`
<select ref="${ref}" value="${selectedProject}" onChange="${({target: {value}}) => onSelect(value)}" onWheel="${handleWheel}">
${projects.map(project => html<option key="${project.id}" value="${project.name}">${project.displayName}</option>)}
</select>
`;
};
error message表示欄
code:error.js
import {html} from '../htm@3.0.4%2Fpreact/script.js';
export const ErrorMsg = ({error}) => error && html`
<div class="error">${error}</div>
`;
export const CSS = `
.error {
display: block;
padding: 15px;
margin: 20px;
text-align: center;
}
.error::before {
font: normal normal normal 14px/1 FontAwesome;
content: '\f071';
margin-right: .3em;
}
`;
(WIP)検索して見つかった文字を強調できるやつ
code:description.js
import {useState, useMemo, useEffect} from '../preact@10.5.13/hooks.js';
export const Description = ({text, words}) => {
}
Qiita.icon
Deno.icon