updated
が新しい順 updated === 0
jstransform: `translateX(calc(${rect.width}px / 2 - 50%))`,
shdeno check --remote -r=https://scrapbox.io https://scrapbox.io/api/code/takker/選択範囲に対してwindowを表示するtest/App.tsx
App.tsx/// <reference no-default-lib="true" />
/// <reference lib="esnext" />
/// <reference lib="dom" />
/** @jsx h */
/** @jsxFrag Fragment */
import { Fragment, h, render } from "../preact/mod.tsx";
import { useCallback, useState, useMemo, useEffect, useRef } from "../preact/hooks.ts";
import { useSelection } from "./useSelection.ts";
import { useSearch } from "./useSearch.ts";
import { selections } from "../scrapbox-userscript-std/dom.ts";
export interface Options {
/** 表示する最大候補数
*
* @default 5
*/
limit?: number;
}
const App = ({ limit }: Options) => {
const { text, range } = useSelection();
const ref = useRef<HTMLDivElement>(null); // 座標計算用
const style = useMemo<h.JSX.CSSProperties>(() => {
// 一行だけ選択している時のみ表示する
if (text === "" || text.includes("\n") || !ref.current) {
return { display: "none" };
}
// 座標を取得する
const root = ref.current.parentNode;
if (!(root instanceof ShadowRoot)) {
throw Error(`The parent of "div.container" must be ShadowRoot`);
}
/** 基準座標 */
const parentRect = root.host?.parentElement?.getBoundingClientRect?.();
/** 選択範囲の座標 */
const rect = selections()?.lastElementChild?.getBoundingClientRect?.();
if (!rect || !parentRect) return { display: "none" };
return {
top: `${rect.bottom - parentRect.top}px`,
left: `${(rect.left - parentRect.left)}px`,
};
}, [text, range]); // 選択範囲のサイズ変更でも再計算するために、rangeを依存配列に加えている
const candidates = useSearch(text, limit ?? 5);
return (<>
<style>{`
.container {
position: absolute;
max-width: 80vw;
max-height: 80vh;
margin-top: 14px;
overflow-x: hidden;
overflow-y: auto;
z-index: 1000;
background-color: var(--dropdown-menu-bg, #fff);
color: var(--dropdown-menu-text-color, #333);
border: var(--dropdown-menu-border, 1px solid rgba(0,0,0,.15));
border-radius: 4px;
box-shadow: 0 6px 12px rgba(0,0,0,.175);
}
`}</style>
<div ref={ref} className="container" style={style}>
{candidates.map((title) => (
<div key={title} className="candidate" title={title}>{title}</div>
))}
</div>
</>);
};
const app = document.createElement("div");
const shadowRoot = app.attachShadow({ mode: "open" });
document.body.append(app);
render(<App limit={10} />, shadowRoot);
useSearch.tsimport { useMemo } from "../preact/hooks.ts";
import { toTitleLc, revertTitleLc } from "../scrapbox-userscript-std/dom.ts";
import { Asearch } from "../deno-asearch/mod.ts";
import type { Scrapbox } from "../scrapbox-jp%2Ftypes/userscript.ts";
declare const scrapbox: Scrapbox;
const getMaxDistance = [
0, // 空文字のとき
0, 0,
1, 1,
2, 2, 2, 2,
3, 3, 3, 3, 3, 3,
4, 4, 4, 4, 4, 4, 4, 4, 4, 4,
5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5,
6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6,
];
export const useSearch = (text: string, limit: number): string[] => useMemo(() => {
// 空白を`_`に置換して、空白一致できるようにする
const textLc = toTitleLc(text.replace(/\s+/g, " "));
const forwardMatch = Asearch(`${textLc} `).match;
const match = Asearch(` ${textLc} `).match;
const maxDistance = getMaxDistance[textLc.length];
const textIgnoreSpace = revertTitleLc(textLc).trim();
// 空白をワイルドカードとして検索する
// 検索文字列が空白を含むときのみ実行
const ignoreSpace = /\s/.test(text) ? {
forwardMatch: Asearch(`${textIgnoreSpace} `).match,
match: Asearch(` ${textIgnoreSpace} `).match,
distance: getMaxDistance[textIgnoreSpace.length],
} : undefined
return scrapbox.Project.pages
.flatMap((page) => {
// 空白一致検索
{
const result = forwardMatch(page.titleLc, maxDistance);
if (result.found) return [{
point: result.distance,
...page
}];
}
{
const result = match(page.titleLc, maxDistance);
if (result.found) return [{
point: result.distance + 0.25,
...page
}];
}
if (!ignoreSpace) return [];
// 空白をワイルドカードとして検索
{
const result = ignoreSpace.forwardMatch(page.title, ignoreSpace.distance);
if (result.found) return [{
point: result.distance + 0.5,
...page
}];
}
{
const result = ignoreSpace.match(page.title, ignoreSpace.distance);
if (result.found) return [{
point: result.distance + 0.75,
...page
}];
}
return [];
})
.sort((a,b) => {
// 1. 優先順位順
const diff = a.point - b.point;
if (diff !== 0) return diff;
// 2. 文字列が短い順
const ldiff = a.title.length - b.title.length
if (ldiff !== 0 ) return ldiff;
// 3. つながっているリンク順
if (a.exists !== b.exists) a.exists ? -1 : 1;
// 4. 更新日時が新しい順
return b.updated - a.updated;
})
.slice(0, limit)
.map((page) => page.title)
}, [text, limit, scrapbox.Project.pages]);
useSimpleSearch.tsimport { useMemo } from "../preact/hooks.ts";
import { toTitleLc } from "../scrapbox-userscript-std/dom.ts";
import type { Scrapbox } from "../scrapbox-jp%2Ftypes/userscript.ts";
declare const scrapbox: Scrapbox;
export const useSearch = (text: string, maxCandidates = 10): string[] => useMemo(() => {
const textLc = toTitleLc(text);
return scrapbox.Project.pages
.flatMap((page) => {
const index = page.titleLc.indexOf(textLc);
if (index < 0) return [];
return [{ index, ...page }];
})
.sort((a,b) => {
// 1. 一致した箇所が早い順
const diff = a.index - b.index;
if (diff !== 0) return diff;
// 2. 文字列が短い順
const ldiff = a.title.length - b.title.length
if (ldiff !== 0 ) return ldiff;
// 3. つながっているリンク順
if (a.exists !== b.exists) a.exists ? -1 : 1;
// 4. 更新日時が新しい順
return b.updated - a.updated;
})
.slice(0, maxCandidates)
.map((page) => page.title)
}, [text, maxCandidates, scrapbox.Project.pages]);
useSelection.tsimport { useState, useEffect } from "../preact/hooks.ts";
import { takeSelection, Range } from "../scrapbox-userscript-std/dom.ts";
export const useSelection = (): { text: string; range: Range; } => {
const [range, setRange] = useState<Range>({
start: { line: 0, char: 0 },
end: { line: 0, char: 0 }
});
const [text, setText] =useState("");
useEffect(() => {
const selection = takeSelection();
const update = () => {
setRange(selection.getRange());
setText(selection.getSelectedText());
};
selection.addChangeListener(update);
return () => selection.removeChangeListener(update);
}, []);
return { text, range } as const;
};