681 lines
30 KiB
TypeScript
681 lines
30 KiB
TypeScript
|
|
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
|
import { WorkflowData, PlotNode, PlotConnection, PlotNodeType, Entity, EntityType } from '../types';
|
|
import { Plus, Trash2, ArrowRight, BookOpen, MessageCircle, Zap, Palette, Save, Link2 } from 'lucide-react';
|
|
|
|
interface StoryWorkflowProps {
|
|
data: WorkflowData;
|
|
onUpdate: (data: WorkflowData) => void;
|
|
entities: Entity[];
|
|
onNavigateToEntity: (entityId: string) => void;
|
|
}
|
|
|
|
const CARD_WIDTH = 260;
|
|
const CARD_HEIGHT = 220;
|
|
|
|
const INITIAL_COLORS = [
|
|
'#ffffff', // White
|
|
'#dbeafe', // Blue
|
|
'#dcfce7', // Green
|
|
'#fef9c3', // Yellow
|
|
'#fee2e2', // Red
|
|
'#f3e8ff', // Purple
|
|
];
|
|
|
|
const renderTextWithLinks = (text: string, entities: Entity[], onNavigate: (id: string) => void) => {
|
|
if (!text) return <span className="text-slate-400 italic">Description...</span>;
|
|
|
|
const parts: (string | React.ReactNode)[] = [text];
|
|
|
|
entities.forEach(entity => {
|
|
if (!entity.name) return;
|
|
const regex = new RegExp(`(${entity.name})`, 'gi');
|
|
|
|
for (let i = 0; i < parts.length; i++) {
|
|
const part = parts[i];
|
|
if (typeof part === 'string') {
|
|
const split = part.split(regex);
|
|
if (split.length > 1) {
|
|
const newParts = split.map((s, idx) => {
|
|
if (s.toLowerCase() === entity.name.toLowerCase()) {
|
|
return (
|
|
<span
|
|
key={`${entity.id}-${idx}`}
|
|
onClick={(e) => { e.stopPropagation(); onNavigate(entity.id); }}
|
|
className="text-indigo-600 hover:text-indigo-800 underline decoration-indigo-300 hover:decoration-indigo-600 cursor-pointer font-medium bg-indigo-50 px-0.5 rounded transition-all"
|
|
title={`Voir la fiche de ${entity.name}`}
|
|
>
|
|
{s}
|
|
</span>
|
|
);
|
|
}
|
|
return s;
|
|
});
|
|
parts.splice(i, 1, ...newParts);
|
|
i += newParts.length - 1;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
return <>{parts}</>;
|
|
};
|
|
|
|
interface StoryNodeProps {
|
|
node: PlotNode;
|
|
isSelected: boolean;
|
|
isEditing: boolean;
|
|
activeColorPickerId: string | null;
|
|
entities: Entity[];
|
|
savedColors: string[];
|
|
|
|
onMouseDown: (e: React.MouseEvent, id: string) => void;
|
|
onMouseUp: (e: React.MouseEvent, id: string) => void;
|
|
onStartConnection: (e: React.MouseEvent, id: string) => void;
|
|
onUpdate: (id: string, updates: Partial<PlotNode>) => void;
|
|
onSetEditing: (id: string | null) => void;
|
|
onToggleColorPicker: (id: string) => void;
|
|
onSaveColor: (color: string) => void;
|
|
onNavigateToEntity: (id: string) => void;
|
|
|
|
onInputFocus: (e: React.FocusEvent) => void;
|
|
onInputCheckAutocomplete: (e: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>, id: string, field: 'title'|'description') => void;
|
|
onKeyDownInInput: (e: React.KeyboardEvent, id: string) => void;
|
|
}
|
|
|
|
const StoryNode = React.memo(({
|
|
node, isSelected, isEditing, activeColorPickerId, entities, savedColors,
|
|
onMouseDown, onMouseUp, onStartConnection, onUpdate, onSetEditing,
|
|
onToggleColorPicker, onSaveColor, onNavigateToEntity,
|
|
onInputFocus, onInputCheckAutocomplete, onKeyDownInInput
|
|
}: StoryNodeProps) => {
|
|
|
|
const [showTypePicker, setShowTypePicker] = useState(false);
|
|
|
|
const richDescription = useMemo(() => {
|
|
return renderTextWithLinks(node.description, entities, onNavigateToEntity);
|
|
}, [node.description, entities, onNavigateToEntity]);
|
|
|
|
return (
|
|
<div
|
|
className={`absolute flex flex-col rounded-xl shadow-sm border transition-all z-10 group
|
|
${isSelected ? 'ring-2 ring-indigo-500 shadow-lg scale-[1.01]' : 'border-slate-200 hover:shadow-md'}
|
|
`}
|
|
style={{
|
|
transform: `translate3d(${node.x}px, ${node.y}px, 0)`,
|
|
width: CARD_WIDTH,
|
|
height: CARD_HEIGHT,
|
|
backgroundColor: node.color || '#ffffff',
|
|
willChange: 'transform'
|
|
}}
|
|
onMouseDown={(e) => onMouseDown(e, node.id)}
|
|
onMouseUp={(e) => onMouseUp(e, node.id)}
|
|
onMouseLeave={() => setShowTypePicker(false)}
|
|
>
|
|
<div className="h-1.5 rounded-t-xl bg-black/5 w-full cursor-grab active:cursor-grabbing" />
|
|
|
|
<div className="flex-1 px-4 pb-4 pt-2 flex flex-col overflow-hidden relative">
|
|
<div className="flex justify-between items-start mb-2 relative">
|
|
{isEditing ? (
|
|
<input
|
|
className="font-bold text-slate-800 bg-white/50 border-b border-indigo-400 outline-none w-full mr-6 text-sm p-1 rounded"
|
|
value={node.title}
|
|
onChange={(e) => onUpdate(node.id, { title: e.target.value })}
|
|
onFocus={onInputFocus}
|
|
autoFocus
|
|
/>
|
|
) : (
|
|
<div
|
|
className="font-bold text-slate-800 cursor-text truncate mr-6 text-sm"
|
|
onDoubleClick={() => onSetEditing(node.id)}
|
|
>
|
|
{node.title}
|
|
</div>
|
|
)}
|
|
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); onToggleColorPicker(node.id); }}
|
|
className="p-1 rounded-full hover:bg-black/10 text-slate-400 hover:text-indigo-600 transition-colors absolute right-0 top-0"
|
|
>
|
|
<Palette size={14} />
|
|
</button>
|
|
|
|
{activeColorPickerId === node.id && (
|
|
<div className="absolute right-[-10px] top-8 bg-white rounded-lg shadow-xl border border-slate-200 p-3 z-50 w-48 animate-in fade-in zoom-in-95 duration-100 cursor-default" onMouseDown={(e) => e.stopPropagation()}>
|
|
<div className="grid grid-cols-4 gap-2 mb-3">
|
|
{savedColors.map(color => (
|
|
<button
|
|
key={color}
|
|
onClick={() => onUpdate(node.id, { color })}
|
|
className={`w-8 h-8 rounded-full border border-slate-200 shadow-sm transition-transform hover:scale-110 ${node.color === color ? 'ring-2 ring-offset-1 ring-indigo-400' : ''}`}
|
|
style={{ backgroundColor: color }}
|
|
/>
|
|
))}
|
|
</div>
|
|
<div className="flex items-center gap-2 pt-2 border-t border-slate-100">
|
|
<div className="relative w-8 h-8 rounded-full overflow-hidden border border-slate-300 shadow-inner">
|
|
<input
|
|
type="color"
|
|
className="absolute -top-2 -left-2 w-16 h-16 cursor-pointer"
|
|
value={node.color || '#ffffff'}
|
|
onChange={(e) => onUpdate(node.id, { color: e.target.value })}
|
|
/>
|
|
</div>
|
|
<button
|
|
onClick={() => onSaveColor(node.color || '#ffffff')}
|
|
className="text-[10px] font-bold text-indigo-600 hover:text-indigo-800 hover:underline flex-1 text-right"
|
|
>
|
|
+ SAUVER
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto custom-scrollbar relative mb-4">
|
|
{isEditing ? (
|
|
<textarea
|
|
className={`w-full h-full bg-white/70 resize-none outline-none text-xs leading-relaxed p-2 rounded border border-indigo-100 shadow-inner ${node.type === 'dialogue' ? 'font-mono text-slate-700' : 'text-slate-600'}`}
|
|
placeholder={node.type === 'dialogue' ? "Héros: Salut !\nGuide: ..." : "Résumé de l'intrigue..."}
|
|
value={node.description}
|
|
onChange={(e) => onInputCheckAutocomplete(e, node.id, 'description')}
|
|
onKeyDown={(e) => onKeyDownInInput(e, node.id)}
|
|
onFocus={onInputFocus}
|
|
onBlur={() => onSetEditing(null)}
|
|
/>
|
|
) : (
|
|
<div
|
|
className={`w-full h-full text-xs text-slate-600 leading-relaxed p-1 cursor-text whitespace-pre-wrap ${node.type === 'dialogue' ? 'font-mono bg-indigo-50/30 rounded pl-2 border-l-2 border-indigo-200' : ''}`}
|
|
onClick={() => onSetEditing(node.id)}
|
|
>
|
|
{richDescription}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="absolute bottom-2 right-2 z-20">
|
|
{showTypePicker && (
|
|
<div className="absolute bottom-full mb-2 right-0 bg-white shadow-xl border border-slate-200 rounded-lg p-1 flex gap-1 animate-in zoom-in-95 duration-100 w-max" onMouseDown={(e) => e.stopPropagation()}>
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); onUpdate(node.id, { type: 'story' }); setShowTypePicker(false); }}
|
|
className={`p-1.5 rounded hover:bg-slate-100 ${node.type === 'story' ? 'bg-indigo-50 ring-1 ring-indigo-200' : ''}`}
|
|
title="Narration"
|
|
>
|
|
<BookOpen size={14} className="text-slate-500" />
|
|
</button>
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); onUpdate(node.id, { type: 'action' }); setShowTypePicker(false); }}
|
|
className={`p-1.5 rounded hover:bg-amber-50 ${node.type === 'action' ? 'bg-amber-50 ring-1 ring-amber-200' : ''}`}
|
|
title="Action"
|
|
>
|
|
<Zap size={14} className="text-amber-500" />
|
|
</button>
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); onUpdate(node.id, { type: 'dialogue' }); setShowTypePicker(false); }}
|
|
className={`p-1.5 rounded hover:bg-blue-50 ${node.type === 'dialogue' ? 'bg-blue-50 ring-1 ring-blue-200' : ''}`}
|
|
title="Dialogue"
|
|
>
|
|
<MessageCircle size={14} className="text-blue-500" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
<button
|
|
className="p-1.5 rounded-full bg-white/70 hover:bg-white shadow-sm border border-slate-100 hover:border-indigo-200 transition-all opacity-80 group-hover:opacity-100"
|
|
onClick={(e) => { e.stopPropagation(); setShowTypePicker(!showTypePicker); }}
|
|
>
|
|
{node.type === 'story' && <BookOpen size={14} className="text-slate-500" />}
|
|
{node.type === 'action' && <Zap size={14} className="text-amber-500" />}
|
|
{node.type === 'dialogue' && <MessageCircle size={14} className="text-blue-500" />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
className="absolute -right-3 top-1/2 -translate-y-1/2 w-6 h-6 bg-white border border-slate-300 rounded-full flex items-center justify-center text-slate-400 hover:text-indigo-600 hover:border-indigo-500 shadow-sm opacity-0 group-hover:opacity-100 transition-all z-20"
|
|
onMouseDown={(e) => onStartConnection(e, node.id)}
|
|
>
|
|
<ArrowRight size={12} />
|
|
</button>
|
|
</div>
|
|
);
|
|
}, (prev, next) => {
|
|
return (
|
|
prev.node === next.node &&
|
|
prev.isSelected === next.isSelected &&
|
|
prev.isEditing === next.isEditing &&
|
|
prev.activeColorPickerId === next.activeColorPickerId &&
|
|
prev.entities === next.entities
|
|
);
|
|
});
|
|
|
|
interface SuggestionState {
|
|
active: boolean;
|
|
trigger: string;
|
|
query: string;
|
|
nodeId: string;
|
|
field: 'title' | 'description';
|
|
cursorIndex: number;
|
|
selectedIndex: number;
|
|
filteredEntities: Entity[];
|
|
}
|
|
|
|
const StoryWorkflow: React.FC<StoryWorkflowProps> = ({ data, onUpdate, entities, onNavigateToEntity }) => {
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const rafRef = useRef<number | null>(null);
|
|
|
|
const [internalNodes, setInternalNodes] = useState<PlotNode[]>(data.nodes);
|
|
const internalNodesRef = useRef(internalNodes);
|
|
useEffect(() => { internalNodesRef.current = internalNodes; }, [internalNodes]);
|
|
|
|
useEffect(() => {
|
|
setInternalNodes(data.nodes);
|
|
}, [data.nodes]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
|
};
|
|
}, []);
|
|
|
|
const [activeSuggestion, setActiveSuggestion] = useState<SuggestionState | null>(null);
|
|
const [selectedNodeIds, setSelectedNodeIds] = useState<Set<string>>(new Set());
|
|
const [savedColors, setSavedColors] = useState<string[]>(INITIAL_COLORS);
|
|
const [activeColorPickerId, setActiveColorPickerId] = useState<string | null>(null);
|
|
const [editingNodeId, setEditingNodeId] = useState<string | null>(null);
|
|
const [history, setHistory] = useState<WorkflowData[]>([]);
|
|
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const [dragStartPositions, setDragStartPositions] = useState<Map<string, {x: number, y: number}>>(new Map());
|
|
const [dragStartMouse, setDragStartMouse] = useState({ x: 0, y: 0 });
|
|
|
|
const [connectingNodeId, setConnectingNodeId] = useState<string | null>(null);
|
|
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
|
|
|
|
const [isPanning, setIsPanning] = useState(false);
|
|
const [panStart, setPanStart] = useState({ x: 0, y: 0 });
|
|
const [scrollStart, setScrollStart] = useState({ x: 0, y: 0 });
|
|
|
|
const pushHistory = useCallback(() => {
|
|
setHistory(prev => {
|
|
const newHistory = [...prev, data];
|
|
if (newHistory.length > 20) return newHistory.slice(newHistory.length - 20);
|
|
return newHistory;
|
|
});
|
|
}, [data]);
|
|
|
|
const updateNode = useCallback((id: string, updates: Partial<PlotNode>) => {
|
|
const currentNodes = internalNodesRef.current;
|
|
onUpdate({
|
|
...data,
|
|
nodes: currentNodes.map(n => n.id === id ? { ...n, ...updates } : n)
|
|
});
|
|
}, [data, onUpdate]);
|
|
|
|
const handleInputFocus = useCallback((e: React.FocusEvent) => {
|
|
e.stopPropagation();
|
|
}, []);
|
|
|
|
const handleInputWithAutocomplete = useCallback((
|
|
e: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>,
|
|
nodeId: string,
|
|
field: 'title' | 'description'
|
|
) => {
|
|
const val = e.target.value;
|
|
updateNode(nodeId, { [field]: val });
|
|
|
|
const cursor = e.target.selectionStart || 0;
|
|
const textBeforeCursor = val.slice(0, cursor);
|
|
const match = textBeforeCursor.match(/([@#^])([^@#^\s]*)$/);
|
|
|
|
if (match) {
|
|
const trigger = match[1];
|
|
const query = match[2].toLowerCase();
|
|
const targetType = trigger === '@' ? EntityType.CHARACTER : trigger === '#' ? EntityType.LOCATION : EntityType.OBJECT;
|
|
const filtered = entities.filter(ent =>
|
|
ent.type === targetType &&
|
|
ent.name.toLowerCase().includes(query)
|
|
);
|
|
|
|
setActiveSuggestion({
|
|
active: true,
|
|
trigger,
|
|
query,
|
|
nodeId,
|
|
field,
|
|
cursorIndex: cursor,
|
|
selectedIndex: 0,
|
|
filteredEntities: filtered
|
|
});
|
|
} else {
|
|
setActiveSuggestion(null);
|
|
}
|
|
}, [updateNode, entities]);
|
|
|
|
const insertEntity = (entity: Entity) => {
|
|
if (!activeSuggestion) return;
|
|
const { nodeId, field, trigger, query } = activeSuggestion;
|
|
const node = internalNodesRef.current.find(n => n.id === nodeId);
|
|
if (!node) return;
|
|
const currentText = node[field] as string;
|
|
const cursor = activeSuggestion.cursorIndex;
|
|
const insertionLength = trigger.length + query.length;
|
|
const startIdx = cursor - insertionLength;
|
|
if (startIdx < 0) return;
|
|
const before = currentText.slice(0, startIdx);
|
|
const after = currentText.slice(cursor);
|
|
const isDialogue = node.type === 'dialogue' && activeSuggestion.trigger === '@';
|
|
const suffix = isDialogue ? ": " : " ";
|
|
updateNode(nodeId, { [field]: before + entity.name + suffix + after });
|
|
setActiveSuggestion(null);
|
|
};
|
|
|
|
const handleKeyDownInInput = useCallback((e: React.KeyboardEvent, nodeId: string) => {
|
|
if (activeSuggestion && activeSuggestion.nodeId === nodeId) {
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
setActiveSuggestion(prev => prev ? { ...prev, selectedIndex: (prev.selectedIndex + 1) % prev.filteredEntities.length } : null);
|
|
return;
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
setActiveSuggestion(prev => prev ? { ...prev, selectedIndex: (prev.selectedIndex - 1 + prev.filteredEntities.length) % prev.filteredEntities.length } : null);
|
|
return;
|
|
} else if (e.key === 'Tab' || e.key === 'Enter') {
|
|
e.preventDefault();
|
|
if (activeSuggestion.filteredEntities.length > 0) {
|
|
insertEntity(activeSuggestion.filteredEntities[activeSuggestion.selectedIndex]);
|
|
} else {
|
|
setActiveSuggestion(null);
|
|
}
|
|
return;
|
|
} else if (e.key === 'Escape') {
|
|
setActiveSuggestion(null);
|
|
return;
|
|
}
|
|
}
|
|
}, [activeSuggestion, entities, updateNode]);
|
|
|
|
const handleNodeMouseDown = useCallback((e: React.MouseEvent, nodeId: string) => {
|
|
e.stopPropagation();
|
|
setActiveColorPickerId(null);
|
|
|
|
setSelectedNodeIds(prevSelected => {
|
|
const newSelection = new Set(prevSelected);
|
|
if (e.ctrlKey) {
|
|
if (newSelection.has(nodeId)) newSelection.delete(nodeId);
|
|
else newSelection.add(nodeId);
|
|
} else {
|
|
if (!newSelection.has(nodeId)) {
|
|
newSelection.clear();
|
|
newSelection.add(nodeId);
|
|
}
|
|
}
|
|
const finalDragIds = e.ctrlKey ? newSelection : (newSelection.has(nodeId) ? newSelection : new Set([nodeId]));
|
|
const startPositions = new Map<string, {x: number, y: number}>();
|
|
internalNodesRef.current.forEach(n => {
|
|
if (finalDragIds.has(n.id)) {
|
|
startPositions.set(n.id, { x: n.x, y: n.y });
|
|
}
|
|
});
|
|
setDragStartPositions(startPositions);
|
|
return newSelection;
|
|
});
|
|
|
|
setIsDragging(true);
|
|
setDragStartMouse({ x: e.clientX, y: e.clientY });
|
|
pushHistory();
|
|
}, [pushHistory]);
|
|
|
|
const startConnection = useCallback((e: React.MouseEvent, nodeId: string) => {
|
|
e.stopPropagation();
|
|
pushHistory();
|
|
setConnectingNodeId(nodeId);
|
|
}, [pushHistory]);
|
|
|
|
const finishConnection = useCallback((e: React.MouseEvent, targetId: string) => {
|
|
if (connectingNodeId && connectingNodeId !== targetId) {
|
|
const exists = data.connections.some(c => c.source === connectingNodeId && c.target === targetId);
|
|
if (!exists) {
|
|
const newConn: PlotConnection = {
|
|
id: `conn-${Date.now()}`,
|
|
source: connectingNodeId,
|
|
target: targetId
|
|
};
|
|
onUpdate({
|
|
...data,
|
|
nodes: internalNodesRef.current,
|
|
connections: [...data.connections, newConn]
|
|
});
|
|
}
|
|
}
|
|
setConnectingNodeId(null);
|
|
}, [data, onUpdate, connectingNodeId]);
|
|
|
|
const handleToggleColorPicker = useCallback((id: string) => {
|
|
setActiveColorPickerId(prev => prev === id ? null : id);
|
|
}, []);
|
|
|
|
const handleSaveColor = useCallback((color: string) => {
|
|
setSavedColors(prev => !prev.includes(color) ? [...prev, color] : prev);
|
|
}, []);
|
|
|
|
const handleMouseMove = (e: React.MouseEvent) => {
|
|
const rect = containerRef.current?.getBoundingClientRect();
|
|
if (!rect) return;
|
|
const clientX = e.clientX;
|
|
const clientY = e.clientY;
|
|
|
|
if (isPanning && containerRef.current) {
|
|
const dx = clientX - panStart.x;
|
|
const dy = clientY - panStart.y;
|
|
containerRef.current.scrollLeft = scrollStart.x - dx;
|
|
containerRef.current.scrollTop = scrollStart.y - dy;
|
|
return;
|
|
}
|
|
|
|
const scrollLeft = containerRef.current?.scrollLeft || 0;
|
|
const scrollTop = containerRef.current?.scrollTop || 0;
|
|
setMousePos({ x: clientX - rect.left + scrollLeft, y: clientY - rect.top + scrollTop });
|
|
|
|
if (isDragging) {
|
|
if (rafRef.current) return;
|
|
rafRef.current = requestAnimationFrame(() => {
|
|
const dx = clientX - dragStartMouse.x;
|
|
const dy = clientY - dragStartMouse.y;
|
|
setInternalNodes(prevNodes => prevNodes.map(node => {
|
|
const startPos = dragStartPositions.get(node.id);
|
|
if (startPos) return { ...node, x: startPos.x + dx, y: startPos.y + dy };
|
|
return node;
|
|
}));
|
|
rafRef.current = null;
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleMouseUp = () => {
|
|
if (rafRef.current) {
|
|
cancelAnimationFrame(rafRef.current);
|
|
rafRef.current = null;
|
|
}
|
|
if (isDragging) onUpdate({ ...data, nodes: internalNodesRef.current });
|
|
setIsDragging(false);
|
|
setIsPanning(false);
|
|
setConnectingNodeId(null);
|
|
};
|
|
|
|
const handleCanvasMouseDown = (e: React.MouseEvent) => {
|
|
if (!e.ctrlKey) setSelectedNodeIds(new Set());
|
|
setActiveSuggestion(null);
|
|
setActiveColorPickerId(null);
|
|
setEditingNodeId(null);
|
|
setIsPanning(true);
|
|
setPanStart({ x: e.clientX, y: e.clientY });
|
|
if (containerRef.current) {
|
|
setScrollStart({ x: containerRef.current.scrollLeft, y: containerRef.current.scrollTop });
|
|
}
|
|
};
|
|
|
|
const handleCanvasDoubleClick = (e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
const rect = containerRef.current?.getBoundingClientRect();
|
|
if (!rect) return;
|
|
const x = e.clientX - rect.left + (containerRef.current?.scrollLeft || 0) - CARD_WIDTH / 2;
|
|
const y = e.clientY - rect.top + (containerRef.current?.scrollTop || 0) - CARD_HEIGHT / 2;
|
|
pushHistory();
|
|
const newNode: PlotNode = {
|
|
id: `node-${Date.now()}`,
|
|
x,
|
|
y,
|
|
title: 'Nouvel événement',
|
|
description: '',
|
|
color: INITIAL_COLORS[0],
|
|
type: 'story'
|
|
};
|
|
onUpdate({ ...data, nodes: [...internalNodesRef.current, newNode] });
|
|
setSelectedNodeIds(new Set([newNode.id]));
|
|
setEditingNodeId(newNode.id);
|
|
};
|
|
|
|
const handleDeleteSelected = () => {
|
|
if (selectedNodeIds.size === 0) return;
|
|
pushHistory();
|
|
const newNodes = internalNodes.filter(n => !selectedNodeIds.has(n.id));
|
|
const newConnections = data.connections.filter(c => !selectedNodeIds.has(c.source) && !selectedNodeIds.has(c.target));
|
|
onUpdate({ nodes: newNodes, connections: newConnections });
|
|
setSelectedNodeIds(new Set());
|
|
};
|
|
|
|
const handleAddNodeCenter = () => {
|
|
pushHistory();
|
|
const scrollLeft = containerRef.current?.scrollLeft || 0;
|
|
const scrollTop = containerRef.current?.scrollTop || 0;
|
|
const clientWidth = containerRef.current?.clientWidth || 800;
|
|
const clientHeight = containerRef.current?.clientHeight || 600;
|
|
const newNode: PlotNode = {
|
|
id: `node-${Date.now()}`,
|
|
x: scrollLeft + clientWidth / 2 - CARD_WIDTH / 2,
|
|
y: scrollTop + clientHeight / 2 - CARD_HEIGHT / 2,
|
|
title: 'Nouveau point d\'intrigue',
|
|
description: '',
|
|
color: INITIAL_COLORS[0],
|
|
type: 'story'
|
|
};
|
|
onUpdate({ ...data, nodes: [...internalNodesRef.current, newNode] });
|
|
setSelectedNodeIds(new Set([newNode.id]));
|
|
setEditingNodeId(newNode.id);
|
|
};
|
|
|
|
return (
|
|
<div className="h-full flex flex-col overflow-hidden bg-[#eef2ff] relative">
|
|
<div className="h-12 bg-white border-b border-indigo-100 flex items-center justify-between px-4 z-10 shadow-sm shrink-0">
|
|
<div className="flex items-center gap-2">
|
|
<button onClick={handleAddNodeCenter} className="flex items-center gap-1.5 px-3 py-1.5 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 text-xs font-bold transition-all shadow-md shadow-indigo-100">
|
|
<Plus size={14} /> AJOUTER NŒUD
|
|
</button>
|
|
<div className="w-px h-6 bg-slate-100 mx-2" />
|
|
<div className="text-[10px] uppercase font-bold text-slate-400 tracking-wider">
|
|
{selectedNodeIds.size > 0 ? `${selectedNodeIds.size} SÉLECTIONNÉ(S)` : 'Double-cliquez sur le canvas pour créer'}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button onClick={handleDeleteSelected} disabled={selectedNodeIds.size === 0} className="p-2 text-red-500 hover:bg-red-50 rounded-lg disabled:opacity-30 transition-colors" title="Supprimer">
|
|
<Trash2 size={16} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
ref={containerRef}
|
|
className="flex-1 overflow-auto relative cursor-grab active:cursor-grabbing bg-[#eef2ff]"
|
|
onMouseDown={handleCanvasMouseDown}
|
|
onMouseMove={handleMouseMove}
|
|
onMouseUp={handleMouseUp}
|
|
onDoubleClick={handleCanvasDoubleClick}
|
|
style={{
|
|
backgroundImage: 'radial-gradient(#d1d5db 1px, transparent 1px)',
|
|
backgroundSize: '24px 24px'
|
|
}}
|
|
>
|
|
<svg className="absolute top-0 left-0 w-[4000px] h-[4000px] pointer-events-none z-0">
|
|
{data.connections.map(conn => {
|
|
const source = internalNodes.find(n => n.id === conn.source);
|
|
const target = internalNodes.find(n => n.id === conn.target);
|
|
if (!source || !target) return null;
|
|
const startX = source.x + CARD_WIDTH / 2;
|
|
const startY = source.y + CARD_HEIGHT / 2;
|
|
const endX = target.x + CARD_WIDTH / 2;
|
|
const endY = target.y + CARD_HEIGHT / 2;
|
|
return (
|
|
<line key={conn.id} x1={startX} y1={startY} x2={endX} y2={endY} stroke="#cbd5e1" strokeWidth="2" markerEnd="url(#arrowhead)" />
|
|
);
|
|
})}
|
|
{connectingNodeId && (
|
|
<line
|
|
x1={(internalNodes.find(n => n.id === connectingNodeId)?.x || 0) + CARD_WIDTH/2}
|
|
y1={(internalNodes.find(n => n.id === connectingNodeId)?.y || 0) + CARD_HEIGHT/2}
|
|
x2={mousePos.x} y2={mousePos.y}
|
|
stroke="#6366f1" strokeWidth="2" strokeDasharray="5,5" markerEnd="url(#arrowhead-blue)"
|
|
/>
|
|
)}
|
|
<defs>
|
|
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="28" refY="3.5" orient="auto">
|
|
<path d="M0,0 L0,7 L10,3.5 Z" fill="#cbd5e1" />
|
|
</marker>
|
|
<marker id="arrowhead-blue" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto">
|
|
<path d="M0,0 L0,7 L10,3.5 Z" fill="#6366f1" />
|
|
</marker>
|
|
</defs>
|
|
</svg>
|
|
|
|
{internalNodes.map(node => (
|
|
<StoryNode
|
|
key={node.id}
|
|
node={node}
|
|
isSelected={selectedNodeIds.has(node.id)}
|
|
isEditing={editingNodeId === node.id}
|
|
activeColorPickerId={activeColorPickerId}
|
|
entities={entities}
|
|
savedColors={savedColors}
|
|
onMouseDown={handleNodeMouseDown}
|
|
onMouseUp={finishConnection}
|
|
onStartConnection={startConnection}
|
|
onUpdate={updateNode}
|
|
onSetEditing={setEditingNodeId}
|
|
onToggleColorPicker={handleToggleColorPicker}
|
|
onSaveColor={handleSaveColor}
|
|
onNavigateToEntity={onNavigateToEntity}
|
|
onInputFocus={handleInputFocus}
|
|
onInputCheckAutocomplete={handleInputWithAutocomplete}
|
|
onKeyDownInInput={handleKeyDownInInput}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{activeSuggestion && (
|
|
<div className="fixed z-50 bg-white rounded-xl shadow-2xl border border-indigo-100 w-64 max-h-48 overflow-y-auto" style={{ left: '50%', top: '50%', transform: 'translate(-50%, -50%)' }}>
|
|
<div className="px-3 py-2 bg-indigo-600 text-white text-[10px] font-black uppercase tracking-widest">
|
|
Insérer {activeSuggestion.trigger === '@' ? 'Personnage' : activeSuggestion.trigger === '#' ? 'Lieu' : 'Objet'}
|
|
</div>
|
|
<div className="divide-y divide-slate-50">
|
|
{activeSuggestion.filteredEntities.length > 0 ? (
|
|
activeSuggestion.filteredEntities.map((ent, idx) => (
|
|
<button
|
|
key={ent.id}
|
|
className={`w-full text-left px-4 py-3 text-xs flex items-center gap-3 hover:bg-indigo-50 transition-colors ${idx === activeSuggestion.selectedIndex ? 'bg-indigo-50 text-indigo-700 font-bold' : 'text-slate-700'}`}
|
|
onClick={() => insertEntity(ent)}
|
|
>
|
|
{ent.name}
|
|
</button>
|
|
))
|
|
) : (
|
|
<div className="p-4 text-xs text-slate-400 italic text-center">Aucun résultat</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default StoryWorkflow;
|