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:
parent
c17957f98e
commit
7797b7b8fd
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
export * from "./file-upload"
|
||||
export * from "./mention"
|
||||
export * from "./selection"
|
||||
export * from "./shortcut"
|
||||
|
|
|
|||
|
|
@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'")
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
export * from "./insertSelectedTextNode"
|
||||
export * from "./selectedTextBridge"
|
||||
export * from "./SelectedTextNode"
|
||||
export * from "./SelectedTextNodeComponent"
|
||||
export * from "./SelectedTextPlugin"
|
||||
|
|
@ -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(" ")])
|
||||
})
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -33,7 +33,6 @@ export class BlockSliceAction {
|
|||
mainView: "mainView",
|
||||
mainEntry: "mainEntry",
|
||||
mainFeed: "mainFeed",
|
||||
selectedText: "selectedText",
|
||||
unreadOnly: "unreadOnly",
|
||||
}
|
||||
get set() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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タイムライン",
|
||||
|
|
|
|||
|
|
@ -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 时间线",
|
||||
|
|
|
|||
|
|
@ -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 時間線",
|
||||
|
|
|
|||
Loading…
Reference in New Issue