Markdown Notes App - Complete Setup Guide¶
1. Termux Setup and Installation¶
Install Required Packages¶
# Update package list
pkg update && pkg upgrade
# Install Node.js and npm
pkg install nodejs npm
# Install Git
pkg install git
# Install Android SDK tools (optional for debugging)
pkg install android-tools
# Give Termux storage permissions
termux-setup-storage
Install Expo CLI¶
2. Project Initialization¶
Create Expo Project¶
# Create new Expo project
npx create-expo-app MarkdownNotesApp --template blank
# Navigate to project directory
cd MarkdownNotesApp
# Install required dependencies
npm install react-native-markdown-display
npm install react-native-mathjax-html-to-svg
npm install react-native-webview
npm install @react-native-async-storage/async-storage
npm install react-native-fs
npm install react-native-share
npm install expo-file-system
npm install expo-document-picker
npm install react-native-super-grid
npm install react-native-vector-icons
npm install react-native-modal
npm install react-native-keyboard-aware-scroll-view
# Install Mermaid support
npm install mermaid
npm install react-native-render-html
3. Project Structure¶
MarkdownNotesApp/
├── App.js
├── app.json
├── package.json
├── components/
│ ├── MarkdownEditor.js
│ ├── MarkdownPreview.js
│ ├── FileManager.js
│ ├── MathRenderer.js
│ ├── MermaidRenderer.js
│ └── TableOfContents.js
├── utils/
│ ├── storage.js
│ ├── markdownUtils.js
│ ├── exportUtils.js
│ └── keybindings.js
├── styles/
│ └── styles.js
└── assets/
├── fonts/
└── icons/
4. Core Implementation Files¶
App.js (Main Application)¶
import React, { useState, useEffect, useRef } from 'react';
import {
View,
StyleSheet,
StatusBar,
KeyboardAvoidingView,
Platform,
Alert,
BackHandler,
} from 'react-native';
import MarkdownEditor from './components/MarkdownEditor';
import MarkdownPreview from './components/MarkdownPreview';
import FileManager from './components/FileManager';
import TableOfContents from './components/TableOfContents';
import { loadNotes, saveNote, deleteNote } from './utils/storage';
import { extractTOC } from './utils/markdownUtils';
export default function App() {
const [mode, setMode] = useState('edit'); // 'edit', 'preview', 'files', 'toc'
const [currentNote, setCurrentNote] = useState({
id: null,
title: 'New Note',
content: '# New Note\n\nStart writing your markdown here...',
modified: new Date().toISOString(),
});
const [notes, setNotes] = useState([]);
const [tocItems, setTocItems] = useState([]);
const editorRef = useRef(null);
useEffect(() => {
loadAllNotes();
setupKeyboardHandlers();
}, []);
useEffect(() => {
if (mode === 'toc') {
const toc = extractTOC(currentNote.content);
setTocItems(toc);
}
}, [mode, currentNote.content]);
const loadAllNotes = async () => {
try {
const loadedNotes = await loadNotes();
setNotes(loadedNotes);
if (loadedNotes.length > 0 && !currentNote.id) {
setCurrentNote(loadedNotes[0]);
}
} catch (error) {
Alert.alert('Error', 'Failed to load notes');
}
};
const setupKeyboardHandlers = () => {
const backAction = () => {
handleKeyPress('Escape');
return true;
};
const backHandler = BackHandler.addEventListener('hardwareBackPress', backAction);
return () => backHandler.remove();
};
const handleKeyPress = (key) => {
switch (key) {
case 'i':
if (mode !== 'edit') setMode('edit');
break;
case 'Escape':
if (mode === 'edit') setMode('preview');
else if (mode === 'preview') setMode('files');
else if (mode === 'files' || mode === 'toc') setMode('preview');
break;
case 't':
if (mode === 'preview') setMode('toc');
break;
case 'f':
if (mode === 'preview') setMode('files');
break;
}
};
const handleSaveNote = async () => {
try {
const savedNote = await saveNote(currentNote);
setCurrentNote(savedNote);
await loadAllNotes();
} catch (error) {
Alert.alert('Error', 'Failed to save note');
}
};
const handleContentChange = (content) => {
setCurrentNote(prev => ({
...prev,
content,
modified: new Date().toISOString(),
}));
};
const handleNoteSelect = (note) => {
setCurrentNote(note);
setMode('preview');
};
const handleTocItemPress = (item) => {
// Scroll to heading in preview mode
setMode('preview');
// Implementation for scrolling to specific heading
};
const renderCurrentMode = () => {
switch (mode) {
case 'edit':
return (
<MarkdownEditor
ref={editorRef}
content={currentNote.content}
onContentChange={handleContentChange}
onSave={handleSaveNote}
onKeyPress={handleKeyPress}
/>
);
case 'preview':
return (
<MarkdownPreview
content={currentNote.content}
onKeyPress={handleKeyPress}
/>
);
case 'files':
return (
<FileManager
notes={notes}
currentNote={currentNote}
onNoteSelect={handleNoteSelect}
onDeleteNote={deleteNote}
onRefresh={loadAllNotes}
onKeyPress={handleKeyPress}
/>
);
case 'toc':
return (
<TableOfContents
items={tocItems}
onItemPress={handleTocItemPress}
onKeyPress={handleKeyPress}
/>
);
default:
return null;
}
};
return (
<View style={styles.container}>
<StatusBar barStyle="dark-content" backgroundColor="#ffffff" />
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
{renderCurrentMode()}
</KeyboardAvoidingView>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#ffffff',
},
});
components/MarkdownEditor.js¶
import React, { forwardRef, useImperativeHandle, useRef, useEffect } from 'react';
import {
View,
TextInput,
StyleSheet,
Text,
TouchableOpacity,
KeyboardAvoidingView,
Platform,
} from 'react-native';
import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view';
const MarkdownEditor = forwardRef(({ content, onContentChange, onSave, onKeyPress }, ref) => {
const textInputRef = useRef(null);
const [cursorPosition, setCursorPosition] = React.useState(0);
useImperativeHandle(ref, () => ({
focus: () => textInputRef.current?.focus(),
blur: () => textInputRef.current?.blur(),
insertText: (text) => {
// Implementation for inserting text at cursor position
},
}));
useEffect(() => {
// Auto-focus when entering edit mode
setTimeout(() => {
textInputRef.current?.focus();
}, 100);
}, []);
const handleKeyPress = ({ nativeEvent }) => {
if (nativeEvent.key === 'Escape' || nativeEvent.key === 'Back') {
onKeyPress('Escape');
}
};
const insertMarkdown = (syntax) => {
// Implementation for inserting markdown syntax
const beforeCursor = content.substring(0, cursorPosition);
const afterCursor = content.substring(cursorPosition);
const newContent = beforeCursor + syntax + afterCursor;
onContentChange(newContent);
setCursorPosition(cursorPosition + syntax.length);
};
const toolbarItems = [
{ label: '**B**', syntax: '**bold**' },
{ label: '*I*', syntax: '*italic*' },
{ label: '`C`', syntax: '`code`' },
{ label: '[]', syntax: '[link](url)' },
{ label: '#', syntax: '# ' },
{ label: '$$', syntax: '$$math$$' },
{ label: '```', syntax: '```\ncode\n```' },
];
return (
<KeyboardAvoidingView style={styles.container} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
<View style={styles.toolbar}>
<Text style={styles.title}>Edit Mode (ESC to preview)</Text>
<TouchableOpacity style={styles.saveButton} onPress={onSave}>
<Text style={styles.saveButtonText}>Save</Text>
</TouchableOpacity>
</View>
<View style={styles.markdownToolbar}>
{toolbarItems.map((item, index) => (
<TouchableOpacity
key={index}
style={styles.toolbarButton}
onPress={() => insertMarkdown(item.syntax)}
>
<Text style={styles.toolbarButtonText}>{item.label}</Text>
</TouchableOpacity>
))}
</View>
<KeyboardAwareScrollView style={styles.editorContainer}>
<TextInput
ref={textInputRef}
style={styles.textInput}
value={content}
onChangeText={onContentChange}
multiline
textAlignVertical="top"
placeholder="Start writing your markdown here..."
onKeyPress={handleKeyPress}
onSelectionChange={(event) => setCursorPosition(event.nativeEvent.selection.start)}
autoCapitalize="sentences"
autoCorrect={false}
spellCheck={false}
/>
</KeyboardAwareScrollView>
</KeyboardAvoidingView>
);
});
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#ffffff',
},
toolbar: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: '#e0e0e0',
backgroundColor: '#f5f5f5',
},
title: {
fontSize: 16,
fontWeight: 'bold',
color: '#333',
},
saveButton: {
backgroundColor: '#007AFF',
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 4,
},
saveButtonText: {
color: '#ffffff',
fontWeight: 'bold',
},
markdownToolbar: {
flexDirection: 'row',
paddingHorizontal: 8,
paddingVertical: 8,
borderBottomWidth: 1,
borderBottomColor: '#e0e0e0',
backgroundColor: '#fafafa',
},
toolbarButton: {
paddingHorizontal: 8,
paddingVertical: 4,
marginHorizontal: 2,
backgroundColor: '#e0e0e0',
borderRadius: 4,
},
toolbarButtonText: {
fontSize: 12,
color: '#333',
},
editorContainer: {
flex: 1,
},
textInput: {
flex: 1,
padding: 16,
fontSize: 16,
lineHeight: 24,
fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
color: '#333',
},
});
export default MarkdownEditor;
components/MarkdownPreview.js¶
import React, { useRef } from 'react';
import {
View,
StyleSheet,
Text,
TouchableOpacity,
ScrollView,
} from 'react-native';
import MarkdownDisplay from 'react-native-markdown-display';
import MathRenderer from './MathRenderer';
import MermaidRenderer from './MermaidRenderer';
const MarkdownPreview = ({ content, onKeyPress }) => {
const scrollViewRef = useRef(null);
const processContent = (content) => {
// Process math blocks
content = content.replace(/\$\$(.*?)\$\$/gs, (match, math) => {
return `<Math>${math}</Math>`;
});
// Process inline math
content = content.replace(/\$(.*?)\$/g, (match, math) => {
return `<InlineMath>${math}</InlineMath>`;
});
// Process Mermaid blocks
content = content.replace(/```mermaid\n(.*?)\n```/gs, (match, diagram) => {
return `<Mermaid>${diagram}</Mermaid>`;
});
return content;
};
const customRenderers = {
Math: ({ children }) => <MathRenderer content={children} inline={false} />,
InlineMath: ({ children }) => <MathRenderer content={children} inline={true} />,
Mermaid: ({ children }) => <MermaidRenderer content={children} />,
};
const markdownRules = {
heading1: (node, children, parent, styles) => (
<Text key={node.key} style={[styles.heading1, { marginTop: 20, marginBottom: 10 }]}>
{children}
</Text>
),
heading2: (node, children, parent, styles) => (
<Text key={node.key} style={[styles.heading2, { marginTop: 16, marginBottom: 8 }]}>
{children}
</Text>
),
code_inline: (node, children, parent, styles) => (
<Text key={node.key} style={styles.code_inline}>
{children}
</Text>
),
code_block: (node, children, parent, styles) => (
<View key={node.key} style={styles.code_block}>
<Text style={styles.code_block_text}>{node.content}</Text>
</View>
),
};
const markdownStyles = {
body: {
fontSize: 16,
lineHeight: 24,
color: '#333',
},
heading1: {
fontSize: 28,
fontWeight: 'bold',
color: '#000',
},
heading2: {
fontSize: 24,
fontWeight: 'bold',
color: '#000',
},
heading3: {
fontSize: 20,
fontWeight: 'bold',
color: '#000',
},
paragraph: {
marginBottom: 12,
},
code_inline: {
backgroundColor: '#f0f0f0',
paddingHorizontal: 4,
paddingVertical: 2,
borderRadius: 3,
fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
fontSize: 14,
},
code_block: {
backgroundColor: '#f8f8f8',
padding: 12,
borderRadius: 6,
marginVertical: 8,
borderLeftWidth: 4,
borderLeftColor: '#007AFF',
},
code_block_text: {
fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
fontSize: 14,
color: '#333',
},
blockquote: {
backgroundColor: '#f9f9f9',
borderLeftWidth: 4,
borderLeftColor: '#ccc',
paddingLeft: 12,
paddingVertical: 8,
marginVertical: 8,
},
table: {
borderWidth: 1,
borderColor: '#ddd',
marginVertical: 8,
},
table_head_cell: {
backgroundColor: '#f5f5f5',
padding: 8,
borderWidth: 1,
borderColor: '#ddd',
fontWeight: 'bold',
},
table_cell: {
padding: 8,
borderWidth: 1,
borderColor: '#ddd',
},
};
return (
<View style={styles.container}>
<View style={styles.toolbar}>
<Text style={styles.title}>Preview Mode</Text>
<View style={styles.toolbarButtons}>
<TouchableOpacity style={styles.toolbarButton} onPress={() => onKeyPress('t')}>
<Text style={styles.toolbarButtonText}>TOC</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.toolbarButton} onPress={() => onKeyPress('i')}>
<Text style={styles.toolbarButtonText}>Edit</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.toolbarButton} onPress={() => onKeyPress('f')}>
<Text style={styles.toolbarButtonText}>Files</Text>
</TouchableOpacity>
</View>
</View>
<ScrollView
ref={scrollViewRef}
style={styles.content}
contentContainerStyle={styles.contentContainer}
>
<MarkdownDisplay
style={markdownStyles}
rules={markdownRules}
>
{processContent(content)}
</MarkdownDisplay>
</ScrollView>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#ffffff',
},
toolbar: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: '#e0e0e0',
backgroundColor: '#f5f5f5',
},
title: {
fontSize: 16,
fontWeight: 'bold',
color: '#333',
},
toolbarButtons: {
flexDirection: 'row',
},
toolbarButton: {
backgroundColor: '#007AFF',
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 4,
marginLeft: 8,
},
toolbarButtonText: {
color: '#ffffff',
fontSize: 12,
fontWeight: 'bold',
},
content: {
flex: 1,
},
contentContainer: {
padding: 16,
},
});
export default MarkdownPreview;
utils/storage.js¶
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as FileSystem from 'expo-file-system';
const NOTES_KEY = 'markdown_notes';
const NOTES_DIR = FileSystem.documentDirectory + 'notes/';
// Ensure notes directory exists
const ensureNotesDirectory = async () => {
const dirInfo = await FileSystem.getInfoAsync(NOTES_DIR);
if (!dirInfo.exists) {
await FileSystem.makeDirectoryAsync(NOTES_DIR, { intermediates: true });
}
};
export const loadNotes = async () => {
try {
await ensureNotesDirectory();
const notesJson = await AsyncStorage.getItem(NOTES_KEY);
if (notesJson) {
return JSON.parse(notesJson);
}
return [];
} catch (error) {
console.error('Error loading notes:', error);
return [];
}
};
export const saveNote = async (note) => {
try {
await ensureNotesDirectory();
const notes = await loadNotes();
const noteId = note.id || Date.now().toString();
const updatedNote = {
...note,
id: noteId,
modified: new Date().toISOString(),
};
// Save to file system
const fileName = `${noteId}.md`;
const filePath = NOTES_DIR + fileName;
await FileSystem.writeAsStringAsync(filePath, updatedNote.content);
// Update notes array
const existingIndex = notes.findIndex(n => n.id === noteId);
if (existingIndex >= 0) {
notes[existingIndex] = updatedNote;
} else {
notes.push(updatedNote);
}
// Save to AsyncStorage
await AsyncStorage.setItem(NOTES_KEY, JSON.stringify(notes));
return updatedNote;
} catch (error) {
console.error('Error saving note:', error);
throw error;
}
};
export const deleteNote = async (noteId) => {
try {
const notes = await loadNotes();
const filteredNotes = notes.filter(note => note.id !== noteId);
// Delete file
const fileName = `${noteId}.md`;
const filePath = NOTES_DIR + fileName;
const fileInfo = await FileSystem.getInfoAsync(filePath);
if (fileInfo.exists) {
await FileSystem.deleteAsync(filePath);
}
// Update AsyncStorage
await AsyncStorage.setItem(NOTES_KEY, JSON.stringify(filteredNotes));
return filteredNotes;
} catch (error) {
console.error('Error deleting note:', error);
throw error;
}
};
export const exportNotesAsHTML = async () => {
try {
const notes = await loadNotes();
const exportDir = FileSystem.documentDirectory + 'export/';
// Ensure export directory exists
const dirInfo = await FileSystem.getInfoAsync(exportDir);
if (!dirInfo.exists) {
await FileSystem.makeDirectoryAsync(exportDir, { intermediates: true });
}
// Generate HTML for each note
for (const note of notes) {
const html = generateHTMLFromMarkdown(note.content, note.title);
const htmlPath = exportDir + `${note.id}.html`;
await FileSystem.writeAsStringAsync(htmlPath, html);
}
// Generate index file
const indexHTML = generateIndexHTML(notes);
const indexPath = exportDir + 'index.html';
await FileSystem.writeAsStringAsync(indexPath, indexHTML);
return exportDir;
} catch (error) {
console.error('Error exporting notes:', error);
throw error;
}
};
const generateHTMLFromMarkdown = (content, title) => {
// Basic markdown to HTML conversion
// In a real app, you'd use a proper markdown parser
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>${title}</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; }
code { background: #f0f0f0; padding: 2px 4px; border-radius: 3px; }
pre { background: #f8f8f8; padding: 16px; border-radius: 6px; overflow-x: auto; }
blockquote { border-left: 4px soli
# Complete Remaining Files for Markdown Notes App
## components/FileManager.js
```javascript
import React, { useState } from 'react';
import {
View,
Text,
StyleSheet,
FlatList,
TouchableOpacity,
Alert,
TextInput,
Modal,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import * as DocumentPicker from 'expo-document-picker';
import * as FileSystem from 'expo-file-system';
import { exportNotesAsHTML } from '../utils/storage';
const FileManager = ({ notes, currentNote, onNoteSelect, onDeleteNote, onRefresh, onKeyPress }) => {
const [searchQuery, setSearchQuery] = useState('');
const [showNewNoteModal, setShowNewNoteModal] = useState(false);
const [newNoteTitle, setNewNoteTitle] = useState('');
const [sortBy, setSortBy] = useState('modified'); // 'modified', 'title', 'created'
const filteredNotes = notes
.filter(note =>
note.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
note.content.toLowerCase().includes(searchQuery.toLowerCase())
)
.sort((a, b) => {
switch (sortBy) {
case 'title':
return a.title.localeCompare(b.title);
case 'created':
return new Date(b.created) - new Date(a.created);
case 'modified':
default:
return new Date(b.modified) - new Date(a.modified);
}
});
const handleDeleteNote = (noteId) => {
Alert.alert(
'Delete Note',
'Are you sure you want to delete this note?',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
try {
await onDeleteNote(noteId);
onRefresh();
} catch (error) {
Alert.alert('Error', 'Failed to delete note');
}
},
},
]
);
};
const handleCreateNote = () => {
if (newNoteTitle.trim()) {
const newNote = {
id: Date.now().toString(),
title: newNoteTitle.trim(),
content: `# ${newNoteTitle.trim()}\n\nStart writing your note here...`,
created: new Date().toISOString(),
modified: new Date().toISOString(),
};
onNoteSelect(newNote);
setNewNoteTitle('');
setShowNewNoteModal(false);
}
};
const handleImportFile = async () => {
try {
const result = await DocumentPicker.getDocumentAsync({
type: 'text/*',
copyToCacheDirectory: true,
});
if (!result.canceled) {
const content = await FileSystem.readAsStringAsync(result.assets[0].uri);
const fileName = result.assets[0].name.replace(/\.[^/.]+$/, '');
const importedNote = {
id: Date.now().toString(),
title: fileName,
content: content,
created: new Date().toISOString(),
modified: new Date().toISOString(),
};
onNoteSelect(importedNote);
}
} catch (error) {
Alert.alert('Error', 'Failed to import file');
}
};
const handleExportNotes = async () => {
try {
const exportPath = await exportNotesAsHTML();
Alert.alert(
'Export Complete',
`Notes exported to: ${exportPath}`,
[
{ text: 'OK' },
{
text: 'Share',
onPress: () => {
// Implementation for sharing the export folder
},
},
]
);
} catch (error) {
Alert.alert('Error', 'Failed to export notes');
}
};
const formatDate = (dateString) => {
const date = new Date(dateString);
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit'
});
};
const renderNoteItem = ({ item }) => (
<TouchableOpacity
style={[
styles.noteItem,
item.id === currentNote?.id && styles.selectedNoteItem
]}
onPress={() => onNoteSelect(item)}
>
<View style={styles.noteContent}>
<Text style={styles.noteTitle} numberOfLines={1}>
{item.title}
</Text>
<Text style={styles.notePreview} numberOfLines={2}>
{item.content.replace(/[#*`]/g, '').substring(0, 100)}
</Text>
<Text style={styles.noteDate}>
Modified: {formatDate(item.modified)}
</Text>
</View>
<TouchableOpacity
style={styles.deleteButton}
onPress={() => handleDeleteNote(item.id)}
>
<Ionicons name="trash-outline" size={20} color="#ff4444" />
</TouchableOpacity>
</TouchableOpacity>
);
return (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>Notes ({notes.length})</Text>
<View style={styles.headerButtons}>
<TouchableOpacity style={styles.headerButton} onPress={() => setShowNewNoteModal(true)}>
<Ionicons name="add" size={20} color="#ffffff" />
</TouchableOpacity>
<TouchableOpacity style={styles.headerButton} onPress={handleImportFile}>
<Ionicons name="download-outline" size={20} color="#ffffff" />
</TouchableOpacity>
<TouchableOpacity style={styles.headerButton} onPress={handleExportNotes}>
<Ionicons name="share-outline" size={20} color="#ffffff" />
</TouchableOpacity>
</View>
</View>
<View style={styles.searchContainer}>
<TextInput
style={styles.searchInput}
placeholder="Search notes..."
value={searchQuery}
onChangeText={setSearchQuery}
/>
<TouchableOpacity
style={styles.sortButton}
onPress={() => {
const nextSort = sortBy === 'modified' ? 'title' : sortBy === 'title' ? 'created' : 'modified';
setSortBy(nextSort);
}}
>
<Text style={styles.sortButtonText}>
Sort: {sortBy === 'modified' ? 'Date' : sortBy === 'title' ? 'Title' : 'Created'}
</Text>
</TouchableOpacity>
</View>
<FlatList
data={filteredNotes}
renderItem={renderNoteItem}
keyExtractor={(item) => item.id}
style={styles.notesList}
contentContainerStyle={styles.notesListContent}
showsVerticalScrollIndicator={false}
/>
<View style={styles.footer}>
<Text style={styles.footerText}>ESC to go back</Text>
</View>
<Modal
visible={showNewNoteModal}
animationType="slide"
transparent={true}
onRequestClose={() => setShowNewNoteModal(false)}
>
<View style={styles.modalOverlay}>
<View style={styles.modalContent}>
<Text style={styles.modalTitle}>Create New Note</Text>
<TextInput
style={styles.modalInput}
placeholder="Note title"
value={newNoteTitle}
onChangeText={setNewNoteTitle}
autoFocus
/>
<View style={styles.modalButtons}>
<TouchableOpacity
style={[styles.modalButton, styles.cancelButton]}
onPress={() => setShowNewNoteModal(false)}
>
<Text style={styles.cancelButtonText}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.modalButton, styles.createButton]}
onPress={handleCreateNote}
>
<Text style={styles.createButtonText}>Create</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#ffffff',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: '#e0e0e0',
backgroundColor: '#f5f5f5',
},
title: {
fontSize: 18,
fontWeight: 'bold',
color: '#333',
},
headerButtons: {
flexDirection: 'row',
},
headerButton: {
backgroundColor: '#007AFF',
padding: 8,
borderRadius: 4,
marginLeft: 8,
},
searchContainer: {
flexDirection: 'row',
paddingHorizontal: 16,
paddingVertical: 8,
borderBottomWidth: 1,
borderBottomColor: '#e0e0e0',
},
searchInput: {
flex: 1,
height: 36,
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 4,
paddingHorizontal: 12,
marginRight: 8,
},
sortButton: {
backgroundColor: '#f0f0f0',
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 4,
justifyContent: 'center',
},
sortButtonText: {
fontSize: 12,
color: '#666',
},
notesList: {
flex: 1,
},
notesListContent: {
padding: 16,
},
noteItem: {
flexDirection: 'row',
backgroundColor: '#f9f9f9',
borderRadius: 8,
padding: 12,
marginBottom: 8,
borderWidth: 1,
borderColor: '#e0e0e0',
},
selectedNoteItem: {
backgroundColor: '#e3f2fd',
borderColor: '#007AFF',
},
noteContent: {
flex: 1,
},
noteTitle: {
fontSize: 16,
fontWeight: 'bold',
color: '#333',
marginBottom: 4,
},
notePreview: {
fontSize: 14,
color: '#666',
lineHeight: 20,
marginBottom: 4,
},
noteDate: {
fontSize: 12,
color: '#999',
},
deleteButton: {
padding: 8,
justifyContent: 'center',
alignItems: 'center',
},
footer: {
padding: 16,
borderTopWidth: 1,
borderTopColor: '#e0e0e0',
backgroundColor: '#f5f5f5',
},
footerText: {
textAlign: 'center',
color: '#666',
fontSize: 12,
},
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center',
alignItems: 'center',
},
modalContent: {
backgroundColor: 'white',
padding: 20,
borderRadius: 8,
width: '80%',
maxWidth: 300,
},
modalTitle: {
fontSize: 18,
fontWeight: 'bold',
marginBottom: 16,
textAlign: 'center',
},
modalInput: {
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 4,
padding: 12,
marginBottom: 16,
},
modalButtons: {
flexDirection: 'row',
justifyContent: 'space-between',
},
modalButton: {
flex: 1,
padding: 12,
borderRadius: 4,
marginHorizontal: 4,
},
cancelButton: {
backgroundColor: '#f0f0f0',
},
createButton: {
backgroundColor: '#007AFF',
},
cancelButtonText: {
textAlign: 'center',
color: '#333',
},
createButtonText: {
textAlign: 'center',
color: '#ffffff',
fontWeight: 'bold',
},
});
export default FileManager;
components/MathRenderer.js¶
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { WebView } from 'react-native-webview';
const MathRenderer = ({ content, inline = false }) => {
const mathHTML = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/3.2.2/es5/tex-mml-chtml.js"></script>
<style>
body {
margin: 0;
padding: ${inline ? '0' : '8px'};
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
background: transparent;
overflow: hidden;
}
.math-container {
${inline ? 'display: inline;' : 'display: block; text-align: center;'}
font-size: ${inline ? '16px' : '18px'};
}
mjx-container {
${inline ? 'display: inline !important;' : ''}
}
</style>
<script>
window.MathJax = {
tex: {
inlineMath: [['$', '$'], ['\\\\(', '\\\\)']],
displayMath: [['$$', '$$'], ['\\\\[', '\\\\]']],
processEscapes: true,
processEnvironments: true
},
options: {
skipHtmlTags: ['script', 'noscript', 'style', 'textarea', 'pre']
},
startup: {
ready: () => {
MathJax.startup.defaultReady();
MathJax.startup.promise.then(() => {
// Auto-resize the WebView
const height = document.body.scrollHeight;
window.ReactNativeWebView?.postMessage(JSON.stringify({
type: 'resize',
height: height
}));
});
}
}
};
</script>
</head>
<body>
<div class="math-container">
${inline ? `$${content}$` : `$$${content}$$`}
</div>
</body>
</html>`;
const [webViewHeight, setWebViewHeight] = React.useState(inline ? 30 : 60);
const handleMessage = (event) => {
try {
const data = JSON.parse(event.nativeEvent.data);
if (data.type === 'resize') {
setWebViewHeight(Math.max(data.height, inline ? 30 : 60));
}
} catch (error) {
console.error('Error parsing WebView message:', error);
}
};
return (
<View style={[styles.container, inline && styles.inlineContainer]}>
<WebView
source={{ html: mathHTML }}
style={[styles.webView, { height: webViewHeight }]}
onMessage={handleMessage}
scrollEnabled={false}
showsVerticalScrollIndicator={false}
showsHorizontalScrollIndicator={false}
javaScriptEnabled={true}
domStorageEnabled={true}
startInLoadingState={false}
mixedContentMode="compatibility"
allowsInlineMediaPlayback={true}
mediaPlaybackRequiresUserAction={false}
/>
</View>
);
};
const styles = StyleSheet.create({
container: {
marginVertical: 8,
backgroundColor: 'transparent',
},
inlineContainer: {
marginVertical: 0,
display: 'flex',
flexDirection: 'row',
},
webView: {
backgroundColor: 'transparent',
},
});
export default MathRenderer;
components/MermaidRenderer.js¶
import React, { useState } from 'react';
import { View, StyleSheet, Text, TouchableOpacity } from 'react-native';
import { WebView } from 'react-native-webview';
const MermaidRenderer = ({ content }) => {
const [webViewHeight, setWebViewHeight] = useState(200);
const [error, setError] = useState(null);
const mermaidHTML = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="https://cdnjs.cloudflare.com/ajax/libs/mermaid/9.4.3/mermaid.min.js"></script>
<style>
body {
margin: 0;
padding: 16px;
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
background: #ffffff;
overflow-x: auto;
}
.mermaid {
text-align: center;
background: #ffffff;
}
.error {
color: #ff4444;
padding: 16px;
background: #ffebee;
border-radius: 4px;
border-left: 4px solid #ff4444;
}
</style>
</head>
<body>
<div id="diagram">
<div class="mermaid">
${content}
</div>
</div>
<script>
mermaid.initialize({
startOnLoad: true,
theme: 'default',
themeVariables: {
primaryColor: '#007AFF',
primaryTextColor: '#333333',
primaryBorderColor: '#007AFF',
lineColor: '#333333',
secondaryColor: '#f0f0f0',
tertiaryColor: '#ffffff'
},
flowchart: {
useMaxWidth: true,
htmlLabels: true
},
sequence: {
diagramMarginX: 50,
diagramMarginY: 10,
actorMargin: 50,
width: 150,
height: 65,
boxMargin: 10,
boxTextMargin: 5,
noteMargin: 10,
messageMargin: 35,
mirrorActors: true,
bottomMarginAdj: 1,
useMaxWidth: true
},
gantt: {
titleTopMargin: 25,
barHeight: 20,
fontFamily: '"Open-Sans", "sans-serif"',
fontSize: 11,
gridLineStartPadding: 35,
bottomPadding: 25,
bottomMarginAdj: 1
}
});
// Function to resize WebView
function resizeWebView() {
const height = Math.max(document.body.scrollHeight, 200);
if (window.ReactNativeWebView) {
window.ReactNativeWebView.postMessage(JSON.stringify({
type: 'resize',
height: height
}));
}
}
// Wait for Mermaid to render
setTimeout(() => {
resizeWebView();
}, 1000);
// Listen for window resize
window.addEventListener('resize', resizeWebView);
// Error handling
window.addEventListener('error', function(e) {
if (window.ReactNativeWebView) {
window.ReactNativeWebView.postMessage(JSON.stringify({
type: 'error',
message: e.message
}));
}
});
// Mermaid error handling
mermaid.parseError = function(err, hash) {
if (window.ReactNativeWebView) {
window.ReactNativeWebView.postMessage(JSON.stringify({
type: 'error',
message: 'Mermaid parsing error: ' + err
}));
}
};
</script>
</body>
</html>`;
const handleMessage = (event) => {
try {
const data = JSON.parse(event.nativeEvent.data);
if (data.type === 'resize') {
setWebViewHeight(Math.max(data.height, 200));
setError(null);
} else if (data.type === 'error') {
setError(data.message);
}
} catch (error) {
console.error('Error parsing WebView message:', error);
setError('Failed to render diagram');
}
};
const handleRetry = () => {
setError(null);
setWebViewHeight(200);
};
if (error) {
return (
<View style={styles.errorContainer}>
<Text style={styles.errorTitle}>Mermaid Diagram Error</Text>
<Text style={styles.errorMessage}>{error}</Text>
<TouchableOpacity style={styles.retryButton} onPress={handleRetry}>
<Text style={styles.retryButtonText}>Retry</Text>
</TouchableOpacity>
<View style={styles.codeContainer}>
<Text style={styles.codeTitle}>Original Code:</Text>
<Text style={styles.codeText}>{content}</Text>
</View>
</View>
);
}
return (
<View style={styles.container}>
<WebView
source={{ html: mermaidHTML }}
style={[styles.webView, { height: webViewHeight }]}
onMessage={handleMessage}
scrollEnabled={false}
showsVerticalScrollIndicator={false}
showsHorizontalScrollIndicator={false}
javaScriptEnabled={true}
domStorageEnabled={true}
startInLoadingState={false}
mixedContentMode="compatibility"
allowsInlineMediaPlaybook={true}
mediaPlaybackRequiresUserAction={false}
onError={(syntheticEvent) => {
const { nativeEvent } = syntheticEvent;
setError(`WebView error: ${nativeEvent.description}`);
}}
/>
</View>
);
};
const styles = StyleSheet.create({
container: {
marginVertical: 12,
backgroundColor: '#ffffff',
borderRadius: 8,
borderWidth: 1,
borderColor: '#e0e0e0',
overflow: 'hidden',
},
webView: {
backgroundColor: '#ffffff',
},
errorContainer: {
backgroundColor: '#ffebee',
borderRadius: 8,
borderWidth: 1,
borderColor: '#ffcdd2',
padding: 16,
marginVertical: 12,
},
errorTitle: {
fontSize: 16,
fontWeight: 'bold',
color: '#d32f2f',
marginBottom: 8,
},
errorMessage: {
fontSize: 14,
color: '#d32f2f',
marginBottom: 16,
},
retryButton: {
backgroundColor: '#d32f2f',
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 4,
alignSelf: 'flex-start',
marginBottom: 16,
},
retryButtonText: {
color: '#ffffff',
fontWeight: 'bold',
},
codeContainer: {
backgroundColor: '#f5f5f5',
borderRadius: 4,
padding: 12,
},
codeTitle: {
fontSize: 12,
fontWeight: 'bold',
color: '#666',
marginBottom: 8,
},
codeText: {
fontSize: 12,
fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
color: '#333',
},
});
export default MermaidRenderer;
components/TableOfContents.js¶
```javascript import React from 'react'; import { View, Text, StyleSheet, FlatList, TouchableOpacity, } from 'react-native'; import { Ionicons } from 'expo/vector-icons';
const TableOfContents = ({ items, onItemPress, onKeyPress }) => { const renderTocItem = ({ item, index }) => { const indentLevel = item.level - 1; const indentStyle = { marginLeft: indentLevel * 20, };
return (
<TouchableOpacity
style={[styles.tocItem, indentStyle]}
onPress={() => onItemPress(item)}
>
<View style={styles.tocItemContent}>
<View style={[styles.levelIndicator, { backgroundColor: getLevelColor(item.level) }]} />
<Text style={[styles.tocText, { fontSize: getTocFontSize(item.level) }]}>
{item.text}
</Text>
</View>
<Text style={styles.tocLevel}>H{item.level}</Text>
</TouchableOpacity>
);
};
const getLevelColor = (level) => { const colors = ['#007AFF', '#34C759', '#FF9500', '#FF3B30', '#AF52DE', '#00C7BE']; return colors[(level - 1) % colors.length]; };
const getTocFontSize = (level) => { switch (level) { case 1: return 18; case 2: return 16; case 3: return 15; case 4: return 14; case 5: return 13; case 6: return 12; default: return 14; } };
const getTocStats = () => { const stats = {}; items.forEach(item => { stats[item.level] = (stats[item.level] || 0) + 1; }); return stats; };
const stats = getTocStats();
return (
{items.length > 0 && (
<View style={styles.statsContainer}>
<Text style={styles.statsTitle}>Document Structure:</Text>
<View style={styles.statsRow}>
{Object.entries(stats).map(([level, count]) => (
<View key={level} style={styles.statItem}>
<View style={[styles.statIndicator, { backgroundColor: getLevelColor(parseInt(level)) }]} />
<Text style={styles.statText}>H{level}: {count}</Text>
</View>
))}
</View>
</View>
)}
{items.length === 0 ? (
<View style={styles.emptyContainer}>
<Ionicons name="document-text-outline" size={48} color="#ccc" />
<Text style={styles.emptyTitle}>No Headings Found</Text>
<Text style={styles.emptyText}>
Add headings to your document using # symbols to generate a table of contents.
</Text>
<View style={styles.exampleContainer}>
<Text style={styles.exampleTitle}>Examples:</Text>
<Text style={styles.exampleText}># Main Heading</Text>
<Text style={styles.exampleText}>## Sub Heading</Text>
<Text style={styles.exampleText}>### Sub-sub Heading</Text>
</View>
</View>
) : (
<FlatList
data={items}
renderItem={renderTocItem}
keyExtractor={(item, index) => `${item.level}-${index}`}
style={styles.tocList}
contentContainerStyle={styles.tocListContent}
showsVerticalScrollIndicator={false}
/>
)}
<View style={styles.footer}>
<Text style={styles.footerText}>
{items.length > 0 ? 'Tap a heading to navigate • ' : ''}ESC to go back
</Text>
</View>
</View>
); };
const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: '#ffffff', }, header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: '#e0e0e0', backgroundColor: '#f5f5f5', }, title: { fontSize: 18, fontWeight: 'bold', color: '#333', }, closeButton: { padding: 4, }, statsContainer: { paddingHorizontal: 16, paddingVertical: 12, backgroundColor: '#f9f9f9', borderBottomWidth: 1, borderBottomColor: '#e0e0e0', }, statsTitle: { fontSize: 14, fontWeight: '600', color: '#666', marginBottom: 8, }, statsRow: { flexDirection: 'row', flexWrap: 'wrap', }, statItem: { flexDirection: 'row', alignItems: 'center', marginRight: 16, marginBottom: 4, }, statIndicator: { width: 8, height: 8, borderRadius: 4, marginRight: 6, }, statText: { fontSize: 12, color: '#666', }, tocList: { flex: 1, }, tocListContent: { paddingVertical: 8, },