Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
415 changes: 415 additions & 0 deletions docs/OPTIMIZATION_AND_RENDERING_ISSUES.md

Large diffs are not rendered by default.

523 changes: 523 additions & 0 deletions docs/UI_AND_HCI_ANALYSIS.md

Large diffs are not rendered by default.

323 changes: 194 additions & 129 deletions src/App.tsx

Large diffs are not rendered by default.

34 changes: 19 additions & 15 deletions src/components/CodeEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import { useRef, useCallback, useEffect } from "react";
import { useRef, useCallback, useEffect, useMemo } from "react";
import { getImageFromClipboard, saveImageToFile, createMarkdownImage, insertAtCursor } from "../utils/imageUtils";

interface CodeEditorProps {
content: string;
onChange: (content: string) => void;
onCursorChange?: (line: number, column: number) => void;
onImagePaste?: () => void; // Callback when image is successfully pasted
onError?: (message: string) => void; // Callback for error messages
filePath?: string | null; // Current file path for saving images
}

export function CodeEditor({ content, onChange, onCursorChange, onImagePaste, filePath }: CodeEditorProps) {
export function CodeEditor({ content, onChange, onCursorChange, onImagePaste, onError, filePath }: CodeEditorProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const gutterRef = useRef<HTMLDivElement>(null);
const highlightRef = useRef<HTMLDivElement>(null);
Expand All @@ -32,7 +33,7 @@ const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {

// Check if file is saved (required for image storage)
if (!filePath) {
alert('Please save your file first before pasting images.');
onError?.('Please save your file first before pasting images.');
return;
}

Expand Down Expand Up @@ -66,7 +67,7 @@ const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
onImagePaste?.();
} catch (error) {
console.error('Failed to paste image:', error);
alert('Failed to save image. Please try again.');
onError?.('Failed to save image. Please try again.');
}
}
// If no image, let default paste behavior handle text
Expand Down Expand Up @@ -122,8 +123,11 @@ const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
}
}, []);

// Memoize highlighted lines to avoid recalculating on non-content re-renders
const highlightedLines = useMemo(() => lines.map((line) => highlightLine(line)), [content]);

// Syntax highlighting for markdown
const highlightLine = (line: string): React.ReactNode => {
function highlightLine(line: string): React.ReactNode {
// H1 headers
if (line.startsWith("# ")) {
return <span className="text-[var(--syntax-h1)] font-bold">{line}</span>;
Expand Down Expand Up @@ -179,12 +183,12 @@ const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
if (line.includes("**")) {
return highlightBold(line);
}
// Regular text
// Regular text
return <span>{line || "\u00A0"}</span>;
};
}

// Highlight images ![alt](url) - shows truncated for data URLs
const highlightImages = (text: string): React.ReactNode => {
function highlightImages(text: string): React.ReactNode {
const parts: React.ReactNode[] = [];
const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
let lastIndex = 0;
Expand Down Expand Up @@ -220,9 +224,9 @@ const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
}

return parts.length > 0 ? <>{parts}</> : <span>{text}</span>;
};
}

const highlightLinks = (text: string): React.ReactNode => {
function highlightLinks(text: string): React.ReactNode {
const parts: React.ReactNode[] = [];
const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
let lastIndex = 0;
Expand All @@ -247,9 +251,9 @@ const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
}

return parts.length > 0 ? <>{parts}</> : <span>{text}</span>;
};
}

const highlightBold = (text: string): React.ReactNode => {
function highlightBold(text: string): React.ReactNode {
const parts: React.ReactNode[] = [];
const boldRegex = /\*\*([^*]+)\*\*/g;
let lastIndex = 0;
Expand All @@ -273,7 +277,7 @@ const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
}

return parts.length > 0 ? <>{parts}</> : <span>{text}</span>;
};
}

