summaryrefslogblamecommitdiff
path: root/makima/frontend/src/components/files/BodyRenderer.tsx
blob: c3f402e15c2222f804d9cd34a0c8a204de093c6a (plain) (tree)
1
2
3
4
5
6
7
8
                                                    

                                                        
                                                          
                                                             


                             


                                                           


                                                 





                                                                           

 














                              

                                                                          





                                                  








                                                              
















                                                                         
 







                                                            

































                                                                      
          
                               
























                                                                                                                                
                                         











                                                                              
                                                  




























                                                                                                                                                                                                         


                                                             


                
         

















                                                                                                



          


                              


                         


                                            


                                                 
    

                         








                                                        


                                                         

          
                     







                                                        


                                                         

          













                                     
















                                       













                                                              




                  



                         


                         



                                    


                                                 





                                                    




                                                    







                                        









                              
                            


                                                         


                                               













                                               


                                                     

                                                            

                                    







                                                                 















                                                         





























                                                                                                                                           
      
   




                                                                                                                                       
                                                



                 

 


                           


                         


                                    


                                                 





                                                        




                                                    









                                                                                 









                              
                            


                                                         


                                               













                                               



                                                     




                                                                                   
     




                                                                 

















                                                                      
                             


                                                                                                                                                             






















                                                                                                                                           






                                                                                                                                                         
                                                



            
















































                                                                     









































                                                                                                                                           






































































































































































































































































































































                                                                                                                                                            
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[];
  isEditing?: boolean;
  onUpdate?: (index: number, element: BodyElement) => void;
  onReorder?: (fromIndex: number, toIndex: number) => void;
  onEditingChange?: (isEditing: boolean) => void;
  hasPendingRemoteUpdate?: boolean;
  onOverwrite?: () => void;
  onFocusElement?: (index: number) => void;
  onDeleteElement?: (index: number) => void;
  onDuplicateElement?: (index: number) => void;
  onConvertElement?: (index: number, toType: string) => void;
  onGenerateFromElement?: (index: number, action: string) => void;
  onCreateTaskFromElement?: (index: number, selectedText?: string) => void;
}

export function BodyRenderer({
  elements,
  isEditing = false,
  onUpdate,
  onReorder,
  onEditingChange,
  hasPendingRemoteUpdate,
  onOverwrite,
  onFocusElement,
  onDeleteElement,
  onDuplicateElement,
  onConvertElement,
  onGenerateFromElement,
  onCreateTaskFromElement,
}: BodyRendererProps) {
  const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
  const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
  const [contextMenu, setContextMenu] = useState<{
    x: number;
    y: number;
    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();
    // Get any selected text
    const selection = window.getSelection();
    const selectedText = selection?.toString().trim() || undefined;
    setContextMenu({
      x: e.clientX,
      y: e.clientY,
      elementIndex: index,
      selectedText,
    });
  };

  const closeContextMenu = () => {
    setContextMenu(null);
  };

  if (elements.length === 0) {
    return (
      <div className="text-[#555] font-mono text-sm italic">
        No content yet. Use the CLI below to add content.
      </div>
    );
  }

  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-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}
          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)}
          onContextMenu={handleContextMenu(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}
              onEditingChange={onEditingChange}
              hasPendingRemoteUpdate={hasPendingRemoteUpdate}
              onOverwrite={onOverwrite}
            />
          </div>
        </div>
      ))}

      {/* Context Menu */}
      {contextMenu && (
        <ElementContextMenu
          x={contextMenu.x}
          y={contextMenu.y}
          element={elements[contextMenu.elementIndex]}
          elementIndex={contextMenu.elementIndex}
          selectedText={contextMenu.selectedText}
          onClose={closeContextMenu}
          onFocus={(index) => onFocusElement?.(index)}
          onDelete={(index) => onDeleteElement?.(index)}
          onDuplicate={(index) => onDuplicateElement?.(index)}
          onConvert={(index, toType) => onConvertElement?.(index, toType)}
          onGenerate={(index, action) => onGenerateFromElement?.(index, action)}
          onCreateTask={(index, selectedText) => onCreateTaskFromElement?.(index, selectedText)}
        />
      )}
    </div>
  );
}

