diff options
Diffstat (limited to 'makima/frontend/src/components/files/BodyRenderer.tsx')
| -rw-r--r-- | makima/frontend/src/components/files/BodyRenderer.tsx | 289 |
1 files changed, 263 insertions, 26 deletions
diff --git a/makima/frontend/src/components/files/BodyRenderer.tsx b/makima/frontend/src/components/files/BodyRenderer.tsx index 9d008e2..06b2b75 100644 --- a/makima/frontend/src/components/files/BodyRenderer.tsx +++ b/makima/frontend/src/components/files/BodyRenderer.tsx @@ -1,11 +1,18 @@ +import { useState, useRef, useEffect } from "react"; import type { BodyElement } from "../../lib/api"; import { ChartRenderer } from "../charts/ChartRenderer"; interface BodyRendererProps { elements: BodyElement[]; + isEditing?: boolean; + onUpdate?: (index: number, element: BodyElement) => void; + onReorder?: (fromIndex: number, toIndex: number) => void; } -export function BodyRenderer({ elements }: BodyRendererProps) { +export function BodyRenderer({ elements, isEditing = false, onUpdate, onReorder }: BodyRendererProps) { + const [draggedIndex, setDraggedIndex] = useState<number | null>(null); + const [dragOverIndex, setDragOverIndex] = useState<number | null>(null); + if (elements.length === 0) { return ( <div className="text-[#555] font-mono text-sm italic"> @@ -14,21 +21,123 @@ export function BodyRenderer({ elements }: BodyRendererProps) { ); } + const handleDragStart = (index: number) => (e: React.DragEvent) => { + setDraggedIndex(index); + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", index.toString()); + }; + + const handleDragOver = (index: number) => (e: React.DragEvent) => { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + if (draggedIndex !== null && draggedIndex !== index) { + setDragOverIndex(index); + } + }; + + const handleDragLeave = () => { + setDragOverIndex(null); + }; + + const handleDrop = (toIndex: number) => (e: React.DragEvent) => { + e.preventDefault(); + const fromIndex = draggedIndex; + setDraggedIndex(null); + setDragOverIndex(null); + + if (fromIndex !== null && fromIndex !== toIndex && onReorder) { + onReorder(fromIndex, toIndex); + } + }; + + const handleDragEnd = () => { + setDraggedIndex(null); + setDragOverIndex(null); + }; + return ( - <div className="space-y-4"> + <div className="space-y-1"> {elements.map((element, index) => ( - <BodyElementRenderer key={index} element={element} /> + <div + key={index} + className={`group flex items-start gap-2 py-1 transition-all ${ + draggedIndex === index ? "opacity-50" : "" + } ${ + dragOverIndex === index + ? "border-t-2 border-[#75aafc] -mt-[2px] pt-[calc(0.25rem+2px)]" + : "" + }`} + onDragOver={handleDragOver(index)} + onDragLeave={handleDragLeave} + onDrop={handleDrop(index)} + > + {/* Drag handle - only show in edit mode */} + {isEditing && onReorder && ( + <div + draggable + onDragStart={handleDragStart(index)} + onDragEnd={handleDragEnd} + className="flex-shrink-0 w-5 h-6 flex items-center justify-center cursor-grab active:cursor-grabbing opacity-0 group-hover:opacity-100 transition-opacity text-[#555] hover:text-[#75aafc]" + title="Drag to reorder" + > + <svg + width="12" + height="12" + viewBox="0 0 12 12" + fill="currentColor" + > + <circle cx="3" cy="2" r="1.5" /> + <circle cx="9" cy="2" r="1.5" /> + <circle cx="3" cy="6" r="1.5" /> + <circle cx="9" cy="6" r="1.5" /> + <circle cx="3" cy="10" r="1.5" /> + <circle cx="9" cy="10" r="1.5" /> + </svg> + </div> + )} + <div className="flex-1"> + <BodyElementRenderer + element={element} + onUpdate={isEditing && onUpdate ? (el) => onUpdate(index, el) : undefined} + /> + </div> + </div> ))} </div> ); } -function BodyElementRenderer({ element }: { element: BodyElement }) { +function BodyElementRenderer({ + element, + onUpdate, +}: { + element: BodyElement; + onUpdate?: (element: BodyElement) => void; +}) { switch (element.type) { case "heading": - return <HeadingElement level={element.level} text={element.text} />; + return ( + <HeadingElement + level={element.level} + text={element.text} + onUpdate={ + onUpdate + ? (text) => onUpdate({ ...element, text }) + : undefined + } + /> + ); case "paragraph": - return <ParagraphElement text={element.text} />; + return ( + <ParagraphElement + text={element.text} + onUpdate={ + onUpdate + ? (text) => onUpdate({ ...element, text }) + : undefined + } + /> + ); case "chart": return ( <ChartElement @@ -51,29 +160,157 @@ function BodyElementRenderer({ element }: { element: BodyElement }) { } } -function HeadingElement({ level, text }: { level: number; text: string }) { - const className = "font-mono text-[#9bc3ff]"; - - switch (level) { - case 1: - return <h1 className={`${className} text-2xl font-bold`}>{text}</h1>; - case 2: - return <h2 className={`${className} text-xl font-bold`}>{text}</h2>; - case 3: - return <h3 className={`${className} text-lg font-semibold`}>{text}</h3>; - case 4: - return <h4 className={`${className} text-base font-semibold`}>{text}</h4>; - case 5: - return <h5 className={`${className} text-sm font-semibold`}>{text}</h5>; - case 6: - return <h6 className={`${className} text-xs font-semibold`}>{text}</h6>; - default: - return <h3 className={`${className} text-lg font-semibold`}>{text}</h3>; +function HeadingElement({ + level, + text, + onUpdate, +}: { + level: number; + text: string; + onUpdate?: (text: string) => void; +}) { + const [isEditing, setIsEditing] = useState(false); + const [editText, setEditText] = useState(text); + const inputRef = useRef<HTMLInputElement>(null); + + useEffect(() => { + setEditText(text); + }, [text]); + + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [isEditing]); + + const handleSave = () => { + if (onUpdate && editText.trim() !== text) { + onUpdate(editText.trim()); + } + setIsEditing(false); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + handleSave(); + } else if (e.key === "Escape") { + setEditText(text); + setIsEditing(false); + } + }; + + const baseClassName = "font-mono text-[#9bc3ff]"; + const sizeClasses: Record<number, string> = { + 1: "text-2xl font-bold", + 2: "text-xl font-bold", + 3: "text-lg font-semibold", + 4: "text-base font-semibold", + 5: "text-sm font-semibold", + 6: "text-xs font-semibold", + }; + const sizeClass = sizeClasses[level] || sizeClasses[3]; + + if (isEditing && onUpdate) { + return ( + <input + ref={inputRef} + type="text" + value={editText} + onChange={(e) => setEditText(e.target.value)} + onBlur={handleSave} + onKeyDown={handleKeyDown} + className={`${baseClassName} ${sizeClass} w-full bg-transparent border-b border-[#3f6fb3] outline-none`} + /> + ); } + + const HeadingTag = `h${level}` as "h1" | "h2" | "h3" | "h4" | "h5" | "h6"; + return ( + <HeadingTag + className={`${baseClassName} ${sizeClass} ${onUpdate ? "cursor-text hover:bg-[rgba(117,170,252,0.05)] px-1 -mx-1 rounded" : ""}`} + onClick={() => onUpdate && setIsEditing(true)} + > + {text} + </HeadingTag> + ); } -function ParagraphElement({ text }: { text: string }) { - return <p className="font-mono text-sm text-white/80 leading-relaxed">{text}</p>; +function ParagraphElement({ + text, + onUpdate, +}: { + text: string; + onUpdate?: (text: string) => void; +}) { + const [isEditing, setIsEditing] = useState(false); + const [editText, setEditText] = useState(text); + const textareaRef = useRef<HTMLTextAreaElement>(null); + + useEffect(() => { + setEditText(text); + }, [text]); + + useEffect(() => { + if (isEditing && textareaRef.current) { + textareaRef.current.focus(); + // Auto-resize textarea + textareaRef.current.style.height = "auto"; + textareaRef.current.style.height = textareaRef.current.scrollHeight + "px"; + } + }, [isEditing]); + + const handleSave = () => { + if (onUpdate && editText.trim() !== text) { + onUpdate(editText.trim()); + } + setIsEditing(false); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + setEditText(text); + setIsEditing(false); + } + // Ctrl/Cmd + Enter to save + if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { + handleSave(); + } + }; + + const handleInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => { + setEditText(e.target.value); + // Auto-resize + e.target.style.height = "auto"; + e.target.style.height = e.target.scrollHeight + "px"; + }; + + if (isEditing && onUpdate) { + return ( + <div className="relative"> + <textarea + ref={textareaRef} + value={editText} + onChange={handleInput} + onBlur={handleSave} + onKeyDown={handleKeyDown} + className="font-mono text-sm text-white/80 leading-relaxed w-full bg-transparent border border-[#3f6fb3] outline-none p-2 resize-none min-h-[60px]" + /> + <div className="text-[10px] text-[#555] font-mono mt-1"> + Ctrl+Enter to save, Esc to cancel + </div> + </div> + ); + } + + return ( + <p + className={`font-mono text-sm text-white/80 leading-relaxed ${onUpdate ? "cursor-text hover:bg-[rgba(117,170,252,0.05)] px-1 -mx-1 rounded" : ""}`} + onClick={() => onUpdate && setIsEditing(true)} + > + {text} + </p> + ); } function ChartElement({ |
