From 7797b7b8fd6e9087c9fb1c87b104f4b694f2eef3 Mon Sep 17 00:00:00 2001 From: Innei Date: Wed, 19 Nov 2025 22:39:15 +0800 Subject: [PATCH] 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 --- .../renderer/src/lib/simple-text-selection.ts | 45 +++- .../ai-chat/components/layouts/ChatInput.tsx | 6 +- .../components/message/ai-block-constants.ts | 7 - .../message/useContextBlockPresentation.tsx | 5 - .../src/modules/ai-chat/editor/index.ts | 3 +- .../modules/ai-chat/editor/plugins/index.tsx | 1 + .../plugins/selection/SelectedTextNode.tsx | 135 ++++++++++++ .../selection/SelectedTextNodeComponent.tsx | 25 +++ .../plugins/selection/SelectedTextPlugin.tsx | 22 ++ .../ai-chat/editor/plugins/selection/index.ts | 5 + .../selection/insertSelectedTextNode.ts | 29 +++ .../plugins/selection/selectedTextBridge.ts | 29 +++ .../ai-chat/store/slices/block.slice.ts | 1 - .../src/modules/ai-chat/store/types.ts | 7 +- .../modules/entry-content/EntryContent.tsx | 1 - .../components/layouts/ArticleLayout.tsx | 56 +++-- .../selection/TextSelectionToolbar.tsx | 192 ++++++++++++++++++ locales/app/en.json | 3 + locales/app/ja.json | 3 + locales/app/zh-CN.json | 3 + locales/app/zh-TW.json | 3 + 21 files changed, 531 insertions(+), 50 deletions(-) create mode 100644 apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/selection/SelectedTextNode.tsx create mode 100644 apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/selection/SelectedTextNodeComponent.tsx create mode 100644 apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/selection/SelectedTextPlugin.tsx create mode 100644 apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/selection/index.ts create mode 100644 apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/selection/insertSelectedTextNode.ts create mode 100644 apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/selection/selectedTextBridge.ts create mode 100644 apps/desktop/layer/renderer/src/modules/entry-content/components/selection/TextSelectionToolbar.tsx diff --git a/apps/desktop/layer/renderer/src/lib/simple-text-selection.ts b/apps/desktop/layer/renderer/src/lib/simple-text-selection.ts index 57c3871a5..00328207e 100644 --- a/apps/desktop/layer/renderer/src/lib/simple-text-selection.ts +++ b/apps/desktop/layer/renderer/src/lib/simple-text-selection.ts @@ -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, + } +} diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatInput.tsx b/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatInput.tsx index 7ffa01060..3444267d3 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatInput.tsx +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatInput.tsx @@ -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" /> diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/ai-block-constants.ts b/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/ai-block-constants.ts index 78fdda371..9a5845cdf 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/ai-block-constants.ts +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/ai-block-constants.ts @@ -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", diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/useContextBlockPresentation.tsx b/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/useContextBlockPresentation.tsx index 65a020636..74ce9bdef 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/useContextBlockPresentation.tsx +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/useContextBlockPresentation.tsx @@ -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" diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/editor/index.ts b/apps/desktop/layer/renderer/src/modules/ai-chat/editor/index.ts index 4e02f832c..147ce8175 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/editor/index.ts +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/editor/index.ts @@ -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, ] diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/index.tsx b/apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/index.tsx index 768bd804d..a0946d24b 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/index.tsx +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/index.tsx @@ -1,3 +1,4 @@ export * from "./file-upload" export * from "./mention" +export * from "./selection" export * from "./shortcut" diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/selection/SelectedTextNode.tsx b/apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/selection/SelectedTextNode.tsx new file mode 100644 index 000000000..17d5e8c60 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/selection/SelectedTextNode.tsx @@ -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 + +export class SelectedTextNode extends DecoratorNode { + __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 ( + + ) + } + + override isInline(): boolean { + return false + } + + override isKeyboardSelectable(): boolean { + return false + } + + override getTextContent(): string { + return `${escapeXML(this.__text)}` + } +} + +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("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'") +} diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/selection/SelectedTextNodeComponent.tsx b/apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/selection/SelectedTextNodeComponent.tsx new file mode 100644 index 000000000..af92301b6 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/selection/SelectedTextNodeComponent.tsx @@ -0,0 +1,25 @@ +import { cn } from "@follow/utils" + +interface SelectedTextNodeComponentProps { + text: string + sourceEntryId?: string + timestamp?: number +} + +export function SelectedTextNodeComponent({ text }: SelectedTextNodeComponentProps) { + return ( + + + + "{text}" + + + ) +} diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/selection/SelectedTextPlugin.tsx b/apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/selection/SelectedTextPlugin.tsx new file mode 100644 index 000000000..e3f80f27e --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/selection/SelectedTextPlugin.tsx @@ -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] diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/selection/index.ts b/apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/selection/index.ts new file mode 100644 index 000000000..80372d449 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/selection/index.ts @@ -0,0 +1,5 @@ +export * from "./insertSelectedTextNode" +export * from "./selectedTextBridge" +export * from "./SelectedTextNode" +export * from "./SelectedTextNodeComponent" +export * from "./SelectedTextPlugin" diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/selection/insertSelectedTextNode.ts b/apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/selection/insertSelectedTextNode.ts new file mode 100644 index 000000000..adbe779eb --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/selection/insertSelectedTextNode.ts @@ -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(" ")]) + }) +} diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/selection/selectedTextBridge.ts b/apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/selection/selectedTextBridge.ts new file mode 100644 index 000000000..530a1cb53 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/selection/selectedTextBridge.ts @@ -0,0 +1,29 @@ +import type { SelectedTextNodePayload } from "./SelectedTextNode" + +type Listener = (payload: SelectedTextNodePayload) => void + +const listeners = new Set() +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) + } +} diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/store/slices/block.slice.ts b/apps/desktop/layer/renderer/src/modules/ai-chat/store/slices/block.slice.ts index 28a708368..2a034dba8 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/store/slices/block.slice.ts +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/store/slices/block.slice.ts @@ -33,7 +33,6 @@ export class BlockSliceAction { mainView: "mainView", mainEntry: "mainEntry", mainFeed: "mainFeed", - selectedText: "selectedText", unreadOnly: "unreadOnly", } get set() { diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/store/types.ts b/apps/desktop/layer/renderer/src/modules/ai-chat/store/types.ts index 6fbf54e1f..8745bae65 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/store/types.ts +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/store/types.ts @@ -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 extends BaseContextBlock { type: T value: string diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/EntryContent.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/EntryContent.tsx index 3988db635..643d90a31 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-content/EntryContent.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-content/EntryContent.tsx @@ -95,7 +95,6 @@ const EntryContentImpl: Component = ({ }) return () => { removeBlock(BlockSliceAction.SPECIAL_TYPES.mainEntry) - removeBlock(BlockSliceAction.SPECIAL_TYPES.selectedText) } }, [addOrUpdateBlock, entryId, removeBlock]) const animationController = useAnimationControls() diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/ArticleLayout.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/ArticleLayout.tsx index db9b1ceb8..4691b9450 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/ArticleLayout.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/ArticleLayout.tsx @@ -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 = ({ const feed = useFeedById(entry?.feedId) const isInbox = useIsInbox(entry?.inboxId) const [showTranscript, setShowTranscript] = useState(false) + const [textSelection, setTextSelection] = useState(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 = ({ )} + diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/components/selection/TextSelectionToolbar.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/components/selection/TextSelectionToolbar.tsx new file mode 100644 index 000000000..21bc45d86 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/entry-content/components/selection/TextSelectionToolbar.tsx @@ -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(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 = (event) => { + event.preventDefault() + } + + if (!selection || !position) return null + + return ( + + + {/* Inner glow layer */} +
+
+ + {onAskAI ? ( + onAskAI(selection)} + /> + ) : null} +
+ + + ) +} + +type ToolbarButtonProps = { + iconClassName: string + label: string + onClick?: () => void + active?: boolean +} + +function ToolbarButton({ iconClassName, label, onClick, active }: ToolbarButtonProps) { + return ( + + ) +} + +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 } +} diff --git a/locales/app/en.json b/locales/app/en.json index 527f84e77..aebd72661 100644 --- a/locales/app/en.json +++ b/locales/app/en.json @@ -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", diff --git a/locales/app/ja.json b/locales/app/ja.json index ef8cf85b1..bf0ebfcf3 100644 --- a/locales/app/ja.json +++ b/locales/app/ja.json @@ -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タイムライン", diff --git a/locales/app/zh-CN.json b/locales/app/zh-CN.json index 6b9cb43b1..1a20525ea 100644 --- a/locales/app/zh-CN.json +++ b/locales/app/zh-CN.json @@ -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 时间线", diff --git a/locales/app/zh-TW.json b/locales/app/zh-TW.json index 0feed2a02..97ae5105c 100644 --- a/locales/app/zh-TW.json +++ b/locales/app/zh-TW.json @@ -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 時間線",