function BodyElementRenderer({
  element,
  onUpdate,
  onEditingChange,
  hasPendingRemoteUpdate,
  onOverwrite,
}: {
  element: BodyElement;
  onUpdate?: (element: BodyElement) => void;
  onEditingChange?: (isEditing: boolean) => void;
  hasPendingRemoteUpdate?: boolean;
  onOverwrite?: () => void;
}) {
  switch (element.type) {
    case "heading":
      return (
        <HeadingElement
          level={element.level}
          text={element.text}
          onUpdate={
            onUpdate
              ? (text) => onUpdate({ ...element, text })
              : undefined
          }
          onEditingChange={onEditingChange}
          hasPendingRemoteUpdate={hasPendingRemoteUpdate}
          onOverwrite={onOverwrite}
        />
      );
    case "paragraph":
      return (
        <ParagraphElement
          text={element.text}
          onUpdate={
            onUpdate
              ? (text) => onUpdate({ ...element, text })
              : undefined
          }
          onEditingChange={onEditingChange}
          hasPendingRemoteUpdate={hasPendingRemoteUpdate}
          onOverwrite={onOverwrite}
        />
      );
    case "code":
      return (
        <CodeElement
          language={element.language}
          content={element.content}
        />
      );
    case "list":
      return (
        <ListElement
          ordered={element.ordered}
          items={element.items}
        />
      );
    case "chart":
      return (
        <ChartElement
          chartType={element.chartType}
          data={element.data}
          title={element.title}
          config={element.config}
        />
      );
    case "image":
      return (
        <ImageElement
          src={element.src}
          alt={element.alt}
          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;
  }
}

function HeadingElement({
  level,
  text,
  onUpdate,
  onEditingChange,
  hasPendingRemoteUpdate,
  onOverwrite,
}: {
  level: number;
  text: string;
  onUpdate?: (text: string) => void;
  onEditingChange?: (isEditing: boolean) => void;
  hasPendingRemoteUpdate?: boolean;
  onOverwrite?: () => void;
}) {
  const [isEditing, setIsEditing] = useState(false);
  const [editText, setEditText] = useState(text);
  const inputRef = useRef<HTMLInputElement>(null);

  useEffect(() => {
    // Only update editText if not currently editing
    if (!isEditing) {
      setEditText(text);
    }
  }, [text, isEditing]);

  useEffect(() => {
    if (isEditing && inputRef.current) {
      inputRef.current.focus();
      inputRef.current.select();
    }
  }, [isEditing]);

  const startEditing = () => {
    setIsEditing(true);
    onEditingChange?.(true);
  };

  const stopEditing = () => {
    setIsEditing(false);
    onEditingChange?.(false);
  };

  const handleSave = () => {
    // Don't auto-save if there's a pending remote update
    if (hasPendingRemoteUpdate) return;

    if (onUpdate && editText.trim() !== text) {
      onUpdate(editText.trim());
    }
    stopEditing();
  };

  const handleOverwrite = () => {
    if (onUpdate && editText.trim() !== text) {
      onUpdate(editText.trim());
    }
    onOverwrite?.();
    stopEditing();
  };

  const handleCancel = () => {
    setEditText(text);
    stopEditing();
  };

  const handleKeyDown = (e: React.KeyboardEvent) => {
    // Disable Enter save if there's a pending remote update
    if (e.key === "Enter" && !hasPendingRemoteUpdate) {
      handleSave();
    } else if (e.key === "Escape") {
      handleCancel();
    }
  };

  const handleBlur = () => {
    // Don't auto-save on blur if there's a pending remote update
    if (!hasPendingRemoteUpdate) {
      handleSave();
    }
  };

  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 (
      <div>
        <input
          ref={inputRef}
          type="text"
          value={editText}
          onChange={(e) => setEditText(e.target.value)}
          onBlur={handleBlur}
          onKeyDown={handleKeyDown}
          className={`${baseClassName} ${sizeClass} w-full bg-transparent border-b border-[#3f6fb3] outline-none`}
        />
        {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>
    );
  }

  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 && startEditing()}
    >
      {text}
    </HeadingTag>
  );
}

