From f5222a7ae5ade5589436778cb01fc0abe625b3c3 Mon Sep 17 00:00:00 2001
From: soryu <soryu@soryu.co>
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 +++++++++++++++++++--
 1 file changed, 263 insertions(+), 26 deletions(-)

(limited to 'makima/frontend/src/components/files/BodyRenderer.tsx')

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({
-- 
cgit v1.2.3

