From 87044a747b47bd83249d61a45842c7f7b2eae56d Mon Sep 17 00:00:00 2001 From: soryu Date: Sun, 11 Jan 2026 05:52:14 +0000 Subject: Contract system --- .../frontend/src/components/files/BodyRenderer.tsx | 376 +++++++++++++++++++++ .../frontend/src/components/files/FileDetail.tsx | 2 + makima/frontend/src/components/files/FileList.tsx | 179 ++-------- .../src/components/files/RepoSyncIndicator.tsx | 190 +++++++++++ 4 files changed, 597 insertions(+), 150 deletions(-) create mode 100644 makima/frontend/src/components/files/RepoSyncIndicator.tsx (limited to 'makima/frontend/src/components/files') 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 (
+ {/* Markdown Export Toolbar */} +
+ +
{elements.map((element, index) => (
); + case "markdown": + return ( + onUpdate({ ...element, content }) + : undefined + } + onEditingChange={onEditingChange} + hasPendingRemoteUpdate={hasPendingRemoteUpdate} + onOverwrite={onOverwrite} + /> + ); default: return null; } @@ -621,3 +670,330 @@ function ListElement({ ); } + +/** + * 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( +
+ {lang && ( +
+ {lang} +
+ )} +
+            
+              {codeLines.join('\n')}
+            
+          
+
+ ); + 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 = { + 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( + + {renderInlineMarkdown(text)} + + ); + 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( +
    + {items.map((item, idx) => ( +
  • {renderInlineMarkdown(item)}
  • + ))} +
+ ); + 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( +
    + {items.map((item, idx) => ( +
  1. {renderInlineMarkdown(item)}
  2. + ))} +
+ ); + continue; + } + + // Empty lines + if (line.trim() === '') { + i++; + continue; + } + + // Regular paragraphs + elements.push( +

+ {renderInlineMarkdown(line)} +

+ ); + 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( + + {codeMatch[1]} + + ); + remaining = remaining.slice(codeMatch[0].length); + continue; + } + + // Check for bold + const boldMatch = remaining.match(/^\*\*([^*]+)\*\*/); + if (boldMatch) { + parts.push({boldMatch[1]}); + remaining = remaining.slice(boldMatch[0].length); + continue; + } + + // Check for italic + const italicMatch = remaining.match(/^\*([^*]+)\*/); + if (italicMatch) { + parts.push({italicMatch[1]}); + remaining = remaining.slice(italicMatch[0].length); + continue; + } + + // Check for links + const linkMatch = remaining.match(/^\[([^\]]+)\]\(([^)]+)\)/); + if (linkMatch) { + parts.push( + + {linkMatch[1]} + + ); + 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(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) => { + setEditContent(e.target.value); + e.target.style.height = "auto"; + e.target.style.height = e.target.scrollHeight + "px"; + }; + + if (isEditing && onUpdate) { + return ( +
+
+ Editing Markdown +
+