function ParagraphElement({
  text,
  onUpdate,
  onEditingChange,
  hasPendingRemoteUpdate,
  onOverwrite,
}: {
  text: string;
  onUpdate?: (text: string) => void;
  onEditingChange?: (isEditing: boolean) => void;
  hasPendingRemoteUpdate?: boolean;
  onOverwrite?: () => void;
}) {
  const [isEditing, setIsEditing] = useState(false);
  const [editText, setEditText] = useState(text);
  const textareaRef = useRef<HTMLTextAreaElement>(null);

  useEffect(() => {
    // Only update editText if not currently editing
    if (!isEditing) {
      setEditText(text);
    }
  }, [text, isEditing]);

  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 startEditing = () => {
    setIsEditing(true);
    onEditingChange?.(true);
  };

  const stopEditing = () => {
    setIsEditing(false);
    onEditingChange?.(false);
  };

  const handleSave = () => {
    // Don't auto-save if there's a pending remote update
    if (hasPendingRemoteUpdate) return;

    if (onUpdate && editText.trim() !== text) {
      onUpdate(editText.trim());
    }
    stopEditing();
  };

  const handleOverwrite = () => {
    if (onUpdate && editText.trim() !== text) {
      onUpdate(editText.trim());
    }
    onOverwrite?.();
    stopEditing();
  };

  const handleCancel = () => {
    setEditText(text);
    stopEditing();
  };

  const handleKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === "Escape") {
      handleCancel();
    }
    // Ctrl/Cmd + Enter to save - disabled if there's a pending remote update
    if (e.key === "Enter" && (e.ctrlKey || e.metaKey) && !hasPendingRemoteUpdate) {
      handleSave();
    }
  };

  const handleBlur = () => {
    // Don't auto-save on blur if there's a pending remote update
    if (!hasPendingRemoteUpdate) {
      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={handleBlur}
          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]"
        />
        {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 (
    <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 && startEditing()}
    >
      {text}
    </p>
  );
}

function ChartElement({
  chartType,
  data,
  title,
  config,
}: {
  chartType: "line" | "bar" | "pie" | "area";
  data: Record<string, unknown>[];
  title?: string;
  config?: Record<string, unknown>;
}) {
  return (
    <div className="border border-[#333] p-4 bg-black/30">
      <ChartRenderer
        chartType={chartType}
        data={data}
        title={title}
        config={config}
      />
    </div>
  );
}

function ImageElement({
  src,
  alt,
  caption,
}: {
  src: string;
  alt?: string;
  caption?: string;
}) {
  return (
    <figure className="space-y-2">
      <img
        src={src}
        alt={alt || ""}
        className="max-w-full border border-[#333]"
      />
      {caption && (
        <figcaption className="text-[#555] font-mono text-xs italic">
          {caption}
        </figcaption>
      )}
    </figure>
  );
}

function CodeElement({
  language,
  content,
}: {
  language?: string;
  content: string;
}) {
  return (
    <div className="relative">
      {language && (
        <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]">
          {language}
        </div>
      )}
      <pre className="bg-[#0d0d0d] border border-[#333] p-4 overflow-x-auto">
        <code className="font-mono text-sm text-[#9bc3ff] whitespace-pre">
          {content}
        </code>
      </pre>
    </div>
  );
}

function ListElement({
  ordered,
  items,
}: {
  ordered: boolean;
  items: string[];
}) {
  const ListTag = ordered ? "ol" : "ul";
  return (
    <ListTag className={`font-mono text-sm text-white/80 leading-relaxed pl-6 space-y-1 ${ordered ? "list-decimal" : "list-disc"}`}>
      {items.map((item, index) => (
        <li key={index} className="pl-1">
          {item}
        </li>
      ))}
    </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>
  );
}