feat: implement selected text functionality in AI chat editor, resolves FOL-2800

- Introduced a new SelectedTextNode to handle selected text within the AI chat editor.
- Added a TextSelectionToolbar for user interaction with selected text, including copy and AI query options.
- Updated the editor plugins to include the SelectedTextPlugin for managing selected text insertion.
- Enhanced text selection event handling to capture and process selected text with associated metadata.
- Refactored related components and styles for improved user experience and integration.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei 2025-11-19 22:39:15 +08:00
parent c17957f98e
commit 7797b7b8fd
No known key found for this signature in database
GPG Key ID: 0F62D33977F021F7
21 changed files with 531 additions and 50 deletions

View File

@ -2,9 +2,19 @@
* Simple text selection utilities for ShadowDOM
*/
export interface SelectionRect {
top: number
right: number
bottom: number
left: number
width: number
height: number
}
export interface TextSelectionEvent {
selectedText: string
timestamp: number
rect: SelectionRect
}
/**
@ -28,21 +38,23 @@ export function addTextSelectionListener(
try {
const range = selection.getRangeAt(0)
if (!shadowRoot.contains(range.commonAncestorContainer)) return
if (!selection.isCollapsed) {
const selectedText = selection.toString().trim()
if (selectedText) {
onTextSelect({
selectedText,
timestamp: Date.now(),
rect: normalizeRect(range.getBoundingClientRect()),
})
}
return
}
} catch {
// Uncaught IndexSizeError: Failed to execute 'getRangeAt' on 'Selection': 0 is not a valid index.
return
}
if (!selection.isCollapsed) {
const selectedText = selection.toString().trim()
if (selectedText) {
onTextSelect({
selectedText,
timestamp: Date.now(),
})
}
} else {
onSelectionClear?.()
}
onSelectionClear?.()
}, 200)
}
@ -53,3 +65,14 @@ export function addTextSelectionListener(
document.removeEventListener("selectionchange", handleSelectionChange)
}
}
function normalizeRect(rect: DOMRect | DOMRectReadOnly): SelectionRect {
return {
top: rect.top,
right: rect.right,
bottom: rect.bottom,
left: rect.left,
width: rect.width,
height: rect.height,
}
}

View File

@ -15,7 +15,7 @@ import { COMMAND_ID } from "~/modules/command/commands/id"
import { getCommand } from "~/modules/command/hooks/use-command"
import { useCommandShortcut } from "~/modules/command/hooks/use-command-binding"
import { FileUploadPlugin, MentionPlugin, ShortcutPlugin } from "../../editor"
import { FileUploadPlugin, MentionPlugin, SelectedTextPlugin, ShortcutPlugin } from "../../editor"
import { useMainEntryId } from "../../hooks/useMainEntryId"
import { AIPanelRefsContext } from "../../store/AIChatContext"
import { useChatActions, useChatScene, useChatStatus } from "../../store/hooks"
@ -173,7 +173,9 @@ export const ChatInput = memo(
onKeyDown={handleKeyDown}
autoFocus
plugins={
scene === "onboarding" ? [] : [MentionPlugin, ShortcutPlugin, FileUploadPlugin]
scene === "onboarding"
? []
: [MentionPlugin, ShortcutPlugin, FileUploadPlugin, SelectedTextPlugin]
}
namespace="AIChatRichEditor"
/>

View File

@ -20,11 +20,6 @@ export const BLOCK_STYLES = {
icon: "bg-orange/10 text-orange",
label: "text-orange",
},
selectedText: {
container: "from-purple/5 to-purple/10 border-purple/20 hover:border-purple/30",
icon: "bg-purple/10 text-purple",
label: "text-purple",
},
fileAttachment: {
container: "from-pink/5 to-pink/10 border-pink/20 hover:border-pink/30",
icon: "bg-pink/10 text-pink",
@ -52,7 +47,6 @@ export const DEFAULT_BLOCK_STYLES = {
export const BLOCK_ICONS = {
mainEntry: "i-mgc-star-cute-fi",
mainFeed: "i-mgc-rss-cute-fi",
selectedText: "i-mgc-quill-pen-cute-re",
fileAttachment: "i-mgc-file-upload-cute-re",
unreadOnly: "i-mgc-round-cute-fi",
} as const
@ -63,7 +57,6 @@ export const BLOCK_ICONS = {
export const BLOCK_LABELS = {
mainEntry: "Current",
mainFeed: "Current",
selectedText: "Text",
fileAttachment: "File",
mainView: "View",
unreadOnly: "Filter",

View File

@ -140,11 +140,6 @@ export function useContextBlockPresentation(block: AIChatContextBlock): ContextB
)
break
}
case "selectedText": {
displayContent = `"${block.value}"`
title = block.value
break
}
case "unreadOnly": {
displayContent = "Unread Only"
title = "Unread Only"

View File

@ -1,6 +1,6 @@
import { LexicalRichEditorNodes } from "@follow/components/ui/lexical-rich-editor/nodes.js"
import { FileAttachmentNode, MentionNode, ShortcutNode } from "./plugins"
import { FileAttachmentNode, MentionNode, SelectedTextNode, ShortcutNode } from "./plugins"
export * from "./plugins"
@ -9,4 +9,5 @@ export const LexicalAIEditorNodes = [
MentionNode,
ShortcutNode,
FileAttachmentNode,
SelectedTextNode,
]

View File

@ -1,3 +1,4 @@
export * from "./file-upload"
export * from "./mention"
export * from "./selection"
export * from "./shortcut"

View File

@ -0,0 +1,135 @@
import type {
DOMConversionMap,
DOMExportOutput,
LexicalEditor,
LexicalNode,
NodeKey,
SerializedLexicalNode,
Spread,
} from "lexical"
import { DecoratorNode } from "lexical"
import * as React from "react"
import { SelectedTextNodeComponent } from "./SelectedTextNodeComponent"
export type SelectedTextNodePayload = {
text: string
sourceEntryId?: string
timestamp?: number
}
export type SerializedSelectedTextNode = Spread<SelectedTextNodePayload, SerializedLexicalNode>
export class SelectedTextNode extends DecoratorNode<React.JSX.Element> {
__text: string
__sourceEntryId?: string
__timestamp?: number
static override getType(): string {
return "selected-text"
}
static override clone(node: SelectedTextNode): SelectedTextNode {
return new SelectedTextNode(node.__text, node.__sourceEntryId, node.__timestamp, node.__key)
}
constructor(text: string, sourceEntryId?: string, timestamp?: number, key?: NodeKey) {
super(key)
this.__text = text
this.__sourceEntryId = sourceEntryId
this.__timestamp = timestamp
}
getText(): string {
return this.__text
}
setText(text: string): void {
const writable = this.getWritable()
writable.__text = text
}
getSourceEntryId(): string | undefined {
return this.__sourceEntryId
}
getTimestamp(): number | undefined {
return this.__timestamp
}
override createDOM(): HTMLElement {
const div = document.createElement("div")
div.dataset.selectedTextNode = "true"
return div
}
override updateDOM(): false {
return false
}
static override importDOM(): DOMConversionMap | null {
return null
}
static override importJSON(serializedNode: SerializedSelectedTextNode): SelectedTextNode {
const { text, sourceEntryId, timestamp } = serializedNode
return $createSelectedTextNode({ text, sourceEntryId, timestamp })
}
override exportJSON(): SerializedSelectedTextNode {
return {
text: this.__text,
sourceEntryId: this.__sourceEntryId,
timestamp: this.__timestamp,
type: "selected-text",
version: 1,
}
}
override exportDOM(): DOMExportOutput {
const element = document.createElement("div")
element.dataset.selectedTextNode = "true"
element.textContent = this.__text
return { element }
}
override decorate(_editor: LexicalEditor): React.JSX.Element {
return (
<SelectedTextNodeComponent
text={this.__text}
sourceEntryId={this.__sourceEntryId}
timestamp={this.__timestamp}
/>
)
}
override isInline(): boolean {
return false
}
override isKeyboardSelectable(): boolean {
return false
}
override getTextContent(): string {
return `<user-selection>${escapeXML(this.__text)}</user-selection>`
}
}
export function $createSelectedTextNode(payload: SelectedTextNodePayload): SelectedTextNode {
return new SelectedTextNode(payload.text, payload.sourceEntryId, payload.timestamp)
}
export function $isSelectedTextNode(
node: LexicalNode | null | undefined,
): node is SelectedTextNode {
return node instanceof SelectedTextNode
}
function escapeXML(text: string): string {
return text
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;")
}

View File

@ -0,0 +1,25 @@
import { cn } from "@follow/utils"
interface SelectedTextNodeComponentProps {
text: string
sourceEntryId?: string
timestamp?: number
}
export function SelectedTextNodeComponent({ text }: SelectedTextNodeComponentProps) {
return (
<span
className={cn(
"relative select-none rounded-md border px-2 py-1 text-sm font-medium transition-colors",
"border-blue/20 bg-blue/10 text-blue",
"hover:border-blue/30 hover:bg-blue/20",
"mb-2 flex items-start",
)}
>
<i className="i-mingcute-text-2-line size-4 shrink-0 translate-y-0.5" />
<span className="ml-2 line-clamp-3 max-w-full whitespace-pre-wrap" title={text}>
"{text}"
</span>
</span>
)
}

View File

@ -0,0 +1,22 @@
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
import { useEffect } from "react"
import { insertSelectedTextNode } from "./insertSelectedTextNode"
import { subscribeSelectedTextInsertion } from "./selectedTextBridge"
import { SelectedTextNode } from "./SelectedTextNode"
export function SelectedTextPlugin() {
const [editor] = useLexicalComposerContext()
useEffect(() => {
return subscribeSelectedTextInsertion((payload) => {
editor.focus()
insertSelectedTextNode(editor, payload)
})
}, [editor])
return null
}
SelectedTextPlugin.id = "selected-text"
SelectedTextPlugin.nodes = [SelectedTextNode]

View File

@ -0,0 +1,5 @@
export * from "./insertSelectedTextNode"
export * from "./selectedTextBridge"
export * from "./SelectedTextNode"
export * from "./SelectedTextNodeComponent"
export * from "./SelectedTextPlugin"

View File

@ -0,0 +1,29 @@
import type { LexicalEditor } from "lexical"
import {
$createParagraphNode,
$createTextNode,
$getRoot,
$getSelection,
$isRangeSelection,
} from "lexical"
import type { SelectedTextNodePayload } from "./SelectedTextNode"
import { $createSelectedTextNode } from "./SelectedTextNode"
export function insertSelectedTextNode(editor: LexicalEditor, payload: SelectedTextNodePayload) {
editor.update(() => {
let selection = $getSelection()
if (!$isRangeSelection(selection)) {
const root = $getRoot()
const paragraph = $createParagraphNode()
root.append(paragraph)
paragraph.selectEnd()
selection = $getSelection()
}
if (!selection) return
const selectedNode = $createSelectedTextNode(payload)
selection.insertNodes([selectedNode, $createTextNode(" ")])
})
}

View File

@ -0,0 +1,29 @@
import type { SelectedTextNodePayload } from "./SelectedTextNode"
type Listener = (payload: SelectedTextNodePayload) => void
const listeners = new Set<Listener>()
let pendingPayload: SelectedTextNodePayload | null = null
export function queueSelectedTextInsertion(payload: SelectedTextNodePayload) {
pendingPayload = payload
if (listeners.size === 0) return
for (const listener of listeners) {
listener(payload)
}
pendingPayload = null
}
export function subscribeSelectedTextInsertion(listener: Listener) {
listeners.add(listener)
if (pendingPayload) {
listener(pendingPayload)
pendingPayload = null
}
return () => {
listeners.delete(listener)
}
}

View File

@ -33,7 +33,6 @@ export class BlockSliceAction {
mainView: "mainView",
mainEntry: "mainEntry",
mainFeed: "mainFeed",
selectedText: "selectedText",
unreadOnly: "unreadOnly",
}
get set() {

View File

@ -20,12 +20,7 @@ interface BaseContextBlock {
disabled?: boolean
}
export type ValueContextBlockType =
| "mainView"
| "mainEntry"
| "mainFeed"
| "selectedText"
| "unreadOnly"
export type ValueContextBlockType = "mainView" | "mainEntry" | "mainFeed" | "unreadOnly"
export interface AbstractValueContextBlock<T extends string> extends BaseContextBlock {
type: T
value: string

View File

@ -95,7 +95,6 @@ const EntryContentImpl: Component<EntryContentProps> = ({
})
return () => {
removeBlock(BlockSliceAction.SPECIAL_TYPES.mainEntry)
removeBlock(BlockSliceAction.SPECIAL_TYPES.selectedText)
}
}, [addOrUpdateBlock, entryId, removeBlock])
const animationController = useAnimationControls()

View File

@ -8,7 +8,12 @@ import { cn } from "@follow/utils"
import { ErrorBoundary } from "@sentry/react"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { AIChatPanelStyle, useAIChatPanelStyle, useAIPanelVisibility } from "~/atoms/settings/ai"
import {
AIChatPanelStyle,
setAIPanelVisibility,
useAIChatPanelStyle,
useAIPanelVisibility,
} from "~/atoms/settings/ai"
import { useUISettingKey } from "~/atoms/settings/ui"
import { ShadowDOM } from "~/components/common/ShadowDOM"
import type { TocRef } from "~/components/ui/markdown/components/Toc"
@ -16,8 +21,7 @@ import { useInPeekModal } from "~/components/ui/modal/inspire/InPeekModal"
import { readableContentMaxWidthClassName } from "~/constants/ui"
import { useRenderStyle } from "~/hooks/biz/useRenderStyle"
import type { TextSelectionEvent } from "~/lib/simple-text-selection"
import { useBlockActions } from "~/modules/ai-chat/store/hooks"
import { BlockSliceAction } from "~/modules/ai-chat/store/slices/block.slice"
import { queueSelectedTextInsertion } from "~/modules/ai-chat/editor/plugins/selection/selectedTextBridge"
import { EntryContentHTMLRenderer } from "~/modules/renderer/html"
import { EntryContentMarkdownRenderer } from "~/modules/renderer/markdown"
import { WrappedElementProvider } from "~/providers/wrapped-element-provider"
@ -29,6 +33,7 @@ import { EntryRenderError } from "../entry-content/EntryRenderError"
import { ReadabilityNotice } from "../entry-content/ReadabilityNotice"
import { EntryAttachments } from "../EntryAttachments"
import { EntryTitle } from "../EntryTitle"
import { TextSelectionToolbar } from "../selection/TextSelectionToolbar"
import { MediaTranscript, TranscriptToggle, useTranscription } from "./shared"
import { ArticleAudioPlayer } from "./shared/AudioPlayer"
import type { EntryLayoutProps } from "./types"
@ -48,30 +53,44 @@ export const ArticleLayout: React.FC<EntryLayoutProps> = ({
const feed = useFeedById(entry?.feedId)
const isInbox = useIsInbox(entry?.inboxId)
const [showTranscript, setShowTranscript] = useState(false)
const [textSelection, setTextSelection] = useState<TextSelectionEvent | null>(null)
const { content } = useEntryContent(entryId)
const customCSS = useUISettingKey("customCSS")
const { addOrUpdateBlock, removeBlock } = useBlockActions()
const handleTextSelect = useCallback(
(event: TextSelectionEvent) => {
addOrUpdateBlock({
id: BlockSliceAction.SPECIAL_TYPES.selectedText,
type: "selectedText",
value: event.selectedText,
})
},
[addOrUpdateBlock],
)
const handleTextSelect = useCallback((event: TextSelectionEvent) => {
setTextSelection(event)
}, [])
const handleSelectionClear = useCallback(() => {
removeBlock(BlockSliceAction.SPECIAL_TYPES.selectedText)
}, [removeBlock])
setTextSelection(null)
}, [])
const aiChatPanelStyle = useAIChatPanelStyle()
const isAIPanelVisible = useAIPanelVisibility()
const shouldShowAISummary = aiChatPanelStyle === AIChatPanelStyle.Floating || !isAIPanelVisible
const handleAskAI = useCallback(
(selectionEvent?: TextSelectionEvent) => {
const pendingSelection = selectionEvent ?? textSelection
if (!pendingSelection?.selectedText) return
queueSelectedTextInsertion({
text: pendingSelection.selectedText,
sourceEntryId: entryId,
timestamp: pendingSelection.timestamp,
})
setAIPanelVisibility(true)
handleSelectionClear()
},
[entryId, handleSelectionClear, textSelection],
)
useEffect(() => {
if (!showTranscript) return
handleSelectionClear()
}, [showTranscript, handleSelectionClear])
if (!entry) return null
return (
@ -120,6 +139,11 @@ export const ArticleLayout: React.FC<EntryLayoutProps> = ({
)}
</ErrorBoundary>
</div>
<TextSelectionToolbar
selection={textSelection}
onRequestClose={handleSelectionClear}
onAskAI={handleAskAI}
/>
</WrappedElementProvider>
<EntryAttachments entryId={entryId} />

View File

@ -0,0 +1,192 @@
import { Spring } from "@follow/components/constants/spring.js"
import { RootPortal } from "@follow/components/ui/portal/index.js"
import { cn } from "@follow/utils"
import { m } from "motion/react"
import type { CSSProperties, MouseEventHandler } from "react"
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import { copyToClipboard } from "~/lib/clipboard"
import type { TextSelectionEvent } from "~/lib/simple-text-selection"
const styles = {
toolbar: {
backgroundImage:
"linear-gradient(to bottom right, rgba(var(--color-background) / 0.98), rgba(var(--color-background) / 0.95))",
boxShadow:
"0 8px 24px rgba(0, 0, 0, 0.12), 0 4px 12px rgba(0, 0, 0, 0.08), 0 2px 6px rgba(0, 0, 0, 0.06), 0 4px 16px hsl(var(--fo-a) / 0.08), 0 2px 8px hsl(var(--fo-a) / 0.06), 0 1px 3px rgba(0, 0, 0, 0.04)",
} as CSSProperties,
innerGlow: {
background:
"linear-gradient(to bottom right, hsl(var(--fo-a) / 0.02), transparent, hsl(var(--fo-a) / 0.02))",
} as CSSProperties,
}
type TextSelectionToolbarProps = {
selection: TextSelectionEvent | null
onRequestClose: () => void
onAskAI?: (selection: TextSelectionEvent) => void
}
const DEFAULT_DIMENSIONS = {
width: 220,
height: 48,
}
const VIEWPORT_PADDING = 12
export function TextSelectionToolbar({
selection,
onRequestClose,
onAskAI,
}: TextSelectionToolbarProps) {
const { t } = useTranslation()
const toolbarRef = useRef<HTMLDivElement | null>(null)
const [toolbarSize, setToolbarSize] = useState(DEFAULT_DIMENSIONS)
const [copied, setCopied] = useState(false)
const [viewport, setViewport] = useState(() => getViewport())
useEffect(() => {
const handleResize = () => setViewport(getViewport())
window.addEventListener("resize", handleResize)
return () => {
window.removeEventListener("resize", handleResize)
}
}, [])
useEffect(() => {
if (!selection) return
const handleScroll = () => onRequestClose()
window.addEventListener("scroll", handleScroll, true)
return () => {
window.removeEventListener("scroll", handleScroll, true)
}
}, [selection, onRequestClose])
useLayoutEffect(() => {
if (!selection || !toolbarRef.current) return
const rect = toolbarRef.current.getBoundingClientRect()
setToolbarSize({
width: rect.width || DEFAULT_DIMENSIONS.width,
height: rect.height || DEFAULT_DIMENSIONS.height,
})
}, [selection, copied])
useEffect(() => {
if (!copied) return
const timer = setTimeout(() => setCopied(false), 1600)
return () => clearTimeout(timer)
}, [copied])
const position = useMemo(() => {
if (!selection) return null
const { rect } = selection
const toolbarHeight = toolbarSize.height || DEFAULT_DIMENSIONS.height
const toolbarWidth = toolbarSize.width || DEFAULT_DIMENSIONS.width
const viewportWidth = viewport.width || toolbarWidth
let top = rect.top - toolbarHeight - VIEWPORT_PADDING
if (top < VIEWPORT_PADDING) {
top = rect.bottom + VIEWPORT_PADDING
}
let left = rect.left + rect.width / 2 - toolbarWidth / 2
const maxLeft = Math.max(VIEWPORT_PADDING, viewportWidth - toolbarWidth - VIEWPORT_PADDING)
left = clamp(left, VIEWPORT_PADDING, maxLeft)
return { top, left }
}, [selection, toolbarSize, viewport])
const handleCopy = useCallback(async () => {
if (!selection) return
await copyToClipboard(selection.selectedText)
setCopied(true)
}, [selection])
const handleMouseDown: MouseEventHandler<HTMLDivElement> = (event) => {
event.preventDefault()
}
if (!selection || !position) return null
return (
<RootPortal>
<m.div
ref={toolbarRef}
style={{
top: position.top,
left: position.left,
...styles.toolbar,
}}
className="pointer-events-auto fixed z-[70] rounded-full border border-border/50 bg-material-ultra-thick px-1.5 py-1 backdrop-blur-background"
onMouseDown={handleMouseDown}
layout="position"
initial={{ opacity: 0, y: 4, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={Spring.presets.smooth}
>
{/* Inner glow layer */}
<div
className="pointer-events-none absolute inset-0 rounded-full"
style={styles.innerGlow}
/>
<div className="relative flex items-center gap-1 text-[0.85rem] font-medium text-text">
<ToolbarButton
iconClassName={copied ? "i-mgc-check-cute-re" : "i-mgc-copy-cute-re"}
label={
copied
? t("entry_content.selection_toolbar.copied")
: t("entry_content.selection_toolbar.copy")
}
onClick={handleCopy}
active={copied}
/>
{onAskAI ? (
<ToolbarButton
iconClassName="i-mingcute-sparkles-2-line"
label={t("entry_content.selection_toolbar.ask_ai")}
onClick={() => onAskAI(selection)}
/>
) : null}
</div>
</m.div>
</RootPortal>
)
}
type ToolbarButtonProps = {
iconClassName: string
label: string
onClick?: () => void
active?: boolean
}
function ToolbarButton({ iconClassName, label, onClick, active }: ToolbarButtonProps) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm transition-all duration-200",
active
? "bg-fill/80 text-text shadow-sm"
: "text-text-secondary hover:bg-fill/60 hover:text-text active:scale-95",
)}
aria-label={label}
>
<i className={cn("text-base", iconClassName)} />
<span>{label}</span>
</button>
)
}
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max)
function getViewport() {
if (typeof window === "undefined") {
return { width: 0, height: 0 }
}
return { width: window.innerWidth, height: window.innerHeight }
}

