fix(mobile): restore spotlight highlights

This commit is contained in:
DIYgod 2026-04-14 12:34:38 +08:00
parent e3227c932f
commit f5158ee6cc
7 changed files with 104 additions and 13 deletions

View File

@ -54,7 +54,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>2</string>
<string>3</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSApplicationCategoryType</key>

View File

@ -1,9 +1,11 @@
import { useMemo } from "react"
import type { TextProps } from "react-native"
import { View } from "react-native"
import { Text as RNText, View } from "react-native"
import { useGeneralSettingKey } from "@/src/atoms/settings/general"
import { useSpotlightSettingKey } from "@/src/atoms/settings/spotlight"
import { Text } from "@/src/components/ui/typography/Text"
import { HighlightedText } from "@/src/modules/spotlight/HighlightedText"
export const EntryTranslation = ({
source,
@ -22,6 +24,7 @@ export const EntryTranslation = ({
bilingual?: boolean
} & TextProps) => {
const bilingualFinal = useGeneralSettingKey("translationMode") === "bilingual" || bilingual
const spotlightRules = useSpotlightSettingKey("spotlights")
const nextSource = useMemo(() => {
if (!source) {
return ""
@ -37,25 +40,27 @@ export const EntryTranslation = ({
if (!bilingualFinal) {
return (
<Text {...props} className={className}>
{nextTarget || nextSource}
<HighlightedText text={nextTarget || nextSource} rules={spotlightRules} />
</Text>
)
}
if (inline) {
return (
<Text {...props} className={className}>
{`${nextTarget ? `${nextTarget}` : ""}${nextSource}`}
{nextTarget ? <HighlightedText text={nextTarget} rules={spotlightRules} /> : null}
{nextTarget ? <RNText>{" ⇋ "}</RNText> : null}
<HighlightedText text={nextSource} rules={spotlightRules} />
</Text>
)
}
return (
<View>
<Text {...props} className={className}>
{nextSource}
<HighlightedText text={nextSource} rules={spotlightRules} />
</Text>
{nextTarget && (
<Text {...props} className={className}>
{nextTarget}
<HighlightedText text={nextTarget} rules={spotlightRules} />
</Text>
)}
</View>

View File

@ -0,0 +1,39 @@
import type { SpotlightRule } from "@follow/shared/spotlight"
import { Fragment, useMemo } from "react"
import { Text as RNText } from "react-native"
import { getHighlightedTextSegments } from "./highlightedTextSegments"
import { getHighlightedTextSegmentStyle } from "./highlightedTextStyle"
export const HighlightedText = ({
text,
rules,
}: {
text?: string | null
rules: SpotlightRule[]
}) => {
const segments = useMemo(() => getHighlightedTextSegments(text, rules), [rules, text])
const keyedSegments = useMemo(() => {
let offset = 0
return segments.map((segment) => {
const key = segment.highlight ? `${offset}-${segment.highlight.ruleId}-${segment.text}` : null
offset += segment.text.length
return { key: key ?? `${offset}-${segment.text}`, segment }
})
}, [segments])
return (
<>
{keyedSegments.map(({ key, segment }) =>
segment.highlight ? (
<RNText key={key} style={getHighlightedTextSegmentStyle(segment.highlight.color)}>
{segment.text}
</RNText>
) : (
<Fragment key={key}>{segment.text}</Fragment>
),
)}
</>
)
}

View File

@ -1,6 +1,6 @@
import { createStore, Provider, useAtomValue } from "jotai"
import { entryAtom, noMediaAtom, readerRenderInlineStyleAtom } from "./atoms"
import { entryAtom, noMediaAtom, readerRenderInlineStyleAtom, spotlightAtom } from "./atoms"
import { HTML } from "./HTML"
import { WebViewBridgeManager } from "./managers/webview-bridge"
@ -14,10 +14,15 @@ export const App = () => {
const entry = useAtomValue(entryAtom, { store })
const readerRenderInlineStyle = useAtomValue(readerRenderInlineStyleAtom, { store })
const noMedia = useAtomValue(noMediaAtom, { store })
const spotlightRules = useAtomValue(spotlightAtom, { store })
return (
<Provider store={store}>
<HTML renderInlineStyle={readerRenderInlineStyle} noMedia={noMedia}>
<HTML
renderInlineStyle={readerRenderInlineStyle}
noMedia={noMedia}
spotlightRules={spotlightRules}
>
{entry?.content}
</HTML>
</Provider>

View File

@ -1,4 +1,5 @@
import { MemoedDangerousHTMLStyle } from "@follow/components/common/MemoedDangerousHTMLStyle.js"
import type { SpotlightRule } from "@follow/shared/spotlight"
import { clsx } from "clsx"
import katexStyle from "katex/dist/katex.min.css?raw"
import * as React from "react"
@ -14,6 +15,7 @@ export type HTMLProps<A extends keyof React.JSX.IntrinsicElements = "div"> = {
accessory?: React.ReactNode
noMedia?: boolean
spotlightRules?: SpotlightRule[]
} & React.JSX.IntrinsicElements[A] &
Partial<{
renderInlineStyle: boolean
@ -25,25 +27,29 @@ export const HTML = <A extends keyof React.JSX.IntrinsicElements = "div">(props:
as = "article",
accessory,
noMedia,
spotlightRules,
...rest
} = props
const [remarkOptions, setRemarkOptions] = useState({
renderInlineStyle,
noMedia,
spotlightRules,
})
const [shouldForceReMountKey, setShouldForceReMountKey] = useState(0)
useEffect(() => {
setRemarkOptions((options) => {
if (JSON.stringify(options) === JSON.stringify({ renderInlineStyle, noMedia })) {
if (
JSON.stringify(options) === JSON.stringify({ renderInlineStyle, noMedia, spotlightRules })
) {
return options
}
setShouldForceReMountKey((key) => key + 1)
return { ...options, renderInlineStyle, noMedia }
return { ...options, renderInlineStyle, noMedia, spotlightRules }
})
}, [renderInlineStyle, noMedia])
}, [renderInlineStyle, noMedia, spotlightRules])
const [refElement, setRefElement] = useState<HTMLDivElement | null>(null)

View File

@ -1,5 +1,6 @@
import { MemoedDangerousHTMLStyle } from "@follow/components/common/MemoedDangerousHTMLStyle.jsx"
import { Checkbox } from "@follow/components/ui/checkbox/index.jsx"
import type { SpotlightRule } from "@follow/shared/spotlight"
import { parseHtml as parseHtmlGeneral } from "@follow/utils/html"
import type { Components } from "hast-util-to-jsx-runtime"
import { createElement } from "react"
@ -9,8 +10,9 @@ import { createHeadingRenderer, MarkdownLink, MarkdownP } from "./components"
import { MarkdownImage } from "./components/image"
import { Math } from "./components/math"
import { ShikiHighLighter } from "./components/shiki"
import { applySpotlightToHtmlRendererTree } from "./spotlight"
const Style: Components["style"] = ({ node, ...props }) => {
const renderStyleTag: Components["style"] = ({ node, ...props }) => {
if (typeof props.children === "string") {
return createElement(MemoedDangerousHTMLStyle, null, props.children)
}
@ -22,10 +24,16 @@ export const parseHtml = (
options?: Partial<{
renderInlineStyle: boolean
noMedia?: boolean
spotlightRules?: SpotlightRule[]
}>,
) => {
const spotlightRules = options?.spotlightRules ?? []
return parseHtmlGeneral(content, {
...options,
hastTransform: (tree) => {
applySpotlightToHtmlRendererTree(tree, spotlightRules)
},
components: {
a: ({ node, ...props }) => {
// Ignore link wrapper when child is an image to ensure image preview
@ -62,7 +70,7 @@ export const parseHtml = (
h6: (props) => {
return createHeadingRenderer(6)(props)
},
style: Style,
style: renderStyleTag,
img: ({ node, ...props }) => {
return createElement(MarkdownImage, props as any)
},

View File

@ -5,6 +5,7 @@ import { beforeEach, describe, expect, test, vi } from "vitest"
import { entryAtom, spotlightAtom } from "./atoms"
import { WebViewBridgeManager } from "./managers/webview-bridge"
import { parseHtml } from "./parser"
import { applySpotlightToHtmlRendererTree } from "./spotlight"
const spotlightRules: SpotlightRule[] = [
@ -212,3 +213,30 @@ describe("WebViewBridgeManager", () => {
expect(store.get(spotlightAtom)).toEqual(spotlightRules)
})
})
describe("parseHtml", () => {
test("injects spotlight markup when parser receives spotlight rules", () => {
const { hastTree } = parseHtml("<p>alpha beta</p>", { spotlightRules })
expect(hastTree).toMatchObject({
children: [
{
type: "element",
tagName: "p",
children: [
{
type: "element",
tagName: "span",
properties: expect.objectContaining({
"data-spotlight-rule-id": "alpha-rule",
style: "background-color:#ffcc00CC;border-radius:5px;padding-inline:2px;",
}),
children: [{ type: "text", value: "alpha" }],
},
{ type: "text", value: " beta" },
],
},
],
})
})
})