(feat) add infinite scroll

This commit is contained in:
polv committed 2026-02-28 20:48:53 +07:00
1 parent e3ccd517a8
commit 5dcda78b05
4 files changed
+124 -45

No files matched your search

+5
View File
@@ -39,3 +39,8 @@ body {
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
html {
height: 100vh;
overflow-y: hidden;
}
+4 -2
View File
@@ -7,8 +7,10 @@ import SearchPage from "./pages/SearchPage";
function App() {
return (
<Provider>
<SearchPage />
<LoadingDialog />
<>
<SearchPage />
<LoadingDialog />
</>
</Provider>
);
}
+40 -7
View File
@@ -2,19 +2,29 @@ import { createElement as h } from "react";
export default function Glossary({
glossary,
maxLength,
onTermClicked,
}: {
glossary: any;
maxLength?: number;
onTermClicked: (t: string) => void;
}) {
function trimString(s: any) {
if (!maxLength) return s;
if (typeof s === "string") {
return s.substring(0, maxLength);
}
return s;
}
// Plain string
if (typeof glossary === "string") {
return glossary;
return trimString(glossary);
}
// GlossaryText
if ("type" in glossary && glossary.type === "text") {
return glossary.text;
return trimString(glossary.text);
}
// GlossaryImage
@@ -26,7 +36,11 @@ export default function Glossary({
if ("type" in glossary && glossary.type === "structured-content") {
const sc = glossary;
return (
<StructuredContent node={sc.content} onTermClicked={onTermClicked} />
<StructuredContent
node={sc.content}
maxLength={maxLength}
onTermClicked={onTermClicked}
/>
);
}
@@ -58,22 +72,37 @@ function GlossaryImage(img: any) {
function StructuredContent({
node,
maxLength,
onTermClicked,
}: {
node: any;
maxLength?: number;
onTermClicked: (t: string) => void;
}): any {
if (!node) return null;
function trimString(s: any) {
if (!maxLength) return s;
if (typeof s === "string") {
return s.substring(0, maxLength);
}
return s;
}
if (typeof node === "string") {
return node;
return trimString(node);
}
if (Array.isArray(node)) {
return (
<>
{...node.map((n, i) => (
<StructuredContent key={i} node={n} onTermClicked={onTermClicked} />
<StructuredContent
key={i}
node={n}
maxLength={maxLength}
onTermClicked={onTermClicked}
/>
))}
</>
);
@@ -114,7 +143,11 @@ function StructuredContent({
}}
>
{content ? (
<StructuredContent node={content} onTermClicked={onTermClicked} />
<StructuredContent
node={content}
maxLength={maxLength}
onTermClicked={onTermClicked}
/>
) : null}
</a>
);
@@ -126,6 +159,6 @@ function StructuredContent({
return h(
tag,
{ style: block.style, title: block.title, open: block.open },
StructuredContent({ node: block.content, onTermClicked }),
StructuredContent({ node: block.content, maxLength, onTermClicked }),
);
}
+75 -36
View File
@@ -1,4 +1,10 @@
import { CompositionEventHandler, useEffect, useRef, useState } from "react";
import {
CompositionEventHandler,
UIEventHandler,
useEffect,
useRef,
useState,
} from "react";
import { toHiragana } from "wanakana";
import {
@@ -65,7 +71,7 @@ function SearchPage() {
);
}
async function runSearch() {
async function runSearch(isNew = true) {
const ender = /\p{Z}$/u.test(q) ? "" : "*";
const re = /\<(.+?)\>\[(.+?)\]/g;
@@ -73,35 +79,56 @@ function SearchPage() {
let qTerm = "";
let qReading = "";
q.trim()
.split(re)
.map((s, i) => {
switch (i % 3) {
case 1:
qTerm += s;
break;
case 2:
qReading += s;
break;
default:
qTerm += s;
qReading += s;
}
});
let norm_q = q.trim();
if (isAutoKana) {
norm_q = norm_q.replace(/n$/i, "ん").replace(/[a-z]$/i, "");
}
norm_q.split(re).map((s, i) => {
switch (i % 3) {
case 1:
qTerm += s;
break;
case 2:
qReading += s;
break;
default:
qTerm += s;
qReading += s;
}
});
qTerm += ender;
qReading += ender;
setEntries(
await invoke("search_yomitan", {
qTerm,
qReading,
limit: 10,
offset: 0,
}),
);
const result = await invoke<Entry[]>("search_yomitan", {
qTerm,
qReading,
limit: 10,
offset: isNew ? 0 : entries.length,
});
if (isNew) {
setEntries(result);
} else {
setEntries([...entries, ...result]);
}
}
const onEntriesScrollEnd: UIEventHandler = async ({ target }) => {
if (!(target instanceof HTMLElement)) return;
const { scrollHeight, scrollTop } = target;
if (scrollHeight - 20 > scrollTop + target.getBoundingClientRect().height)
return;
await runSearch(false);
setTimeout(() => {
target.scrollTop = scrollTop + 100;
}, 100);
};
function onSearchboxChange(q: string) {
if (isAutoKana) {
q = toHiragana(q, { useObsoleteKana: true, IMEMode: true });
@@ -167,7 +194,16 @@ function SearchPage() {
) : null;
return (
<Stack maxW={"1000px"} margin={"0.5em auto"}>
<Stack
style={{
margin: "0.5em auto",
maxWidth: "1000px",
maxHeight: "100vh",
display: "flex",
flexDirection: "column",
overflowY: "hidden",
}}
>
<form
lang={lang}
onSubmit={(e) => {
@@ -201,18 +237,20 @@ function SearchPage() {
</Switch.Root>
</div>
</form>
<Box as={"ol"} listStyleType={"number"} style={{ margin: "1em" }}>
<Box
as={"ol"}
listStyleType={"number"}
style={{ overflowY: "scroll", scrollbarWidth: "none" }}
onScrollEnd={onEntriesScrollEnd}
>
{entries.map(
({ term, reading, dict_title, glossary_json, ...it }, i) => (
<li key={i} lang={lang}>
<details>
<summary>
{term}
{reading ? ` (${reading})` : ""} 【{dict_title}】
</summary>
<li key={i} lang={lang} style={{ marginLeft: "1.5em" }}>
<div>
<Card.Root>
<Card.Header>
{term}
{reading ? ` (${reading})` : ""} 【{dict_title}】
{JSON.stringify(it, (_, v) => v || undefined)}
</Card.Header>
<Card.Body>
@@ -223,8 +261,9 @@ function SearchPage() {
whiteSpace: "pre-wrap",
}}
>
{JSON.parse(glossary_json).map((g: any) => (
{JSON.parse(glossary_json).map((g: any, i: number) => (
<Glossary
key={i}
glossary={g}
onTermClicked={(t) => setQ(t + " ")}
/>
@@ -232,7 +271,7 @@ function SearchPage() {
</div>
</Card.Body>
</Card.Root>
</details>
</div>
</li>
),
)}