View File

@ -174,6 +174,9 @@
"entry_content.readability_notice": "This content is provided by Readability. If you find typographical anomalies, please go to the source site to view the original content.",
"entry_content.render_error": "Render error:",
"entry_content.report_issue": "Report issue",
"entry_content.selection_toolbar.ask_ai": "Ask AI",
"entry_content.selection_toolbar.copied": "Copied",
"entry_content.selection_toolbar.copy": "Copy",
"entry_content.web_app_notice": "Maybe web app doesn't support this content type. But you can download the desktop app.",
"entry_list.zero_unread": "Zero Unread",
"entry_list_header.ai_timeline": "AI timeline",

View File

@ -173,6 +173,9 @@
"entry_content.readability_notice": "このコンテンツは Readability により提供されています。誤字などを発見した場合は、元のサイトでオリジナルのコンテンツをご覧ください。",
"entry_content.render_error": "レンダリングエラー:",
"entry_content.report_issue": "問題を報告",
"entry_content.selection_toolbar.ask_ai": "AI に質問",
"entry_content.selection_toolbar.copied": "コピーしました",
"entry_content.selection_toolbar.copy": "コピー",
"entry_content.web_app_notice": "このコンテンツタイプはウェブアプリではサポートされていないかもしれません。デスクトップアプリをダウンロードしてください。",
"entry_list.zero_unread": "未読ゼロ",
"entry_list_header.ai_timeline": "AIタイムライン",

