From 87044a747b47bd83249d61a45842c7f7b2eae56d Mon Sep 17 00:00:00 2001
From: soryu <soryu@soryu.co>
Date: Sun, 11 Jan 2026 05:52:14 +0000
Subject:  Contract system

---
 .../frontend/src/components/files/BodyRenderer.tsx | 376 +++++++++++++++++++++
 1 file changed, 376 insertions(+)

(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 cf99fde..c3f402e 100644
--- a/makima/frontend/src/components/files/BodyRenderer.tsx
+++ b/makima/frontend/src/components/files/BodyRenderer.tsx
@@ -2,6 +2,7 @@ import { useState, useRef, useEffect } from "react";
 import type { BodyElement } from "../../lib/api";
 import { ChartRenderer } from "../charts/ChartRenderer";
 import { ElementContextMenu } from "./ElementContextMenu";
+import { copyMarkdownToClipboard } from "../../lib/markdown";
 
 interface BodyRendererProps {
   elements: BodyElement[];
@@ -42,6 +43,15 @@ export function BodyRenderer({
     elementIndex: number;
     selectedText?: string;
   } | null>(null);
+  const [copiedMarkdown, setCopiedMarkdown] = useState(false);
+
+  const handleCopyMarkdown = async () => {
+    const success = await copyMarkdownToClipboard(elements);
+    if (success) {
+      setCopiedMarkdown(true);
+      setTimeout(() => setCopiedMarkdown(false), 2000);
+    }
+  };
 
   const handleContextMenu = (index: number) => (e: React.MouseEvent) => {
     e.preventDefault();
@@ -104,6 +114,31 @@ export function BodyRenderer({
 
   return (
     <div className="space-y-1">
+      {/* Markdown Export Toolbar */}
+      <div className="flex justify-end mb-2">
+        <button
+          onClick={handleCopyMarkdown}
+          className="flex items-center gap-1 px-2 py-1 text-[10px] font-mono text-[#555] hover:text-[#75aafc] transition-colors"
+          title="Copy content as markdown"
+        >
+          {copiedMarkdown ? (
+            <>
+              <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
+                <polyline points="20 6 9 17 4 12" />
+              </svg>
+              Copied!
+            </>
+          ) : (
+            <>
+              <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
+                <path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
+                <rect x="8" y="2" width="8" height="4" rx="1" ry="1" />
+              </svg>
+              Copy as Markdown
+            </>
+          )}
+        </button>
+      </div>
       {elements.map((element, index) => (
         <div
           key={index}
@@ -250,6 +285,20 @@ function BodyElementRenderer({
           caption={element.caption}
         />
       );
+    case "markdown":
+      return (
+        <MarkdownElement
+          content={element.content}
+          onUpdate={
+            onUpdate
+              ? (content) => onUpdate({ ...element, content })
+              : undefined
+          }
+          onEditingChange={onEditingChange}
+          hasPendingRemoteUpdate={hasPendingRemoteUpdate}
+          onOverwrite={onOverwrite}
+        />
+      );
     default:
       return null;
   }
@@ -621,3 +670,330 @@ function ListElement({
     </ListTag>
   );
 }
+
+/**
+ * Simple inline markdown renderer.
+ * Renders basic markdown syntax to HTML elements.
+ */
+function renderMarkdown(content: string): React.ReactNode {
+  const lines = content.split('\n');
+  const elements: React.ReactNode[] = [];
+  let i = 0;
+
+  while (i < lines.length) {
+    const line = lines[i];
+
+    // Code blocks
+    if (line.startsWith('```')) {
+      const lang = line.slice(3).trim();
+      const codeLines: string[] = [];
+      i++;
+      while (i < lines.length && !lines[i].startsWith('```')) {
+        codeLines.push(lines[i]);
+        i++;
+      }
+      i++; // skip closing ```
+      elements.push(
+        <div key={elements.length} className="relative my-2">
+          {lang && (
+            <div className="absolute top-0 right-0 px-2 py-0.5 font-mono text-[10px] text-[#555] bg-[#1a1a1a] border-b border-l border-[#333]">
+              {lang}
+            </div>
+          )}
+          <pre className="bg-[#0d0d0d] border border-[#333] p-4 overflow-x-auto">
+            <code className="font-mono text-sm text-[#9bc3ff] whitespace-pre">
+              {codeLines.join('\n')}
+            </code>
+          </pre>
+        </div>
+      );
+      continue;
+    }
+
+    // Headings
+    const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
+    if (headingMatch) {
+      const level = headingMatch[1].length;
+      const text = headingMatch[2];
+      const HeadingTag = `h${level}` as 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
+      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",
+      };
+      elements.push(
+        <HeadingTag key={elements.length} className={`font-mono text-[#9bc3ff] ${sizeClasses[level]} my-2`}>
+          {renderInlineMarkdown(text)}
+        </HeadingTag>
+      );
+      i++;
+      continue;
+    }
+
+    // Unordered lists
+    if (line.match(/^[-*]\s+/)) {
+      const items: string[] = [];
+      while (i < lines.length && lines[i].match(/^[-*]\s+/)) {
+        items.push(lines[i].replace(/^[-*]\s+/, ''));
+        i++;
+      }
+      elements.push(
+        <ul key={elements.length} className="font-mono text-sm text-white/80 leading-relaxed pl-6 space-y-1 list-disc my-2">
+          {items.map((item, idx) => (
+            <li key={idx} className="pl-1">{renderInlineMarkdown(item)}</li>
+          ))}
+        </ul>
+      );
+      continue;
+    }
+
+    // Ordered lists
+    if (line.match(/^\d+\.\s+/)) {
+      const items: string[] = [];
+      while (i < lines.length && lines[i].match(/^\d+\.\s+/)) {
+        items.push(lines[i].replace(/^\d+\.\s+/, ''));
+        i++;
+      }
+      elements.push(
+        <ol key={elements.length} className="font-mono text-sm text-white/80 leading-relaxed pl-6 space-y-1 list-decimal my-2">
+          {items.map((item, idx) => (
+            <li key={idx} className="pl-1">{renderInlineMarkdown(item)}</li>
+          ))}
+        </ol>
+      );
+      continue;
+    }
+
+    // Empty lines
+    if (line.trim() === '') {
+      i++;
+      continue;
+    }
+
+    // Regular paragraphs
+    elements.push(
+      <p key={elements.length} className="font-mono text-sm text-white/80 leading-relaxed my-2">
+        {renderInlineMarkdown(line)}
+      </p>
+    );
+    i++;
+  }
+
+  return <>{elements}</>;
+}
+
+/**
+ * Render inline markdown (bold, italic, code, links).
+ */
+function renderInlineMarkdown(text: string): React.ReactNode {
+  // Process inline elements: **bold**, *italic*, `code`, [link](url)
+  const parts: React.ReactNode[] = [];
+  let remaining = text;
+  let keyCounter = 0;
+
+  while (remaining.length > 0) {
+    // Check for inline code
+    const codeMatch = remaining.match(/^`([^`]+)`/);
+    if (codeMatch) {
+      parts.push(
+        <code key={keyCounter++} className="bg-[#1a1a1a] px-1 py-0.5 text-[#9bc3ff] border border-[#333] text-xs">
+          {codeMatch[1]}
+        </code>
+      );
+      remaining = remaining.slice(codeMatch[0].length);
+      continue;
+    }
+
+    // Check for bold
+    const boldMatch = remaining.match(/^\*\*([^*]+)\*\*/);
+    if (boldMatch) {
+      parts.push(<strong key={keyCounter++} className="font-bold">{boldMatch[1]}</strong>);
+      remaining = remaining.slice(boldMatch[0].length);
+      continue;
+    }
+
+    // Check for italic
+    const italicMatch = remaining.match(/^\*([^*]+)\*/);
+    if (italicMatch) {
+      parts.push(<em key={keyCounter++} className="italic">{italicMatch[1]}</em>);
+      remaining = remaining.slice(italicMatch[0].length);
+      continue;
+    }
+
+    // Check for links
+    const linkMatch = remaining.match(/^\[([^\]]+)\]\(([^)]+)\)/);
+    if (linkMatch) {
+      parts.push(
+        <a key={keyCounter++} href={linkMatch[2]} className="text-[#75aafc] hover:underline" target="_blank" rel="noopener noreferrer">
+          {linkMatch[1]}
+        </a>
+      );
+      remaining = remaining.slice(linkMatch[0].length);
+      continue;
+    }
+
+    // Find next special character or end
+    const nextSpecial = remaining.search(/[`*\[]/);
+    if (nextSpecial === -1) {
+      parts.push(remaining);
+      break;
+    } else if (nextSpecial === 0) {
+      // Special char at start but didn't match a pattern - treat as text
+      parts.push(remaining[0]);
+      remaining = remaining.slice(1);
+    } else {
+      parts.push(remaining.slice(0, nextSpecial));
+      remaining = remaining.slice(nextSpecial);
+    }
+  }
+
+  return <>{parts}</>;
+}
+
+function MarkdownElement({
+  content,
+  onUpdate,
+  onEditingChange,
+  hasPendingRemoteUpdate,
+  onOverwrite,
+}: {
+  content: string;
+  onUpdate?: (content: string) => void;
+  onEditingChange?: (isEditing: boolean) => void;
+  hasPendingRemoteUpdate?: boolean;
+  onOverwrite?: () => void;
+}) {
+  const [isEditing, setIsEditing] = useState(false);
+  const [editContent, setEditContent] = useState(content);
+  const textareaRef = useRef<HTMLTextAreaElement>(null);
+
+  useEffect(() => {
+    if (!isEditing) {
+      setEditContent(content);
+    }
+  }, [content, isEditing]);
+
+  useEffect(() => {
+    if (isEditing && textareaRef.current) {
+      textareaRef.current.focus();
+      textareaRef.current.style.height = "auto";
+      textareaRef.current.style.height = textareaRef.current.scrollHeight + "px";
+    }
+  }, [isEditing]);
+
+  const startEditing = () => {
+    setIsEditing(true);
+    onEditingChange?.(true);
+  };
+
+  const stopEditing = () => {
+    setIsEditing(false);
+    onEditingChange?.(false);
+  };
+
+  const handleSave = () => {
+    if (hasPendingRemoteUpdate) return;
+    if (onUpdate && editContent !== content) {
+      onUpdate(editContent);
+    }
+    stopEditing();
+  };
+
+  const handleOverwrite = () => {
+    if (onUpdate && editContent !== content) {
+      onUpdate(editContent);
+    }
+    onOverwrite?.();
+    stopEditing();
+  };
+
+  const handleCancel = () => {
+    setEditContent(content);
+    stopEditing();
+  };
+
+  const handleKeyDown = (e: React.KeyboardEvent) => {
+    if (e.key === "Escape") {
+      handleCancel();
+    }
+    if (e.key === "Enter" && (e.ctrlKey || e.metaKey) && !hasPendingRemoteUpdate) {
+      handleSave();
+    }
+  };
+
+  const handleBlur = () => {
+    if (!hasPendingRemoteUpdate) {
+      handleSave();
+    }
+  };
+
+  const handleInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
+    setEditContent(e.target.value);
+    e.target.style.height = "auto";
+    e.target.style.height = e.target.scrollHeight + "px";
+  };
+
+  if (isEditing && onUpdate) {
+    return (
+      <div className="relative">
+        <div className="text-[10px] text-[#555] font-mono mb-1 flex items-center gap-2">
+          <span className="text-[#75aafc]">Editing Markdown</span>
+        </div>
+        <textarea
+          ref={textareaRef}
+          value={editContent}
+          onChange={handleInput}
+          onBlur={handleBlur}
+          onKeyDown={handleKeyDown}
+          className="font-mono text-sm text-white/80 leading-relaxed w-full bg-[#0d0d0d] border border-[#3f6fb3] outline-none p-3 resize-none min-h-[120px]"
+          placeholder="Enter markdown content..."
+        />
+        {hasPendingRemoteUpdate ? (
+          <div className="flex items-center gap-2 mt-2">
+            <span className="text-yellow-500 text-xs font-mono">Remote update pending</span>
+            <button
+              onClick={handleOverwrite}
+              onMouseDown={(e) => e.preventDefault()}
+              className="px-2 py-1 font-mono text-xs text-yellow-500 border border-yellow-500/50 hover:border-yellow-500 transition-colors"
+            >
+              Overwrite
+            </button>
+            <button
+              onClick={handleCancel}
+              onMouseDown={(e) => e.preventDefault()}
+              className="px-2 py-1 font-mono text-xs text-[#555] hover:text-white/70 transition-colors"
+            >
+              Cancel
+            </button>
+          </div>
+        ) : (
+          <div className="text-[10px] text-[#555] font-mono mt-1">
+            Ctrl+Enter to save, Esc to cancel
+          </div>
+        )}
+      </div>
+    );
+  }
+
+  return (
+    <div
+      className={`border border-[#333] bg-[#0a0a0a] p-4 rounded ${onUpdate ? "cursor-text hover:border-[#3f6fb3] transition-colors" : ""}`}
+      onClick={() => onUpdate && startEditing()}
+    >
+      <div className="text-[10px] text-[#555] font-mono mb-2 flex items-center gap-1">
+        <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
+          <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
+          <path d="M14 2v6h6" />
+          <path d="M16 13H8" />
+          <path d="M16 17H8" />
+          <path d="M10 9H8" />
+        </svg>
+        <span>Markdown</span>
+      </div>
+      {renderMarkdown(content)}
+    </div>
+  );
+}
-- 
cgit v1.2.3

