Skip to content

Commit d6464d1

Browse files
author
bealqiu
committed
refactor(core): 实现跨平台适配架构
1 parent adee10a commit d6464d1

16 files changed

Lines changed: 324 additions & 89 deletions

File tree

packages/app-mobile/src/components/settings/AppearanceSettings.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,12 @@ export function AppearanceSettings() {
3131
}, [theme]);
3232

3333
const handleLangChange = useCallback(
34-
(code: string) => {
34+
async (code: string) => {
3535
setLang(code);
36-
i18n.changeLanguage(code);
37-
localStorage.setItem("readany-lang", code);
36+
const { changeAndPersistLanguage } = await import("@readany/core/i18n");
37+
await changeAndPersistLanguage(code);
3838
},
39-
[i18n],
39+
[],
4040
);
4141

4242
return (

packages/app-mobile/src/lib/platform/mobile-platform-service.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,4 +173,54 @@ export class MobilePlatformService implements IPlatformService {
173173
async installUpdate() {
174174
// noop on mobile
175175
}
176+
177+
// ---- KV Storage (backed by localStorage in Tauri mobile WebView) ----
178+
179+
async kvGetItem(key: string): Promise<string | null> {
180+
return localStorage.getItem(key);
181+
}
182+
183+
async kvSetItem(key: string, value: string): Promise<void> {
184+
localStorage.setItem(key, value);
185+
}
186+
187+
async kvRemoveItem(key: string): Promise<void> {
188+
localStorage.removeItem(key);
189+
}
190+
191+
async kvGetAllKeys(): Promise<string[]> {
192+
const keys: string[] = [];
193+
for (let i = 0; i < localStorage.length; i++) {
194+
const key = localStorage.key(i);
195+
if (key) keys.push(key);
196+
}
197+
return keys;
198+
}
199+
200+
// ---- Clipboard ----
201+
202+
async copyToClipboard(content: string): Promise<void> {
203+
await navigator.clipboard.writeText(content);
204+
}
205+
206+
// ---- File sharing / download ----
207+
208+
async shareOrDownloadFile(
209+
content: string,
210+
filename: string,
211+
mimeType: string,
212+
): Promise<void> {
213+
const blob = new Blob([content], { type: `${mimeType};charset=utf-8` });
214+
const url = URL.createObjectURL(blob);
215+
const a = document.createElement("a");
216+
a.href = url;
217+
a.download = filename;
218+
a.style.display = "none";
219+
document.body.appendChild(a);
220+
a.click();
221+
setTimeout(() => {
222+
document.body.removeChild(a);
223+
URL.revokeObjectURL(url);
224+
}, 100);
225+
}
176226
}

packages/app-mobile/src/main.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { StrictMode } from "react";
55
import { createRoot } from "react-dom/client";
66
import App from "./App";
77
import "@readany/core/i18n";
8+
import { initI18nLanguage } from "@readany/core/i18n";
89
import "./styles/globals.css";
910
import { setPlatformService } from "@readany/core/services";
1011
import { MobilePlatformService } from "./lib/platform/mobile-platform-service";
@@ -14,6 +15,9 @@ const mobilePlatform = new MobilePlatformService();
1415
mobilePlatform.initSync().catch(console.error);
1516
setPlatformService(mobilePlatform);
1617

18+
// Restore saved language from platform KV storage
19+
initI18nLanguage().catch(console.error);
20+
1721
createRoot(document.getElementById("root")!).render(
1822
<StrictMode>
1923
<App />

packages/app/src/components/settings/GeneralSettings.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@ import { useTranslation } from "react-i18next";
1313
export function GeneralSettings() {
1414
const { t, i18n } = useTranslation();
1515

16-
const handleLanguageChange = (lang: string) => {
17-
i18n.changeLanguage(lang);
18-
localStorage.setItem("readany-lang", lang);
16+
const handleLanguageChange = async (lang: string) => {
17+
const { changeAndPersistLanguage } = await import("@readany/core/i18n");
18+
await changeAndPersistLanguage(lang);
1919
};
2020

2121
return (

packages/app/src/lib/platform/tauri-platform-service.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,4 +193,54 @@ export class TauriPlatformService implements IPlatformService {
193193
await relaunch();
194194
}
195195
}
196+
197+
// ---- KV Storage (backed by localStorage on desktop/web) ----
198+
199+
async kvGetItem(key: string): Promise<string | null> {
200+
return localStorage.getItem(key);
201+
}
202+
203+
async kvSetItem(key: string, value: string): Promise<void> {
204+
localStorage.setItem(key, value);
205+
}
206+
207+
async kvRemoveItem(key: string): Promise<void> {
208+
localStorage.removeItem(key);
209+
}
210+
211+
async kvGetAllKeys(): Promise<string[]> {
212+
const keys: string[] = [];
213+
for (let i = 0; i < localStorage.length; i++) {
214+
const key = localStorage.key(i);
215+
if (key) keys.push(key);
216+
}
217+
return keys;
218+
}
219+
220+
// ---- Clipboard ----
221+
222+
async copyToClipboard(content: string): Promise<void> {
223+
await navigator.clipboard.writeText(content);
224+
}
225+
226+
// ---- File sharing / download ----
227+
228+
async shareOrDownloadFile(
229+
content: string,
230+
filename: string,
231+
mimeType: string,
232+
): Promise<void> {
233+
const blob = new Blob([content], { type: `${mimeType};charset=utf-8` });
234+
const url = URL.createObjectURL(blob);
235+
const a = document.createElement("a");
236+
a.href = url;
237+
a.download = filename;
238+
a.style.display = "none";
239+
document.body.appendChild(a);
240+
a.click();
241+
setTimeout(() => {
242+
document.body.removeChild(a);
243+
URL.revokeObjectURL(url);
244+
}, 100);
245+
}
196246
}

packages/app/src/main.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { StrictMode } from "react";
55
import { createRoot } from "react-dom/client";
66
import App from "./App";
77
import "@readany/core/i18n";
8+
import { initI18nLanguage } from "@readany/core/i18n";
89
import "./styles/globals.css";
910
import { useLibraryStore } from "./stores/library-store";
1011
import { flushAllWrites } from "./stores/persist";
@@ -16,6 +17,9 @@ const tauriPlatform = new TauriPlatformService();
1617
tauriPlatform.initSync().catch(console.error);
1718
setPlatformService(tauriPlatform);
1819

20+
// Restore saved language from platform KV storage
21+
initI18nLanguage().catch(console.error);
22+
1923
// Flush pending state writes before window closes
2024
window.addEventListener("beforeunload", () => {
2125
flushAllWrites();

packages/core/src/export/annotation-exporter.ts

Lines changed: 10 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
/**
22
* Annotation Exporter — export highlights and notes in multiple formats
33
* Supports: Markdown, JSON, Obsidian (with frontmatter), Notion (clipboard-friendly)
4+
*
5+
* Cross-platform: uses IPlatformService for file download and clipboard operations.
46
*/
57
import type { Book, Highlight, Note } from "../types";
8+
import { getPlatformService } from "../services/platform";
69

710
export type ExportFormat = "markdown" | "json" | "obsidian" | "notion";
811

@@ -47,29 +50,17 @@ export class AnnotationExporter {
4750
}
4851
}
4952

50-
/** Trigger a file download with the exported content */
51-
downloadAsFile(content: string, filename: string, format: ExportFormat): void {
53+
/** Trigger a file download / share with the exported content (cross-platform) */
54+
async downloadAsFile(content: string, filename: string, format: ExportFormat): Promise<void> {
5255
const mimeType = format === "json" ? "application/json" : "text/markdown";
53-
const blob = new Blob([content], { type: `${mimeType};charset=utf-8` });
54-
const url = URL.createObjectURL(blob);
55-
56-
const a = document.createElement("a");
57-
a.href = url;
58-
a.download = filename;
59-
a.style.display = "none";
60-
document.body.appendChild(a);
61-
a.click();
62-
63-
// Cleanup
64-
setTimeout(() => {
65-
document.body.removeChild(a);
66-
URL.revokeObjectURL(url);
67-
}, 100);
56+
const platform = getPlatformService();
57+
await platform.shareOrDownloadFile(content, filename, mimeType);
6858
}
6959

70-
/** Copy export content to clipboard */
60+
/** Copy export content to clipboard (cross-platform) */
7161
async copyToClipboard(content: string): Promise<void> {
72-
await navigator.clipboard.writeText(content);
62+
const platform = getPlatformService();
63+
await platform.copyToClipboard(content);
7364
}
7465

7566
// --- Format implementations ---

packages/core/src/hooks/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,12 @@ export { useDrag } from "./use-drag";
66
export { useThrottledValue, useThrottledCallback, useStreamingText } from "./use-throttled-value";
77
export { useKeyboard } from "./use-keyboard";
88
export { useTranslator, type UseTranslatorOptions } from "./useTranslator";
9-
export { useReadingSession } from "./use-reading-session";
9+
export {
10+
useReadingSession,
11+
setSessionEventSource,
12+
webSessionEventSource,
13+
type SessionEventSource,
14+
} from "./use-reading-session";
1015
export { useStreamingChat, type StreamingChatOptions, type StreamingState } from "./use-streaming-chat";
1116

1217
// Reader hooks

packages/core/src/hooks/use-reading-session.ts

Lines changed: 58 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
/**
22
* useReadingSession — reading session state machine hook
3+
*
4+
* Cross-platform: event listeners are provided by a SessionEventSource adapter.
5+
* - Web: uses window/document event listeners (default)
6+
* - React Native: inject an AppState-based adapter
37
*/
48
import { type SessionEvent, createSessionDetector } from "../reader/session-detector";
59
import { useReadingSessionStore } from "../stores/reading-session-store";
@@ -9,6 +13,50 @@ import { useCallback, useEffect, useRef } from "react";
913
// Save session every 5 minutes
1014
const AUTO_SAVE_INTERVAL = 5 * 60 * 1000;
1115

16+
/**
17+
* Platform adapter for user activity / visibility / unload events.
18+
* Each platform provides its own implementation.
19+
*/
20+
export interface SessionEventSource {
21+
/** Subscribe to user activity events. Returns unsubscribe function. */
22+
subscribeActivity(callback: () => void): () => void;
23+
/** Subscribe to visibility changes. Returns unsubscribe function. */
24+
subscribeVisibility(callback: (visible: boolean) => void): () => void;
25+
/** Subscribe to app close / beforeunload. Returns unsubscribe function. */
26+
subscribeBeforeUnload(callback: () => void): () => void;
27+
}
28+
29+
/** Default Web implementation using window/document events */
30+
export const webSessionEventSource: SessionEventSource = {
31+
subscribeActivity(callback) {
32+
const events = ["mousemove", "keydown", "scroll", "click", "touchstart"] as const;
33+
for (const evt of events) {
34+
window.addEventListener(evt, callback);
35+
}
36+
return () => {
37+
for (const evt of events) {
38+
window.removeEventListener(evt, callback);
39+
}
40+
};
41+
},
42+
subscribeVisibility(callback) {
43+
const handler = () => callback(!document.hidden);
44+
document.addEventListener("visibilitychange", handler);
45+
return () => document.removeEventListener("visibilitychange", handler);
46+
},
47+
subscribeBeforeUnload(callback) {
48+
window.addEventListener("beforeunload", callback);
49+
return () => window.removeEventListener("beforeunload", callback);
50+
},
51+
};
52+
53+
/** Global override — set by platforms that cannot use web events (e.g. React Native) */
54+
let _sessionEventSource: SessionEventSource = webSessionEventSource;
55+
56+
export function setSessionEventSource(source: SessionEventSource): void {
57+
_sessionEventSource = source;
58+
}
59+
1260
export function useReadingSession(bookId: string | null, tabId?: string) {
1361
const { startSession, pauseSession, resumeSession, stopSession, updateActiveTime, saveCurrentSession } =
1462
useReadingSessionStore();
@@ -56,24 +104,19 @@ export function useReadingSession(bookId: string | null, tabId?: string) {
56104
useEffect(() => {
57105
if (!bookId) return;
58106

107+
const source = _sessionEventSource;
108+
59109
const onActivity = () => {
60110
if (useAppStore.getState().activeTabId === tabId || !tabId) {
61111
sendEvent({ type: "activity" });
62112
}
63113
};
64-
const onVisibility = () => sendEvent({ type: "visibility", visible: !document.hidden });
65-
66-
const onBeforeUnload = () => {
67-
stopSession();
68-
};
69114

70-
window.addEventListener("mousemove", onActivity);
71-
window.addEventListener("keydown", onActivity);
72-
window.addEventListener("scroll", onActivity);
73-
window.addEventListener("click", onActivity);
74-
window.addEventListener("touchstart", onActivity);
75-
document.addEventListener("visibilitychange", onVisibility);
76-
window.addEventListener("beforeunload", onBeforeUnload);
115+
const unsubActivity = source.subscribeActivity(onActivity);
116+
const unsubVisibility = source.subscribeVisibility((visible) =>
117+
sendEvent({ type: "visibility", visible }),
118+
);
119+
const unsubUnload = source.subscribeBeforeUnload(() => stopSession());
77120

78121
sendEvent({ type: "activity" });
79122

@@ -98,13 +141,9 @@ export function useReadingSession(bookId: string | null, tabId?: string) {
98141
}, 1000);
99142

100143
return () => {
101-
window.removeEventListener("mousemove", onActivity);
102-
window.removeEventListener("keydown", onActivity);
103-
window.removeEventListener("scroll", onActivity);
104-
window.removeEventListener("click", onActivity);
105-
window.removeEventListener("touchstart", onActivity);
106-
document.removeEventListener("visibilitychange", onVisibility);
107-
window.removeEventListener("beforeunload", onBeforeUnload);
144+
unsubActivity();
145+
unsubVisibility();
146+
unsubUnload();
108147
clearInterval(timer);
109148
sendEvent({ type: "close" });
110149
};

packages/core/src/hooks/useTranslator.ts

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,16 @@ export function useTranslator(options: UseTranslatorOptions = {}) {
3535

3636
const cachedResults: string[] = [];
3737
const needsTranslation: { index: number; text: string }[] = [];
38-
textsToTranslate.forEach((text, index) => {
39-
const cached = getFromCache(text, sourceLang, targetLanguage, providerId);
40-
if (cached) {
41-
cachedResults[index] = cached;
42-
} else {
43-
needsTranslation.push({ index, text });
44-
}
45-
});
38+
await Promise.all(
39+
textsToTranslate.map(async (text, index) => {
40+
const cached = await getFromCache(text, sourceLang, targetLanguage, providerId);
41+
if (cached) {
42+
cachedResults[index] = cached;
43+
} else {
44+
needsTranslation.push({ index, text });
45+
}
46+
}),
47+
);
4648

4749
if (needsTranslation.length === 0) {
4850
return textsToTranslate.map((_, i) => cachedResults[i] || "");
@@ -86,11 +88,13 @@ export function useTranslator(options: UseTranslatorOptions = {}) {
8688
throw new Error(`Unknown translation provider: ${providerId}`);
8789
}
8890

89-
needsTranslation.forEach(({ text }, i) => {
90-
if (translatedTexts[i]) {
91-
storeInCache(text, translatedTexts[i], sourceLang, targetLanguage, providerId);
92-
}
93-
});
91+
await Promise.all(
92+
needsTranslation.map(async ({ text }, i) => {
93+
if (translatedTexts[i]) {
94+
await storeInCache(text, translatedTexts[i], sourceLang, targetLanguage, providerId);
95+
}
96+
}),
97+
);
9498

9599
const results = [...textsToTranslate];
96100
cachedResults.forEach((cached, i) => {

0 commit comments

Comments
 (0)