summaryrefslogtreecommitdiff
path: root/makima/frontend/src/components/files/BodyRenderer.tsx
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-01-11 05:52:14 +0000
committersoryu <soryu@soryu.co>2026-01-15 00:21:16 +0000
commit87044a747b47bd83249d61a45842c7f7b2eae56d (patch)
treeef2000ce79ffcc2723ef841acef5aa1deb1d5378 /makima/frontend/src/components/files/BodyRenderer.tsx
parent077820c4167c168072d217a1b01df840463a12a8 (diff)
downloadsoryu-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.tsx376
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>
+ );
+}