diff options
| author | soryu <soryu@soryu.co> | 2026-01-11 05:52:14 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-01-15 00:21:16 +0000 |
| commit | 87044a747b47bd83249d61a45842c7f7b2eae56d (patch) | |
| tree | ef2000ce79ffcc2723ef841acef5aa1deb1d5378 /makima/frontend/src/components/files/BodyRenderer.tsx | |
| parent | 077820c4167c168072d217a1b01df840463a12a8 (diff) | |
| download | soryu-87044a747b47bd83249d61a45842c7f7b2eae56d.tar.gz soryu-87044a747b47bd83249d61a45842c7f7b2eae56d.zip | |
Contract system
Diffstat (limited to 'makima/frontend/src/components/files/BodyRenderer.tsx')
| -rw-r--r-- | makima/frontend/src/components/files/BodyRenderer.tsx | 376 |
1 files changed, 376 insertions, 0 deletions
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> + ); +} |