return (
<main className="flex-1 flex overflow-hidden relative">
Expand All @@ -299,9 +303,9 @@ const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
className="absolute inset-0 p-4 font-mono text-sm leading-6 text-[var(--text-primary)] pointer-events-none overflow-hidden whitespace-pre"
aria-hidden="true"
>
{lines.map((line, i) => (
{highlightedLines.map((highlighted, i) => (
<div key={i} className="h-6">
{highlightLine(line)}
{highlighted}
</div>
))}
</div>
Expand Down
61 changes: 61 additions & 0 deletions src/components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { Component, ErrorInfo, ReactNode } from "react";

interface Props {
children: ReactNode;
}

interface State {
hasError: boolean;
error: Error | null;
}

export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}

static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}

componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error("MarkLite crashed:", error, errorInfo);
}

handleReload = () => {
this.setState({ hasError: false, error: null });
};

render() {
if (this.state.hasError) {
return (
<div className="h-screen flex flex-col items-center justify-center bg-[var(--bg-primary)] text-[var(--text-primary)] p-8">
<div className="flex flex-col items-center gap-6 max-w-md text-center">
<span className="material-symbols-outlined text-[48px] text-[var(--danger)]">
error
</span>
<h1 className="text-xl font-bold">Something went wrong</h1>
<p className="text-sm text-[var(--text-secondary)] leading-relaxed">
MarkLite encountered an unexpected error. Your file data should be safe.
</p>
{this.state.error && (
<pre className="w-full text-left text-xs bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg p-4 overflow-auto max-h-32 text-[var(--text-secondary)]">
{this.state.error.message}
</pre>
)}
<button
onClick={this.handleReload}
className="flex items-center gap-2 bg-[var(--accent)] text-[var(--accent-text)] font-medium text-sm px-6 py-2.5 rounded-lg hover:opacity-90 transition-opacity"
>
<span className="material-symbols-outlined text-[20px]">refresh</span>
Try Again
</button>
</div>
</div>
);
}

return this.props.children;
}
}
22 changes: 16 additions & 6 deletions src/components/ExportMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,12 @@ import { exportToHTML, exportToPDF } from '../utils/exportUtils';

interface ExportMenuProps {
fileName: string;
htmlContent: string;
disabled?: boolean;
getExportHtml?: () => string;
}

type ExportFormat = 'html' | 'pdf';

export function ExportMenu({ fileName, htmlContent, disabled = false }: ExportMenuProps) {
export function ExportMenu({ fileName, getExportHtml }: ExportMenuProps) {
const [isOpen, setIsOpen] = useState(false);
const [isExporting, setIsExporting] = useState(false);
const { theme, font, fontSize } = useTheme();
Expand All @@ -31,8 +30,14 @@ export function ExportMenu({ fileName, htmlContent, disabled = false }: ExportMe
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isOpen]);

const disabled = !getExportHtml;

const handleExport = async (format: ExportFormat) => {
if (isExporting || !htmlContent) return;
if (isExporting || !getExportHtml) return;

// Capture HTML on demand from the visible preview
const htmlContent = getExportHtml();
if (!htmlContent) return;

setIsExporting(true);
setIsOpen(false);
Expand All @@ -56,7 +61,10 @@ export function ExportMenu({ fileName, htmlContent, disabled = false }: ExportMe
<button
onClick={() => !disabled && setIsOpen(!isOpen)}
disabled={disabled || isExporting}
className={`btn-press flex items-center gap-1 px-2 py-1 rounded hover:bg-[var(--bg-hover)] transition-colors text-xs ${
aria-label="Export document"
aria-expanded={isOpen}
aria-haspopup="true"
className={`btn-press flex items-center gap-1 px-2 py-1 rounded-lg hover:bg-[var(--bg-hover)] transition-colors text-xs ${
disabled
? 'opacity-40 cursor-not-allowed text-[var(--text-muted)]'
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
Expand All @@ -78,15 +86,17 @@ export function ExportMenu({ fileName, htmlContent, disabled = false }: ExportMe

{/* Simple Dropdown Menu */}
{isOpen && !disabled && (
<div className="absolute left-0 top-full mt-1 w-40 bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg shadow-xl overflow-hidden z-50 animate-fade-in-down">
<div role="menu" aria-label="Export formats" className="absolute left-0 top-full mt-1 w-40 bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg shadow-xl overflow-hidden z-50 animate-fade-in-down">
<button
role="menuitem"
onClick={() => handleExport('html')}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-left hover:bg-[var(--bg-hover)] transition-colors"
>
<span className="material-symbols-outlined text-[18px]">code</span>
<span>HTML</span>
</button>
<button
role="menuitem"
onClick={() => handleExport('pdf')}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-left hover:bg-[var(--bg-hover)] transition-colors"
>
Expand Down
Loading