From f5222a7ae5ade5589436778cb01fc0abe625b3c3 Mon Sep 17 00:00:00 2001 From: soryu Date: Tue, 23 Dec 2025 19:11:57 +0000 Subject: Add editable file sections and a drag&drop feature --- .../frontend/src/components/files/BodyRenderer.tsx | 289 +++++++++++++++++++-- .../frontend/src/components/files/FileDetail.tsx | 61 +++-- makima/frontend/src/components/files/FileList.tsx | 14 +- makima/frontend/src/routes/files.tsx | 65 ++++- 4 files changed, 371 insertions(+), 58 deletions(-) (limited to 'makima/frontend') 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(null); + const [dragOverIndex, setDragOverIndex] = useState(null); + if (elements.length === 0) { return (
@@ -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 ( -
+
{elements.map((element, index) => ( - +
+ {/* Drag handle - only show in edit mode */} + {isEditing && onReorder && ( +
+ + + + + + + + +
+ )} +
+ onUpdate(index, el) : undefined} + /> +
+
))}
); } -function BodyElementRenderer({ element }: { element: BodyElement }) { +function BodyElementRenderer({ + element, + onUpdate, +}: { + element: BodyElement; + onUpdate?: (element: BodyElement) => void; +}) { switch (element.type) { case "heading": - return ; + return ( + onUpdate({ ...element, text }) + : undefined + } + /> + ); case "paragraph": - return ; + return ( + onUpdate({ ...element, text }) + : undefined + } + /> + ); case "chart": return ( {text}; - case 2: - return

{text}

; - case 3: - return

{text}

; - case 4: - return

{text}

; - case 5: - return
{text}
; - case 6: - return
{text}
; - default: - return

{text}

; +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(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 = { + 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 ( + 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 ( + onUpdate && setIsEditing(true)} + > + {text} + + ); } -function ParagraphElement({ text }: { text: string }) { - return

{text}

; +function ParagraphElement({ + text, + onUpdate, +}: { + text: string; + onUpdate?: (text: string) => void; +}) { + const [isEditing, setIsEditing] = useState(false); + const [editText, setEditText] = useState(text); + const textareaRef = useRef(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) => { + setEditText(e.target.value); + // Auto-resize + e.target.style.height = "auto"; + e.target.style.height = e.target.scrollHeight + "px"; + }; + + if (isEditing && onUpdate) { + return ( +
+