Skip to content

Commit b251fa9

Browse files
committedOct 30, 2024
feat(reader): support custom css, fixed RSSNext#256
Signed-off-by: Innei <[email protected]>

File tree

18 files changed

+277
-61
lines changed

18 files changed

+277
-61
lines changed
 

‎apps/renderer/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@
6868
"nanoid": "5.0.7",
6969
"ofetch": "1.4.1",
7070
"path-to-regexp": "8.2.0",
71+
"plain-shiki": "0.0.12",
7172
"re-resizable": "6.10.0",
7273
"react-blurhash": "^0.3.0",
7374
"react-error-boundary": "4.1.2",

‎apps/renderer/src/atoms/settings/ui.ts

+2
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export const createDefaultSettings = (): UISettings => ({
3434
codeHighlightThemeDark: "github-dark",
3535
guessCodeLanguage: true,
3636
hideRecentReader: false,
37+
customCSS: "",
3738

3839
// View
3940
pictureViewMasonry: true,
@@ -56,6 +57,7 @@ export const uiServerSyncWhiteListKeys: (keyof UISettings)[] = [
5657
"uiFontFamily",
5758
"readerFontFamily",
5859
"opaqueSidebar",
60+
"customCSS",
5961
]
6062

6163
const isZenModeAtom = atom((get) => {

‎apps/renderer/src/components/common/ShadowDOM.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ export const ShadowDOM: FC<
8787
const uiFont = useUISettingKey("uiFontFamily")
8888
const reduceMotion = useReduceMotion()
8989
const usePointerCursor = useUISettingKey("usePointerCursor")
90+
const customCSS = useUISettingKey("customCSS")
9091

9192
return (
9293
<root.div {...rest}>
@@ -105,6 +106,7 @@ export const ShadowDOM: FC<
105106
className="font-theme"
106107
>
107108
{injectHostStyles ? stylesElements : null}
109+
<MemoedDangerousHTMLStyle>{customCSS}</MemoedDangerousHTMLStyle>
108110
{props.children}
109111
</div>
110112
</ShadowDOMContext.Provider>

‎apps/renderer/src/components/ui/modal/stacked/modal.tsx

+3-2
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Divider } from "@follow/components/ui/divider/index.js"
22
import { RootPortalProvider } from "@follow/components/ui/portal/provider.js"
33
import { EllipsisHorizontalTextWithTooltip } from "@follow/components/ui/typography/index.js"
44
import { useRefValue } from "@follow/hooks"
5-
import { nextFrame, stopPropagation } from "@follow/utils/dom"
5+
import { nextFrame, preventDefault, stopPropagation } from "@follow/utils/dom"
66
import { cn, getOS } from "@follow/utils/utils"
77
import * as Dialog from "@radix-ui/react-dialog"
88
import type { BoundingBox } from "framer-motion"
@@ -289,7 +289,8 @@ export const ModalInternal = memo(
289289
<Dialog.Content
290290
asChild
291291
aria-describedby={undefined}
292-
onPointerDownOutside={(event) => event.preventDefault()}
292+
// @ts-expect-error
293+
onPointerDownOutside={preventDefault}
293294
onOpenAutoFocus={openAutoFocus}
294295
>
295296
<div

‎apps/renderer/src/modules/boost/level-benefits.tsx

+6-6
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { cn } from "@follow/utils/utils"
22

33
const benefits = [
44
{
5-
level: "Lv1",
5+
level: 1,
66
benefits: [
77
{
88
icon: "i-mgc-certificate-cute-fi",
@@ -14,20 +14,20 @@ const benefits = [
1414
],
1515
},
1616
{
17-
level: "Lv2",
17+
level: 2,
1818
benefits: [
1919
{ icon: "i-mgc-rocket-cute-re", text: "Faster feed refresh time", comingSoon: true },
2020
],
2121
},
2222
{
23-
level: "Lv3",
23+
level: 3,
2424
benefits: [
2525
{ icon: "i-mgc-eye-2-cute-re", text: "More developer attention" },
2626
{ icon: "i-mgc-rocket-cute-re", text: "Faster feed refresh time", comingSoon: true },
2727
],
2828
},
2929
{
30-
level: "Lv4",
30+
level: 4,
3131
benefits: [
3232
{ icon: "i-mgc-eye-2-cute-re", text: "More developer attention" },
3333
{ icon: "i-mgc-magic-2-cute-re", text: "AI summary for feed", comingSoon: true },
@@ -57,15 +57,15 @@ const BoostLevel = ({
5757
level,
5858
benefits,
5959
}: {
60-
level: string
60+
level: number
6161
benefits: { icon?: string; text: string; comingSoon?: boolean }[]
6262
}) => {
6363
return (
6464
<li>
6565
<div className="flex flex-col">
6666
<h3 className="flex items-center gap-2 text-xl font-semibold text-theme-accent">
6767
<span className="grow border-t border-gray-300 dark:border-gray-700" />
68-
<span>{level}</span>
68+
<span>Lv. {level}</span>
6969
<span className="grow border-t border-gray-300 dark:border-gray-700" />
7070
</h3>
7171
<ul className="ml-4 list-inside list-disc space-y-1">
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { useIsDark } from "@follow/hooks"
2+
import { nextFrame } from "@follow/utils/dom"
3+
import { cn } from "@follow/utils/utils"
4+
import { createPlainShiki } from "plain-shiki"
5+
import { useLayoutEffect, useRef } from "react"
6+
import css from "shiki/langs/css.mjs"
7+
import githubDark from "shiki/themes/github-dark.mjs"
8+
import githubLight from "shiki/themes/github-light.mjs"
9+
10+
import { shiki } from "~/components/ui/code-highlighter/shiki/shared"
11+
12+
shiki.loadLanguageSync(css)
13+
shiki.loadThemeSync(githubDark)
14+
shiki.loadThemeSync(githubLight)
15+
export const CSSEditor: Component<{
16+
onChange: (value: string) => void
17+
defaultValue?: string
18+
}> = ({ onChange, className, defaultValue }) => {
19+
const ref = useRef<HTMLDivElement>(null)
20+
21+
const isDark = useIsDark()
22+
useLayoutEffect(() => {
23+
let dispose: () => void
24+
if (ref.current) {
25+
ref.current.focus()
26+
// Move cursor to the end of the content
27+
const selection = window.getSelection()
28+
const range = document.createRange()
29+
range.selectNodeContents(ref.current)
30+
range.collapse(false)
31+
selection?.removeAllRanges()
32+
selection?.addRange(range)
33+
34+
ref.current.textContent = defaultValue ?? ""
35+
const { dispose: disposeShiki } = createPlainShiki(shiki).mount(ref.current, {
36+
lang: "css",
37+
themes: {
38+
light: "github-light",
39+
dark: "github-dark",
40+
},
41+
defaultTheme: isDark ? "dark" : "light",
42+
})
43+
dispose = disposeShiki
44+
}
45+
return () => dispose?.()
46+
}, [isDark])
47+
return (
48+
<div
49+
className={cn("size-full", className)}
50+
ref={ref}
51+
contentEditable
52+
tabIndex={0}
53+
onKeyDown={(e) => {
54+
if (e.key === "Tab") {
55+
e.preventDefault()
56+
const selection = window.getSelection()
57+
if (selection && selection.rangeCount > 0) {
58+
const range = selection.getRangeAt(0)
59+
const tabNode = document.createTextNode("\u00A0\u00A0\u00A0\u00A0") // Using four non-breaking spaces as a tab
60+
range.insertNode(tabNode)
61+
range.setStartAfter(tabNode)
62+
range.setEndAfter(tabNode)
63+
selection.removeAllRanges()
64+
selection.addRange(range)
65+
}
66+
}
67+
nextFrame(() => {
68+
onChange(ref.current?.textContent ?? "")
69+
})
70+
}}
71+
/>
72+
)
73+
}

‎apps/renderer/src/modules/entry-column/index.tsx

+3-3
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { ScrollArea } from "@follow/components/ui/scroll-area/index.js"
55
import { FeedViewType, views } from "@follow/constants"
66
import { useTitle, useTypeScriptHappyCallback } from "@follow/hooks"
77
import type { FeedModel } from "@follow/models/types"
8-
import { clsx, cn, isBizId } from "@follow/utils/utils"
8+
import { clsx, isBizId } from "@follow/utils/utils"
99
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
1010
import { useTranslation } from "react-i18next"
1111
import type {
@@ -223,13 +223,13 @@ function EntryColumnImpl() {
223223
</AutoResizeHeight>
224224
<m.div
225225
key={`${routeFeedId}-${view}`}
226-
className="relative h-0 grow"
226+
className="relative mt-2 h-0 grow"
227227
initial={{ opacity: 0.01, y: 100 }}
228228
animate={{ opacity: 1, y: 0 }}
229229
exit={{ opacity: 0.01, y: -100 }}
230230
>
231231
<ScrollArea.ScrollArea
232-
scrollbarClassName={cn("mt-3", !views[view].wideMode ? "w-[5px] p-0" : "")}
232+
scrollbarClassName={!views[view].wideMode ? "w-[5px] p-0" : ""}
233233
mask={false}
234234
ref={scrollRef}
235235
rootClassName={clsx("h-full", views[view].wideMode ? "mt-2" : "")}
Original file line numberDiff line numberDiff line change
@@ -1,45 +1 @@
1-
import type { SettingPageConfig } from "./utils"
2-
3-
function getSettings() {
4-
const map = import.meta.glob("../../pages/settings/\\(settings\\)/*", { eager: true })
5-
6-
const settings = [] as {
7-
name: I18nKeysForSettings
8-
iconName: string
9-
path: string
10-
Component: () => JSX.Element
11-
priority: number
12-
loader: () => SettingPageConfig
13-
}[]
14-
for (const path in map) {
15-
const prefix = "(settings)"
16-
const postfix = ".tsx"
17-
const lastItem = path.split("/").pop()
18-
19-
if (!lastItem) continue
20-
let p = lastItem.slice(0, -postfix.length)
21-
22-
if (p.includes(prefix)) {
23-
p = p.replace(prefix, "")
24-
}
25-
26-
if (p === "index" || p === "layout") continue
27-
28-
const Module = map[path] as {
29-
Component: () => JSX.Element
30-
loader: () => SettingPageConfig
31-
}
32-
33-
if (!Module.loader) continue
34-
settings.push({
35-
...Module.loader(),
36-
Component: Module.Component,
37-
loader: Module.loader,
38-
path: p,
39-
})
40-
}
41-
42-
return settings.sort((a, b) => a.priority - b.priority)
43-
}
44-
45-
export const settings = /* #__PURE__ */ getSettings()
1+
export const SETTING_MODAL_ID = "setting-modal"

‎apps/renderer/src/modules/settings/hooks/use-setting-ctx.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { useMemo } from "react"
22

33
import { useUserRole } from "~/atoms/user"
44

5-
import { settings } from "../constants"
5+
import { settings } from "../settings-glob"
66
import type { SettingPageContext } from "../utils"
77

88
export const useSettingPageContext = (): SettingPageContext => {

‎apps/renderer/src/modules/settings/modal/content.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { Trans } from "react-i18next"
88
import { ModalClose } from "~/components/ui/modal/stacked/components"
99
import { SettingsTitle } from "~/modules/settings/title"
1010

11-
import { settings } from "../constants"
11+
import { settings } from "../settings-glob"
1212
import { SettingTabProvider, useSettingTab } from "./context"
1313
import { SettingModalLayout } from "./layout"
1414

‎apps/renderer/src/modules/settings/modal/layout.tsx

+6-1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { useResizeableModal } from "~/components/ui/modal/stacked/hooks"
1616
import { ElECTRON_CUSTOM_TITLEBAR_HEIGHT } from "~/constants"
1717
import { useActivationModal } from "~/modules/activation"
1818

19+
import { SETTING_MODAL_ID } from "../constants"
1920
import { SettingSyncIndicator } from "../helper/SyncIndicator"
2021
import { useAvailableSettings, useSettingPageContext } from "../hooks/use-setting-ctx"
2122
import { SettingsSidebarTitle } from "../title"
@@ -82,7 +83,11 @@ export function SettingModalLayout(
8283
}, [])
8384

8485
return (
85-
<div className={cn("h-full", !isResizeable && "center")} ref={edgeElementRef}>
86+
<div
87+
id={SETTING_MODAL_ID}
88+
className={cn("h-full", !isResizeable && "center")}
89+
ref={edgeElementRef}
90+
>
8691
<m.div
8792
exit={{
8893
opacity: 0,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import type { SettingPageConfig } from "./utils"
2+
3+
function getSettings() {
4+
const map = import.meta.glob("../../pages/settings/\\(settings\\)/*", { eager: true })
5+
6+
const settings = [] as {
7+
name: I18nKeysForSettings
8+
iconName: string
9+
path: string
10+
Component: () => JSX.Element
11+
priority: number
12+
loader: () => SettingPageConfig
13+
}[]
14+
for (const path in map) {
15+
const prefix = "(settings)"
16+
const postfix = ".tsx"
17+
const lastItem = path.split("/").pop()
18+
19+
if (!lastItem) continue
20+
let p = lastItem.slice(0, -postfix.length)
21+
22+
if (p.includes(prefix)) {
23+
p = p.replace(prefix, "")
24+
}
25+
26+
if (p === "index" || p === "layout") continue
27+
28+
const Module = map[path] as {
29+
Component: () => JSX.Element
30+
loader: () => SettingPageConfig
31+
}
32+
33+
if (!Module.loader) continue
34+
settings.push({
35+
...Module.loader(),
36+
Component: Module.Component,
37+
loader: Module.loader,
38+
path: p,
39+
})
40+
}
41+
42+
return settings.sort((a, b) => a.priority - b.priority)
43+
}
44+
45+
export const settings = /* #__PURE__ */ getSettings()

‎apps/renderer/src/modules/settings/tabs/apperance.tsx

+101-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Button } from "@follow/components/ui/button/index.js"
12
import {
23
Select,
34
SelectContent,
@@ -8,20 +9,31 @@ import {
89
import { useIsDark, useThemeAtomValue } from "@follow/hooks"
910
import { IN_ELECTRON } from "@follow/shared/constants"
1011
import { getOS } from "@follow/utils/utils"
12+
import { useForceUpdate } from "framer-motion"
13+
import { useEffect, useRef } from "react"
1114
import { useTranslation } from "react-i18next"
1215
import { bundledThemesInfo } from "shiki/themes"
1316

1417
import {
18+
getUISettings,
1519
setUISetting,
1620
useUISettingKey,
1721
useUISettingSelector,
1822
useUISettingValue,
1923
} from "~/atoms/settings/ui"
2024
import { setFeedColumnShow, useFeedColumnShow } from "~/atoms/sidebar"
25+
import { useCurrentModal, useModalStack } from "~/components/ui/modal/stacked/hooks"
2126
import { isElectronBuild } from "~/constants"
2227
import { useSetTheme } from "~/hooks/common"
2328

24-
import { SettingDescription, SettingSwitch, SettingTabbedSegment } from "../control"
29+
import { CSSEditor } from "../../editor/css-editor"
30+
import { SETTING_MODAL_ID } from "../constants"
31+
import {
32+
SettingActionItem,
33+
SettingDescription,
34+
SettingSwitch,
35+
SettingTabbedSegment,
36+
} from "../control"
2537
import { createDefineSettingItem } from "../helper/builder"
2638
import { createSettingBuilder } from "../helper/setting-builder"
2739
import { SettingItemGroup } from "../section"
@@ -71,6 +83,7 @@ export const SettingAppearance = () => {
7183
}),
7284
ZenMode,
7385
ThumbnailRatio,
86+
CustomCSS,
7487

7588
{
7689
type: "title",
@@ -290,3 +303,90 @@ const ThumbnailRatio = () => {
290303
</SettingItemGroup>
291304
)
292305
}
306+
307+
const CustomCSS = () => {
308+
const { t } = useTranslation("settings")
309+
const { present } = useModalStack()
310+
return (
311+
<SettingItemGroup>
312+
<SettingActionItem
313+
label={t("appearance.custom_css.label")}
314+
action={() => {
315+
present({
316+
title: t("appearance.custom_css.label"),
317+
content: CustomCSSModal,
318+
clickOutsideToDismiss: false,
319+
overlay: false,
320+
resizeable: true,
321+
resizeDefaultSize: {
322+
width: 700,
323+
height: 400,
324+
},
325+
})
326+
}}
327+
buttonText={t("appearance.custom_css.button")}
328+
/>
329+
<SettingDescription>{t("appearance.custom_css.description")}</SettingDescription>
330+
</SettingItemGroup>
331+
)
332+
}
333+
334+
const CustomCSSModal = () => {
335+
const initialCSS = useRef(getUISettings().customCSS)
336+
const { t } = useTranslation("common")
337+
const { dismiss } = useCurrentModal()
338+
useEffect(() => {
339+
return () => {
340+
setUISetting("customCSS", initialCSS.current)
341+
}
342+
}, [])
343+
useEffect(() => {
344+
const modal = document.querySelector(`#${SETTING_MODAL_ID}`) as HTMLDivElement
345+
if (!modal) return
346+
const prevOverlay = getUISettings().modalOverlay
347+
setUISetting("modalOverlay", false)
348+
349+
modal.style.display = "none"
350+
return () => {
351+
setUISetting("modalOverlay", prevOverlay)
352+
353+
modal.style.display = "block"
354+
}
355+
}, [])
356+
const [forceUpdate, key] = useForceUpdate()
357+
return (
358+
<form
359+
className="relative flex h-full max-w-full flex-col"
360+
onSubmit={(e) => {
361+
e.preventDefault()
362+
if (initialCSS.current !== getUISettings().customCSS) {
363+
initialCSS.current = getUISettings().customCSS
364+
}
365+
dismiss()
366+
}}
367+
>
368+
<CSSEditor
369+
defaultValue={initialCSS.current}
370+
key={key}
371+
className="h-0 grow rounded-lg border p-3 font-mono"
372+
onChange={(value) => {
373+
setUISetting("customCSS", value)
374+
}}
375+
/>
376+
377+
<div className="mt-2 flex shrink-0 justify-end gap-2">
378+
<Button
379+
variant="outline"
380+
onClick={(e) => {
381+
e.preventDefault()
382+
setUISetting("customCSS", initialCSS.current)
383+
forceUpdate()
384+
}}
385+
>
386+
{t("words.reset")}
387+
</Button>
388+
<Button type="submit">{t("words.save")}</Button>
389+
</div>
390+
</form>
391+
)
392+
}

‎apps/renderer/src/modules/settings/title.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import { useContext } from "react"
44
import { useTranslation } from "react-i18next"
55
import { useLoaderData } from "react-router-dom"
66

7-
import { settings } from "./constants"
87
import { IsInSettingIndependentWindowContext } from "./context"
8+
import { settings } from "./settings-glob"
99
import type { SettingPageConfig } from "./utils"
1010

1111
export const SettingsSidebarTitle = ({ path, className }: { path: string; className?: string }) => {

‎locales/common/en.json

+2
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,11 @@
3131
"words.record": "record",
3232
"words.record_one": "record",
3333
"words.record_other": "records",
34+
"words.reset": "Reset",
3435
"words.result": "result",
3536
"words.result_one": "result",
3637
"words.result_other": "results",
38+
"words.save": "Save",
3739
"words.submit": "Submit",
3840
"words.update": "Update",
3941
"words.which.all": "All"

‎locales/settings/en.json

+3
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@
5353
"appearance.code_highlight_theme": "Code highlight theme",
5454
"appearance.content": "Content",
5555
"appearance.content_font": "Content Font",
56+
"appearance.custom_css.button": "Edit",
57+
"appearance.custom_css.description": "Custom CSS style for content",
58+
"appearance.custom_css.label": "Custom CSS",
5659
"appearance.custom_font": "Custom Font",
5760
"appearance.fonts": "Fonts",
5861
"appearance.general": "General",

‎packages/shared/src/interface/settings.ts

+2
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,13 @@ export interface UISettings {
2929
usePointerCursor: boolean | null
3030
uiFontFamily: string
3131
readerFontFamily: string
32+
// Content
3233
readerRenderInlineStyle: boolean
3334
codeHighlightThemeLight: string
3435
codeHighlightThemeDark: string
3536
guessCodeLanguage: boolean
3637
hideRecentReader: boolean
38+
customCSS: string
3739

3840
// view
3941
pictureViewMasonry: boolean

‎pnpm-lock.yaml

+24
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)
Please sign in to comment.