generated at
選択範囲に対してwindowを表示するtest
2022-08-28
23:51:16 いい感じになったので、選択範囲に似ているリンクを入力補完するUserScriptにする
23:38:52 空白一致を優先して出すようにした
23:12:50 あいまい検索に切り替えた
前方一致したものを先に出す

22:56:28 hookを切り出した
22:48:18 並び替えを修正した
リンク入力補完 (scrapbox)の結果とも、だいたい一致するようになった

22:25:05 選択範囲中の文字列でscrapbox.Project.pagesを検索して候補に表示してみた
並び替えがイマイチだな
screenshotの並び替え
1. 一致した箇所が早い順
2. 更新日時 updated が新しい順
3. 文字列長が短い順
2.が3.の前にあるせいで、中身のあるページが中身のないページより優先して表示されてしまう
中身のないは常に updated === 0
2.と3.を逆にしよう
かつ空リンクの優先度を低くする
↓の位置だと見づらくなったので、元に戻した
22:01:07 位置調節
選択範囲の真ん中当たりにwindowがくるようにした
js
transform: `translateX(calc(${rect.width}px / 2 - 50%))`,
21:54:17 1行だけ選択しているときのみ表示 & 隙間を14px開ける
popup menuの隙間と同じ量
21:44:05 Scrapboxの選択範囲のすぐ下に、dropdown_menu_(scrapbox)っぽいものを表示させてみた

sh
deno 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);

検索hook
deno-asearchによるあいまい検索
前方あいまい一致と任意位置あいまい一致とで、優先順位を変える
useSearch.ts
import { 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.ts
import { 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]);

選択範囲を取得するhook
useSelection.ts
import { 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; };

#2022-08-28 20:49:38