mirror of
https://github.com/reconurge/flowsint.git
synced 2026-07-10 16:13:05 -05:00
feat(app): mentionlist
This commit is contained in:
BIN
flowsint-app/.DS_Store
vendored
Normal file
BIN
flowsint-app/.DS_Store
vendored
Normal file
Binary file not shown.
@@ -57,6 +57,7 @@
|
|||||||
"@tiptap/extension-horizontal-rule": "^2.12.0",
|
"@tiptap/extension-horizontal-rule": "^2.12.0",
|
||||||
"@tiptap/extension-image": "^2.12.0",
|
"@tiptap/extension-image": "^2.12.0",
|
||||||
"@tiptap/extension-link": "^2.12.0",
|
"@tiptap/extension-link": "^2.12.0",
|
||||||
|
"@tiptap/extension-mention": "2.12.0",
|
||||||
"@tiptap/extension-placeholder": "^2.12.0",
|
"@tiptap/extension-placeholder": "^2.12.0",
|
||||||
"@tiptap/extension-subscript": "^2.12.0",
|
"@tiptap/extension-subscript": "^2.12.0",
|
||||||
"@tiptap/extension-superscript": "^2.12.0",
|
"@tiptap/extension-superscript": "^2.12.0",
|
||||||
@@ -70,6 +71,7 @@
|
|||||||
"@tiptap/pm": "^2.12.0",
|
"@tiptap/pm": "^2.12.0",
|
||||||
"@tiptap/react": "^2.12.0",
|
"@tiptap/react": "^2.12.0",
|
||||||
"@tiptap/starter-kit": "^2.12.0",
|
"@tiptap/starter-kit": "^2.12.0",
|
||||||
|
"@tiptap/suggestion": "^3.15.3",
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
"@xterm/addon-webgl": "^0.18.0",
|
"@xterm/addon-webgl": "^0.18.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^5.5.0",
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { Skeleton } from '@/components/ui/skeleton'
|
|||||||
import {
|
import {
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
Trash2,
|
Trash2,
|
||||||
Save,
|
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronsRight,
|
ChevronsRight,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
@@ -30,11 +29,13 @@ import {
|
|||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
import { queryKeys } from '@/api/query-keys'
|
import { queryKeys } from '@/api/query-keys'
|
||||||
import { useCreateAnalysis } from '@/hooks/use-create-analysis'
|
import { useCreateAnalysis } from '@/hooks/use-create-analysis'
|
||||||
|
import { SaveStatusBadge } from '@/components/shared/save-status-badge'
|
||||||
|
|
||||||
interface AnalysisEditorProps {
|
interface AnalysisEditorProps {
|
||||||
// Core data
|
// Core data
|
||||||
analysis: Analysis | null
|
analysis: Analysis | null
|
||||||
investigationId: string
|
investigationId: string
|
||||||
|
ownerId?: string
|
||||||
|
|
||||||
// Callbacks
|
// Callbacks
|
||||||
onAnalysisUpdate?: (analysis: Analysis) => void
|
onAnalysisUpdate?: (analysis: Analysis) => void
|
||||||
@@ -83,7 +84,6 @@ export const AnalysisEditor = ({
|
|||||||
type: string
|
type: string
|
||||||
}
|
}
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
// State/refs for editor
|
// State/refs for editor
|
||||||
const editorContentRef = useRef<any>('')
|
const editorContentRef = useRef<any>('')
|
||||||
const [titleValue, setTitleValue] = useState('')
|
const [titleValue, setTitleValue] = useState('')
|
||||||
@@ -340,7 +340,7 @@ export const AnalysisEditor = ({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span
|
<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)}
|
onClick={() => setIsEditingTitle(true)}
|
||||||
>
|
>
|
||||||
{titleValue || 'Untitled Analysis'}
|
{titleValue || 'Untitled Analysis'}
|
||||||
@@ -352,32 +352,7 @@ export const AnalysisEditor = ({
|
|||||||
{/* Action buttons */}
|
{/* Action buttons */}
|
||||||
{showActions && (
|
{showActions && (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Button
|
<SaveStatusBadge status={saveStatus} />
|
||||||
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>
|
|
||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<div>
|
<div>
|
||||||
@@ -402,6 +377,7 @@ export const AnalysisEditor = ({
|
|||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{type !== 'analysis' && (
|
{type !== 'analysis' && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => createMutation.mutate()}
|
onClick={() => createMutation.mutate()}
|
||||||
@@ -454,10 +430,9 @@ export const AnalysisEditor = ({
|
|||||||
className="w-full h-full"
|
className="w-full h-full"
|
||||||
editorContentClassName="p-5 min-h-[300px]"
|
editorContentClassName="p-5 min-h-[300px]"
|
||||||
output="json"
|
output="json"
|
||||||
placeholder="Enter your analysis..."
|
placeholder={'Enter your analysis...'}
|
||||||
autofocus={true}
|
autofocus={true}
|
||||||
showToolbar={showToolbar}
|
showToolbar={showToolbar}
|
||||||
editable={true}
|
|
||||||
editorClassName="focus:outline-hidden"
|
editorClassName="focus:outline-hidden"
|
||||||
onEditorReady={setEditor}
|
onEditorReady={setEditor}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ export * from './selection'
|
|||||||
export * from './unset-all-marks'
|
export * from './unset-all-marks'
|
||||||
export * from './reset-marks-on-enter'
|
export * from './reset-marks-on-enter'
|
||||||
export * from './file-handler'
|
export * from './file-handler'
|
||||||
|
export * from './mention'
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './mention'
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -16,7 +16,8 @@ import {
|
|||||||
Color,
|
Color,
|
||||||
UnsetAllMarks,
|
UnsetAllMarks,
|
||||||
ResetMarksOnEnter,
|
ResetMarksOnEnter,
|
||||||
FileHandler
|
FileHandler,
|
||||||
|
Mention
|
||||||
} from '../extensions'
|
} from '../extensions'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { fileToBase64, getOutput, randomId } from '../utils'
|
import { fileToBase64, getOutput, randomId } from '../utils'
|
||||||
@@ -166,6 +167,7 @@ const createExtensions = ({
|
|||||||
HorizontalRule,
|
HorizontalRule,
|
||||||
ResetMarksOnEnter,
|
ResetMarksOnEnter,
|
||||||
CodeBlockLowlight,
|
CodeBlockLowlight,
|
||||||
|
Mention,
|
||||||
Placeholder.configure({ placeholder: () => placeholder })
|
Placeholder.configure({ placeholder: () => placeholder })
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
86
flowsint-app/src/components/shared/save-status-badge.tsx
Normal file
86
flowsint-app/src/components/shared/save-status-badge.tsx
Normal 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'
|
||||||
@@ -56,7 +56,7 @@ export const useGraphInitialization = ({
|
|||||||
if (graphRef.current && typeof graphRef.current.centerAt === 'function') {
|
if (graphRef.current && typeof graphRef.current.centerAt === 'function') {
|
||||||
graphRef.current.centerAt(x, y, 400)
|
graphRef.current.centerAt(x, y, 400)
|
||||||
if (typeof graphRef.current.zoom === 'function') {
|
if (typeof graphRef.current.zoom === 'function') {
|
||||||
graphRef.current.zoom(6, 400)
|
graphRef.current.zoom(12, 400)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -95,10 +95,7 @@ export const useGraphInitialization = ({
|
|||||||
if (!graphInstance || isGraphReadyRef.current) return
|
if (!graphInstance || isGraphReadyRef.current) return
|
||||||
|
|
||||||
// Wait for graph methods to be available
|
// Wait for graph methods to be available
|
||||||
if (
|
if (typeof graphInstance.zoom !== 'function' || typeof graphInstance.zoomToFit !== 'function') {
|
||||||
typeof graphInstance.zoom !== 'function' ||
|
|
||||||
typeof graphInstance.zoomToFit !== 'function'
|
|
||||||
) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,78 +1,11 @@
|
|||||||
import { memo } from 'react'
|
import { memo } from 'react'
|
||||||
import { Cloud, CloudOff, Loader2, Check, AlertCircle } from 'lucide-react'
|
import { useGraphSaveStatus } from '@/stores/graph-save-status-store'
|
||||||
import { SaveStatus } from '@/hooks/use-save-node-positions'
|
import { SaveStatusBadge } from '@/components/shared/save-status-badge'
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
|
|
||||||
|
|
||||||
interface SaveStatusIndicatorProps {
|
export const SaveStatusIndicator = memo(() => {
|
||||||
status: SaveStatus
|
const status = useGraphSaveStatus((s) => s.saveStatus)
|
||||||
}
|
|
||||||
|
|
||||||
export const SaveStatusIndicator = memo(({ status }: SaveStatusIndicatorProps) => {
|
return <SaveStatusBadge status={status} />
|
||||||
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>
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
SaveStatusIndicator.displayName = 'SaveStatusIndicator'
|
SaveStatusIndicator.displayName = 'SaveStatusIndicator'
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ interface GraphState {
|
|||||||
// === Action Type for Form ===
|
// === Action Type for Form ===
|
||||||
currentNodeType: ActionItem | null
|
currentNodeType: ActionItem | null
|
||||||
setCurrentNodeType: (nodeType: ActionItem | null) => void
|
setCurrentNodeType: (nodeType: ActionItem | null) => void
|
||||||
|
setCurrentNodeFromId: (nodeId: string) => GraphNode | null
|
||||||
handleOpenFormModal: (selectedItem: ActionItem | undefined) => void
|
handleOpenFormModal: (selectedItem: ActionItem | undefined) => void
|
||||||
|
|
||||||
// === Action Type for Edit form ===
|
// === Action Type for Edit form ===
|
||||||
@@ -232,6 +233,18 @@ export const useGraphStore = create<GraphState>()(
|
|||||||
set({ currentNode: node })
|
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) => {
|
setCurrentEdge: (edge) => {
|
||||||
const { currentEdge } = get()
|
const { currentEdge } = get()
|
||||||
// Only update if the edge is actually different
|
// Only update if the edge is actually different
|
||||||
|
|||||||
Reference in New Issue
Block a user