feat(app): mentionlist

This commit is contained in:
dextmorgn
2026-01-13 09:17:09 +01:00
parent be0880e77d
commit b81274ae0b
12 changed files with 471 additions and 115 deletions

BIN
flowsint-app/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -57,6 +57,7 @@
"@tiptap/extension-horizontal-rule": "^2.12.0",
"@tiptap/extension-image": "^2.12.0",
"@tiptap/extension-link": "^2.12.0",
"@tiptap/extension-mention": "2.12.0",
"@tiptap/extension-placeholder": "^2.12.0",
"@tiptap/extension-subscript": "^2.12.0",
"@tiptap/extension-superscript": "^2.12.0",
@@ -70,6 +71,7 @@
"@tiptap/pm": "^2.12.0",
"@tiptap/react": "^2.12.0",
"@tiptap/starter-kit": "^2.12.0",
"@tiptap/suggestion": "^3.15.3",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",

View File

@@ -8,7 +8,6 @@ import { Skeleton } from '@/components/ui/skeleton'
import {
PlusIcon,
Trash2,
Save,
ChevronDown,
ChevronsRight,
ExternalLink,
@@ -30,11 +29,13 @@ import {
} from '@/components/ui/dropdown-menu'
import { queryKeys } from '@/api/query-keys'
import { useCreateAnalysis } from '@/hooks/use-create-analysis'
import { SaveStatusBadge } from '@/components/shared/save-status-badge'
interface AnalysisEditorProps {
// Core data
analysis: Analysis | null
investigationId: string
ownerId?: string
// Callbacks
onAnalysisUpdate?: (analysis: Analysis) => void
@@ -83,7 +84,6 @@ export const AnalysisEditor = ({
type: string
}
const queryClient = useQueryClient()
// State/refs for editor
const editorContentRef = useRef<any>('')
const [titleValue, setTitleValue] = useState('')
@@ -340,7 +340,7 @@ export const AnalysisEditor = ({
/>
) : (
<span
className="text-md font-medium cursor-pointer hover:text-primary truncate min-w-0 flex-1"
className={`text-md font-medium truncate min-w-0 flex-1 ${'cursor-pointer hover:text-primary'}`}
onClick={() => setIsEditingTitle(true)}
>
{titleValue || 'Untitled Analysis'}
@@ -352,32 +352,7 @@ export const AnalysisEditor = ({
{/* Action buttons */}
{showActions && (
<div className="flex items-center gap-1">
<Button
size="icon"
variant="ghost"
onClick={handleManualSave}
disabled={!analysis || saveMutation.isPending}
title={
saveStatus === 'saved'
? 'Saved'
: saveStatus === 'saving'
? 'Saving...'
: 'Save'
}
className="h-8 w-8 relative"
>
<Save className="w-4 h-4" strokeWidth={1.5} />
{saveStatus === 'saved' && (
<div className="absolute top-1 right-1 w-2 h-2 bg-green-500 rounded-full" />
)}
{saveStatus === 'saving' && (
<div className="absolute top-1 right-1 w-2 h-2 bg-yellow-500 rounded-full animate-pulse" />
)}
{saveStatus === 'unsaved' && (
<div className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full" />
)}
</Button>
<SaveStatusBadge status={saveStatus} />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div>
@@ -402,6 +377,7 @@ export const AnalysisEditor = ({
</Link>
</DropdownMenuItem>
)}
{type !== 'analysis' && (
<DropdownMenuItem
onClick={() => createMutation.mutate()}
@@ -454,10 +430,9 @@ export const AnalysisEditor = ({
className="w-full h-full"
editorContentClassName="p-5 min-h-[300px]"
output="json"
placeholder="Enter your analysis..."
placeholder={'Enter your analysis...'}
autofocus={true}
showToolbar={showToolbar}
editable={true}
editorClassName="focus:outline-hidden"
onEditorReady={setEditor}
/>

View File

@@ -7,3 +7,4 @@ export * from './selection'
export * from './unset-all-marks'
export * from './reset-marks-on-enter'
export * from './file-handler'
export * from './mention'

View File

@@ -0,0 +1 @@
export * from './mention'

View File

@@ -0,0 +1,115 @@
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'
import { cn } from '@/lib/utils'
import { ItemType } from '@/stores/node-display-settings'
import { useIcon } from '@/hooks/use-icon'
export interface MentionItem {
value: string
type: ItemType
nodeId: string
}
export interface MentionListProps {
items: MentionItem[]
command: (item: { label: string; type: ItemType; nodeId: string }) => void
}
export interface MentionListRef {
onKeyDown: (props: { event: KeyboardEvent }) => boolean
}
const MentionList = forwardRef<MentionListRef, MentionListProps>((props, ref) => {
const [selectedIndex, setSelectedIndex] = useState(0)
const selectItem = (index: number) => {
const item = props.items[index]
if (item) {
props.command({ label: item.value, type: item.type, nodeId: item.nodeId })
}
}
const upHandler = () => {
setSelectedIndex((selectedIndex + props.items.length - 1) % props.items.length)
}
const downHandler = () => {
setSelectedIndex((selectedIndex + 1) % props.items.length)
}
const enterHandler = () => {
selectItem(selectedIndex)
}
useEffect(() => setSelectedIndex(0), [props.items])
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }: { event: KeyboardEvent }) => {
if (event.key === 'ArrowUp') {
upHandler()
return true
}
if (event.key === 'ArrowDown') {
downHandler()
return true
}
if (event.key === 'Enter') {
enterHandler()
return true
}
return false
}
}))
return (
<div className="z-50 min-w-32 max-w-[400px] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md">
{props.items.length ? (
<div className="overflow-y-auto max-h-[300px]">
{props.items.map((item, index) => (
<MentionListItem
item={item}
index={index}
selectedIndex={selectedIndex}
selectItem={selectItem}
/>
))}
</div>
) : (
<div className="px-2 py-1.5 text-sm text-muted-foreground">No results</div>
)}
</div>
)
})
type MentionItemProps = {
item: MentionItem
index: number
selectedIndex: number
selectItem: (index: number) => void
}
const MentionListItem = ({ item, index, selectedIndex, selectItem }: MentionItemProps) => {
const Icon = useIcon(item.type, null) ?? null
return (
<button
className={cn(
'relative flex w-full justify-start cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors',
'hover:bg-accent hover:text-accent-foreground',
'focus:bg-accent focus:text-accent-foreground',
index === selectedIndex && 'bg-accent text-accent-foreground'
)}
key={index}
onClick={() => selectItem(index)}
type="button"
>
{Icon && <Icon size={14} />}
<span className="flex-1 text-left truncate text-ellipsis">{item.value}</span>
<span className="text-xs text-muted-foreground capitalize">{item.type}</span>
</button>
)
}
MentionList.displayName = 'MentionList'
export default MentionList

View File

@@ -0,0 +1,231 @@
import { computePosition, flip, shift } from '@floating-ui/dom'
import { posToDOMRect, ReactRenderer } from '@tiptap/react'
import type { Editor } from '@tiptap/react'
import TiptapMention from '@tiptap/extension-mention'
import type { SuggestionOptions, SuggestionProps } from '@tiptap/suggestion'
import { mergeAttributes } from '@tiptap/core'
import { ReactNodeViewRenderer } from '@tiptap/react'
import { NodeViewWrapper } from '@tiptap/react'
import MentionList from './mention-list'
import type { MentionListRef, MentionItem } from './mention-list'
import { memo, useCallback } from 'react'
import { useIcon } from '@/hooks/use-icon'
import { useNodesDisplaySettings, type ItemType } from '@/stores/node-display-settings'
import { Button } from '@/components/ui/button'
import { GRAPH_COLORS } from '@/components/sketches/graph'
import { useGraphStore } from '@/stores/graph-store'
import { useGraphControls } from '@/stores/graph-controls-store'
function hexWithOpacity(hex: string, opacity: number) {
hex = hex.replace('#', '')
if (hex.length === 3) {
hex = hex
.split('')
.map((c) => c + c)
.join('')
}
const alpha = Math.round(opacity * 255)
.toString(16)
.padStart(2, '0')
.toUpperCase()
return `#${hex}${alpha}`
}
const getMentionItemsFromNodes = (): MentionItem[] => {
const nodes = useGraphStore.getState().nodes
return nodes
.map((node) => {
const label = node.data?.label || node.data?.username || node.id
const type = node.data?.type as ItemType
const nodeId = node.data?.id || node.id
if (!type) return null
return {
value: label,
type: type,
nodeId: nodeId
}
})
.filter((item): item is MentionItem => item !== null)
}
const updatePosition = (editor: Editor, element: HTMLElement) => {
const virtualElement = {
getBoundingClientRect: () =>
posToDOMRect(editor.view, editor.state.selection.from, editor.state.selection.to)
}
computePosition(virtualElement, element, {
placement: 'bottom-start',
strategy: 'absolute',
middleware: [shift(), flip()]
}).then(({ x, y, strategy }) => {
element.style.width = 'max-content'
element.style.position = strategy
element.style.left = `${x}px`
element.style.top = `${y}px`
})
}
// Composant React custom pour le rendu de la mention
const MentionComponent = memo((props: any) => {
const nodeId = props.node.attrs.nodeId
const type = props.node.attrs.type as ItemType | null
const Icon = type ? (useIcon(type, null) ?? null) : null
const colors = useNodesDisplaySettings((s) => s.colors)
const color = type ? (colors[type] ?? GRAPH_COLORS.NODE_DEFAULT) : GRAPH_COLORS.NODE_DEFAULT
const centerOnNode = useGraphControls((state) => state.centerOnNode)
const setCurrentNodeFromId = useGraphStore((state) => state.setCurrentNodeFromId)
const handleClick = useCallback(() => {
const node = setCurrentNodeFromId(nodeId)
if (node) {
const { x, y } = node
// Auto-zoom if enabled and node has coordinates
if (x !== undefined && y !== undefined) {
setTimeout(() => {
centerOnNode(x, y)
}, 200)
}
}
}, [nodeId, centerOnNode])
return (
<NodeViewWrapper
style={{ display: 'inline-flex', justifyItems: 'center', padding: 0, margin: 0 }}
>
<Button
variant={'ghost'}
onClick={handleClick}
className="h-5 px-.5 gap-1 cursor-pointer items-center text-foreground"
style={{
backgroundColor: hexWithOpacity(color, 0.2),
//@ts-ignore
border: `solid 1px ${hexWithOpacity(color, 0.5)}`
}}
>
{Icon && <Icon size={19} iconOnly className="opacity-60" style={{ color }} />}
{props.node.attrs.label}
</Button>
</NodeViewWrapper>
)
})
export const Mention = TiptapMention.extend({
addNodeView() {
return ReactNodeViewRenderer(MentionComponent)
},
addAttributes() {
return {
label: {
default: null,
parseHTML: (element) => element.getAttribute('data-label'),
renderHTML: (attributes) => {
if (!attributes.label) {
return {}
}
return {
'data-label': attributes.label
}
}
},
type: {
default: null,
parseHTML: (element) => element.getAttribute('data-type'),
renderHTML: (attributes) => {
if (!attributes.type) {
return {}
}
return {
'data-type': attributes.type
}
}
},
nodeId: {
default: null,
parseHTML: (element) => element.getAttribute('data-node-id'),
renderHTML: (attributes) => {
if (!attributes.nodeId) {
return {}
}
return {
'data-node-id': attributes.nodeId
}
}
}
}
},
renderHTML({ node, HTMLAttributes }) {
const children = []
children.push([
'span',
{ class: 'mention-label' },
`${this.options.suggestion.char}${node.attrs.label}`
])
return ['span', mergeAttributes({ class: 'mention' }, HTMLAttributes), ...children]
}
}).configure({
deleteTriggerWithBackspace: true,
suggestion: {
char: '@',
items: ({ query }: { query: string }) => {
const items = getMentionItemsFromNodes()
return items
.filter((item) => item.value.toLowerCase().includes(query.toLowerCase()))
.slice(0, 10)
},
render: () => {
let component: ReactRenderer<MentionListRef> | undefined
return {
onStart: (props: SuggestionProps) => {
component = new ReactRenderer(MentionList, {
props,
editor: props.editor
})
if (!props.clientRect) {
return
}
const element = component.element as HTMLElement
element.style.position = 'absolute'
element.style.zIndex = '9999'
document.body.appendChild(element)
updatePosition(props.editor, element)
},
onUpdate(props: SuggestionProps) {
if (!component) return
component.updateProps(props)
if (!props.clientRect) {
return
}
const element = component.element as HTMLElement
updatePosition(props.editor, element)
},
onKeyDown(props: { event: KeyboardEvent }) {
if (props.event.key === 'Escape') {
component?.destroy()
return true
}
return component?.ref?.onKeyDown(props) ?? false
},
onExit() {
if (!component) return
component.element.remove()
component.destroy()
}
}
}
} as Partial<SuggestionOptions>
})
export default Mention

View File

@@ -16,7 +16,8 @@ import {
Color,
UnsetAllMarks,
ResetMarksOnEnter,
FileHandler
FileHandler,
Mention
} from '../extensions'
import { cn } from '@/lib/utils'
import { fileToBase64, getOutput, randomId } from '../utils'
@@ -166,6 +167,7 @@ const createExtensions = ({
HorizontalRule,
ResetMarksOnEnter,
CodeBlockLowlight,
Mention,
Placeholder.configure({ placeholder: () => placeholder })
]

View File

@@ -0,0 +1,86 @@
import { memo } from 'react'
import { Cloud, CloudOff, Loader2, Check, AlertCircle } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
export type SaveStatusType = 'idle' | 'pending' | 'saving' | 'saved' | 'error' | 'unsaved'
interface SaveStatusBadgeProps {
status: SaveStatusType
className?: string
}
export const SaveStatusBadge = memo(({ status, className }: SaveStatusBadgeProps) => {
const getStatusConfig = () => {
// Normalize 'unsaved' to 'pending' for display
const normalizedStatus = status === 'unsaved' ? 'pending' : status
switch (normalizedStatus) {
case 'idle':
return {
icon: Cloud,
text: 'All changes saved',
className: 'text-muted-foreground/60 bg-muted/30 border-muted'
}
case 'pending':
return {
icon: Cloud,
text: 'Pending...',
className: 'text-muted-foreground bg-muted/50 border-muted'
}
case 'saving':
return {
icon: Loader2,
text: 'Saving...',
className:
'text-blue-600 bg-blue-50 dark:bg-blue-950/30 dark:text-blue-400 border-blue-200 dark:border-blue-800',
iconClassName: 'animate-spin'
}
case 'saved':
return {
icon: Check,
text: 'Saved',
className:
'text-green-600 bg-green-50 dark:bg-green-950/30 dark:text-green-400 border-green-200 dark:border-green-800'
}
case 'error':
return {
icon: AlertCircle,
text: 'Error saving',
className:
'text-red-600 bg-red-50 dark:bg-red-950/30 dark:text-red-400 border-red-200 dark:border-red-800'
}
default:
return {
icon: CloudOff,
text: 'Unknown',
className: 'text-muted-foreground bg-muted/50 border-muted'
}
}
}
const config = getStatusConfig()
const Icon = config.icon
return (
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
'flex items-center h-8 gap-1.5 px-1.5 py-1.5',
'rounded-md !bg-transparent',
'text-xs font-medium',
'transition-all duration-200',
config.className,
className
)}
>
<Icon className={cn('h-3.5 w-3.5', config.iconClassName)} />
</div>
</TooltipTrigger>
<TooltipContent>{config.text}</TooltipContent>
</Tooltip>
)
})
SaveStatusBadge.displayName = 'SaveStatusBadge'

View File

@@ -56,7 +56,7 @@ export const useGraphInitialization = ({
if (graphRef.current && typeof graphRef.current.centerAt === 'function') {
graphRef.current.centerAt(x, y, 400)
if (typeof graphRef.current.zoom === 'function') {
graphRef.current.zoom(6, 400)
graphRef.current.zoom(12, 400)
}
}
},
@@ -78,12 +78,12 @@ export const useGraphInitialization = ({
return () => {
setActions({
zoomIn: () => { },
zoomOut: () => { },
zoomToFit: () => { },
zoomToSelection: () => { },
centerOnNode: () => { },
regenerateLayout: () => { },
zoomIn: () => {},
zoomOut: () => {},
zoomToFit: () => {},
zoomToSelection: () => {},
centerOnNode: () => {},
regenerateLayout: () => {},
getViewportCenter: () => null
})
}
@@ -95,10 +95,7 @@ export const useGraphInitialization = ({
if (!graphInstance || isGraphReadyRef.current) return
// Wait for graph methods to be available
if (
typeof graphInstance.zoom !== 'function' ||
typeof graphInstance.zoomToFit !== 'function'
) {
if (typeof graphInstance.zoom !== 'function' || typeof graphInstance.zoomToFit !== 'function') {
return
}

View File

@@ -1,78 +1,11 @@
import { memo } from 'react'
import { Cloud, CloudOff, Loader2, Check, AlertCircle } from 'lucide-react'
import { SaveStatus } from '@/hooks/use-save-node-positions'
import { cn } from '@/lib/utils'
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
import { useGraphSaveStatus } from '@/stores/graph-save-status-store'
import { SaveStatusBadge } from '@/components/shared/save-status-badge'
interface SaveStatusIndicatorProps {
status: SaveStatus
}
export const SaveStatusIndicator = memo(() => {
const status = useGraphSaveStatus((s) => s.saveStatus)
export const SaveStatusIndicator = memo(({ status }: SaveStatusIndicatorProps) => {
const getStatusConfig = () => {
switch (status) {
case 'idle':
return {
icon: Cloud,
text: 'All changes saved',
className: 'text-muted-foreground/60 bg-muted/30 border-muted'
}
case 'pending':
return {
icon: Cloud,
text: 'Pending...',
className: 'text-muted-foreground bg-muted/50 border-muted'
}
case 'saving':
return {
icon: Loader2,
text: 'Saving...',
className: 'text-blue-600 bg-blue-50 dark:bg-blue-950/30 dark:text-blue-400 border-blue-200 dark:border-blue-800',
iconClassName: 'animate-spin'
}
case 'saved':
return {
icon: Check,
text: 'Saved',
className: 'text-green-600 bg-green-50 dark:bg-green-950/30 dark:text-green-400 border-green-200 dark:border-green-800'
}
case 'error':
return {
icon: AlertCircle,
text: 'Error saving',
className: 'text-red-600 bg-red-50 dark:bg-red-950/30 dark:text-red-400 border-red-200 dark:border-red-800'
}
default:
return {
icon: CloudOff,
text: 'Unknown',
className: 'text-muted-foreground bg-muted/50 border-muted'
}
}
}
const config = getStatusConfig()
const Icon = config.icon
return (
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
'flex items-center h-8 gap-1.5 px-2.5 py-1.5',
'rounded-md border',
'text-xs font-medium',
'transition-all duration-200',
config.className
)}
>
<Icon className={cn('h-3.5 w-3.5', config.iconClassName)} />
{/* <span>{config.text}</span> */}
</div>
</TooltipTrigger>
<TooltipContent>{config.text}</TooltipContent>
</Tooltip>
)
return <SaveStatusBadge status={status} />
})
SaveStatusIndicator.displayName = 'SaveStatusIndicator'

View File

@@ -55,6 +55,7 @@ interface GraphState {
// === Action Type for Form ===
currentNodeType: ActionItem | null
setCurrentNodeType: (nodeType: ActionItem | null) => void
setCurrentNodeFromId: (nodeId: string) => GraphNode | null
handleOpenFormModal: (selectedItem: ActionItem | undefined) => void
// === Action Type for Edit form ===
@@ -232,6 +233,18 @@ export const useGraphStore = create<GraphState>()(
set({ currentNode: node })
}
},
setCurrentNodeFromId: (nodeId) => {
const { currentNode, nodes } = get()
// Only update if the node is actually different
if (currentNode?.id !== nodeId) {
const node = nodes.find((n) => n.id === nodeId)
if (node) {
set({ currentNode: node })
return node
}
}
return null
},
setCurrentEdge: (edge) => {
const { currentEdge } = get()
// Only update if the edge is actually different