View File

@ -173,6 +173,9 @@
"entry_content.readability_notice": "此内容由 Readability 提供。如果你发现排版异常,请访问源站查看原始内容。",
"entry_content.render_error": "渲染错误:",
"entry_content.report_issue": "反馈问题",
"entry_content.selection_toolbar.ask_ai": "询问 AI",
"entry_content.selection_toolbar.copied": "已复制",
"entry_content.selection_toolbar.copy": "复制",
"entry_content.web_app_notice": "网页版不支持展示此类型,请下载客户端查看。",
"entry_list.zero_unread": "全部已读",
"entry_list_header.ai_timeline": "AI 时间线",

View File

@ -171,6 +171,9 @@
"entry_content.readability_notice": "此內容由'可讀模式'提供。如果您發現排版異常,請前往網站查看原始內容。",
"entry_content.render_error": "渲染錯誤:",
"entry_content.report_issue": "報告問題",
"entry_content.selection_toolbar.ask_ai": "詢問 AI",
"entry_content.selection_toolbar.copied": "已複製",
"entry_content.selection_toolbar.copy": "複製",
"entry_content.web_app_notice": "Web 應用程式不支援此類型內容。您可以下載桌面應用程式。",
"entry_list.zero_unread": "全部已讀",
"entry_list_header.ai_timeline": "AI 時間線",