foo bar baz
は文字列長11なので3文字まで許容される foo
がどこにも含まれていない文字列もmatchしてしまうmain.tsimport { mount } from "./App.tsx";
mount();
$ deno check --remote -r=https://scrapbox.io https://scrapbox.io/api/code/takker/project補完テスト/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,
useMemo,
useEffect,
useState,
} from "../preact/hooks.ts";
import { Asearch, MatchResult } from "../deno-asearch/mod.ts";
import { getMaxDistance } from "./distance.ts";
import { makeSource } from "./makeSource.ts";
export const mount = () => {
const app = document.createElement("div");
const shadowRoot = app.attachShadow({ mode: "open" });
document.body.append(app);
remove = () => app.remove();
render(<App />, shadowRoot);
};
let remove: () => void;
const App = () => {
const [pattern_, setPattern] = useState("");
const [data, setData] = useState<{ name: string, displayName: string }[]>([]);
useEffect(() => {
(async () => {
for await (const candidates of makeSource()) {
setData((prev) => [...prev, ...candidates]);
}
})();
}, []);
const candidates = useMemo(
() => {
const pattern = pattern_.trim().replace(/^\//, "").replace(/\/$/, "");
if (pattern.length === 0) return [];
const match = Asearch(`${pattern} `).match;
const maxDistance = getMaxDistance[
Math.max(...pattern.split(/\s+/).map((word) => word.trim().length))
];
return data.flatMap(
({ name, displayName }) => {
const result1 = match(name, maxDistance);
const result2 = match(displayName, maxDistance);
if (!result1.found) {
if (!result2.found) return [];
return [{ candidate: displayName, distance: result2.distance }];
}
if (!result2.found) return [{ candidate: name, distance: result1.distance }];
return result1.distance > result2.distance
? [{ candidate: displayName, distance: result2.distance }]
: [{ candidate: name, distance: result1.distance }];
}
)
// 1. 編集距離 2. 文字列超 3. 辞書順序 が小さい順に並び替える
.sort((a, b) => {
const diff = a.distance - b.distance;
if (diff !== 0) return diff;
const lenDiff = a.candidate.length - b.candidate.length;
if (lenDiff !== 0) return lenDiff;
return a.candidate.localeCompare(b.candidate);
});
},
[pattern_, data],
);
const handlePattern = useCallback(
(e: h.JSX.TargetedEvent<HTMLInputElement>) =>
setPattern(e.currentTarget.value),
[],
);
return (
<>
<style>
{`
:host {
position: fixed;
top: 60px;
left: 50%;
transform: translate(-50%, 0);
padding: 5px;
border: 1px solid lime;
border-radius: 5px;
font-size: 14px;
background-color: var(--page-bg);
color: var(--page-text-color);
}
input {
min-width: 40%;
}
button {
position: absolute;
top: 0px;
right: 0px;
}
`}
</style>
<button onClick={remove}>x</button>
<p>
<label>
pattern: <input type="text" value={pattern_} onInput={handlePattern} />
</label>
</p>
<p>
{candidates.length > 0 ? `Matched ${candidates.length} words` : "No matched"}
<br />
<ul>
{candidates.map(({ candidate }) => (<li key={candidate}>{candidate}</li>))}
</ul>
</p>
</>
);
};
distance.tsexport 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,
];
makeSource.tsimport { listProjects } from "../scrapbox-userscript-std/rest.ts";
export async function* makeSource(): AsyncGenerator<{ name: string, displayName: string }[]> {
const watchList = JSON.parse(localStorage.getItem("projectsLastAccessed") ?? "{}");
const chunk = 50;
const ids = [...Object.keys(watchList)];
const yielded = new Set<string>();
for (let i = 0; i <= Math.floor(ids.length / chunk); i++) {
const res = await listProjects(ids.slice(i * chunk, (i + 1) * chunk));
if (!res.ok) continue;
yield res.value.projects.flatMap((project) => {
if (yielded.has(project.name)) return [];
yielded.add(project.name);
return [project];
});
}
}