diff --git a/frontend/src/components/dashboard/Note.jsx b/frontend/src/components/dashboard/Note.jsx
index 885973b..92d95c7 100644
--- a/frontend/src/components/dashboard/Note.jsx
+++ b/frontend/src/components/dashboard/Note.jsx
@@ -1,6 +1,11 @@
-import { useState, useRef, useEffect, useCallback } from 'react';
+import { useState, useRef, useEffect } from 'react';
import FloatingToolbar from './FloatingToolbar';
+import NoteToolbar from './NoteToolbar';
+import NoteEditor from './NoteEditor';
+import NotePreview from './NotePreview';
+import TableModal from './TableModal';
import {
+ FiEdit,
FiBold,
FiItalic,
FiUnderline,
@@ -10,14 +15,9 @@ import {
FiLink,
FiTable,
FiType,
- FiRotateCcw,
- FiRotateCw,
- FiEye,
- FiEdit,
FiCode,
FiMinus,
FiStar,
- FiChevronDown,
FiCopy,
FiClipboard,
} from 'react-icons/fi';
@@ -25,9 +25,9 @@ import { FaQuoteRight, FaListOl, FaStrikethrough, FaHighlighter } from 'react-ic
import { BiCodeBlock, BiMath } from 'react-icons/bi';
import toast from 'react-hot-toast';
import propTypes from 'prop-types';
-import 'highlight.js/styles/atom-one-dark.css';
-import { renderMarkdown } from '../../utils/markdownRenderer.js';
-import { pagesAPI } from '../../utils/api';
+import { useHistory } from '../../hooks/useHistory';
+import { useImageUpload } from '../../hooks/useImageUpload';
+import { useTableModal } from '../../hooks/useTableModal';
// =============================================================================
// DEVELOPER NOTES
@@ -62,174 +62,56 @@ import { pagesAPI } from '../../utils/api';
const Note = ({ activePage, onContentChange, content = '', onSave }) => {
const [editorContent, setEditorContent] = useState(content);
const [isPreview, setIsPreview] = useState(false);
- const [history, setHistory] = useState([]);
- const [historyIndex, setHistoryIndex] = useState(-1);
- const [isUpdatingFromHistory, setIsUpdatingFromHistory] = useState(false);
// Floating toolbar state
- const [floatingToolbarEnabled, setFloatingToolbarEnabled] = useState(true);
+ const [floatingToolbarEnabled] = useState(true);
const [toolbarVisible, setToolbarVisible] = useState(false);
const [toolbarPos, setToolbarPos] = useState({ x: 0, y: 0 });
const editorRef = useRef(null);
- const lineNumbersRef = useRef(null);
- const [lineCount, setLineCount] = useState(20);
- const [, setIsUploadingImage] = useState(false);
-
- const lastLoadedPageRef = useRef(null);
- // Table modal state
- const [showTableModal, setShowTableModal] = useState(false);
- const [tableRowsInput, setTableRowsInput] = useState('3');
- const [tableColsInput, setTableColsInput] = useState('3');
- const [includeHeader, setIncludeHeader] = useState(true);
- const [includeSerial, setIncludeSerial] = useState(true);
- const modalContentRef = useRef(null);
- const [showScrollToBottom, setShowScrollToBottom] = useState(false);
- const [headerData, setHeaderData] = useState([]);
- const [tableData, setTableData] = useState([]);
-
- useEffect(() => {
- if (content !== editorContent && !isUpdatingFromHistory) {
- setEditorContent(content);
- // Only reset history when switching to a different page
- if (lastLoadedPageRef.current !== activePage?.id) {
- setHistory([content]);
- setHistoryIndex(0);
- lastLoadedPageRef.current = activePage?.id;
- }
- }
- }, [content, activePage?.id, editorContent, isUpdatingFromHistory]);
-
- // useEffect(() => { if (isPreview) hljs.highlightAll(); }, [isPreview, editorContent]);
-
- // Auto-resize textarea to match content so the outer container remains the single scroller
- useEffect(() => {
- const ta = editorRef.current;
- if (!ta) return;
- // Reset height to allow shrink when content is reduced
- ta.style.height = 'auto';
- // Set height to the scrollHeight so the textarea grows with content
- ta.style.height = `${ta.scrollHeight}px`;
- // Also sync the line numbers container height to match textarea for visual alignment
- const ln = lineNumbersRef.current;
- if (ln) {
- ln.style.minHeight = `${ta.scrollHeight}px`;
- }
- // Compute how many line number rows are needed based on textarea's rendered height
- const approxLineHeight = 24; // px - matches the visual line height (h-6 ~ 24px)
- const requiredLines = Math.max(1, Math.floor(ta.scrollHeight / approxLineHeight));
- setLineCount(requiredLines);
- }, [editorContent, activePage?.id]);
-
- // Update visibility of the scroll-to-bottom button when modal content changes
- useEffect(() => {
- if (!showTableModal) return;
- const update = () => {
- const el = modalContentRef.current;
- if (!el) {
- setShowScrollToBottom(false);
- return;
- }
- setShowScrollToBottom(el.scrollHeight > el.clientHeight + 8);
- };
- const t = setTimeout(update, 50);
- window.addEventListener('resize', update);
- return () => {
- clearTimeout(t);
- window.removeEventListener('resize', update);
- };
- }, [showTableModal, tableRowsInput, tableColsInput, includeHeader, includeSerial]);
- const addToHistory = useCallback(
+ // Use custom hooks
+ const { addToHistory, handleUndo, handleRedo, resetHistory, canUndo, canRedo } = useHistory(
+ content,
+ onContentChange
+ );
+ const { handleImageUpload } = useImageUpload(
+ activePage,
+ editorContent,
(newContent) => {
- if (isUpdatingFromHistory) return;
-
- setHistory((prev) => {
- // Remove any history after current index (for when user types after undo)
- const newHistory = prev.slice(0, historyIndex + 1);
- newHistory.push(newContent);
-
- // Limit history to 50 entries
- if (newHistory.length > 50) {
- newHistory.shift();
- return newHistory;
- }
-
- return newHistory;
- });
-
- setHistoryIndex((prev) => {
- const newIndex = prev + 1;
- // Adjust index if we limited history size
- return history.length >= 50 ? 49 : newIndex;
- });
+ setEditorContent(newContent);
+ onContentChange?.(newContent);
},
- [historyIndex, isUpdatingFromHistory, history.length]
+ addToHistory
);
+ const {
+ showTableModal,
+ tableRowsInput,
+ setTableRowsInput,
+ tableColsInput,
+ setTableColsInput,
+ includeHeader,
+ setIncludeHeader,
+ includeSerial,
+ setIncludeSerial,
+ headerData,
+ setHeaderData,
+ tableData,
+ setTableData,
+ openTableModal,
+ closeTableModal,
+ confirmInsertTable,
+ } = useTableModal((text, moveCursor) => insertAtCursor(text, moveCursor));
- // Clicking the outer editor container's empty space should move the cursor there.
- const handleContainerClick = (e) => {
- // Only handle clicks directly on the container (not children like textarea)
- if (e.target !== e.currentTarget) return;
-
- const ta = editorRef.current;
- if (!ta) return;
-
- // Append a couple of newlines to create an empty area and place cursor at end
- const appended = '\n\n';
- const newContent = `${editorContent}${appended}`;
- setEditorContent(newContent);
- addToHistory(newContent);
- if (onContentChange) onContentChange(newContent);
-
- // Focus and move caret to end after DOM updates
- setTimeout(() => {
- ta.focus();
- ta.setSelectionRange(newContent.length, newContent.length);
- }, 0);
- };
-
- const handleKeyDown = (e) => {
- if (e.ctrlKey || e.metaKey) {
- switch (e.key.toLowerCase()) {
- case 'z':
- if (e.shiftKey) {
- e.preventDefault();
- handleRedo();
- } else {
- e.preventDefault();
- handleUndo();
- }
- break;
- case 'y':
- e.preventDefault();
- handleRedo();
- break;
- case 'b':
- e.preventDefault();
- wrapSelectedText('**', '**', 'bold text');
- break;
- case 'i':
- e.preventDefault();
- wrapSelectedText('*', '*', 'italic text');
- break;
- case 's':
- e.preventDefault();
- if (onSave) {
- onSave();
- toast.success('Note saved!');
- }
- break;
- default:
- break;
- }
+ useEffect(() => {
+ if (content !== editorContent) {
+ setEditorContent(content);
+ resetHistory(content, activePage?.id);
}
- };
+ }, [content, activePage?.id, editorContent, resetHistory]);
const handleContentChange = (e) => {
const newContent = e.target.value;
setEditorContent(newContent);
-
addToHistory(newContent);
-
if (onContentChange) {
onContentChange(newContent);
}
@@ -312,472 +194,127 @@ const Note = ({ activePage, onContentChange, content = '', onSave }) => {
}
};
- const handleUndo = () => {
- if (historyIndex > 0) {
- setIsUpdatingFromHistory(true);
- const newIndex = historyIndex - 1;
- setHistoryIndex(newIndex);
- const previousContent = history[newIndex];
- setEditorContent(previousContent);
-
- if (onContentChange) {
- onContentChange(previousContent);
- }
-
- setTimeout(() => setIsUpdatingFromHistory(false), 0);
- }
- };
-
- const handleRedo = () => {
- if (historyIndex < history.length - 1) {
- setIsUpdatingFromHistory(true);
- const newIndex = historyIndex + 1;
- setHistoryIndex(newIndex);
- const nextContent = history[newIndex];
- setEditorContent(nextContent);
-
- if (onContentChange) {
- onContentChange(nextContent);
- }
-
- setTimeout(() => setIsUpdatingFromHistory(false), 0);
- }
- };
-
- const scrollModalToBottom = () => {
- const el = modalContentRef.current;
- if (!el) return;
- el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
- };
-
- const openTableModal = () => {
- setTableRowsInput('3');
- setTableColsInput('3');
- setIncludeHeader(true);
- setIncludeSerial(true);
- const r = 3;
- const c = 3;
- setHeaderData(Array.from({ length: c }, (_, i) => `Header ${i + 1}`));
- setTableData(
- Array.from({ length: r }, (_, ri) =>
- Array.from({ length: c }, (_, ci) => `Cell ${ri * c + ci + 1}`)
- )
- );
- setShowTableModal(true);
- };
-
- // When rows or cols inputs change, clamp to max 20 and resize header/table data accordingly
- useEffect(() => {
- const rows = Math.max(1, Math.min(20, parseInt(tableRowsInput || '1', 10)));
- const cols = Math.max(1, Math.min(20, parseInt(tableColsInput || '1', 10)));
-
- setHeaderData((prev) => {
- const next = prev ? [...prev] : [];
- for (let i = 0; i < cols; i++) if (next[i] === undefined) next[i] = `Header ${i + 1}`;
- next.length = cols;
- return next;
- });
-
- setTableData((prev) => {
- const next = prev ? prev.map((r) => [...r]) : [];
- for (let r = 0; r < rows; r++) {
- if (!next[r]) next[r] = Array.from({ length: cols }, (_, c) => `Cell ${r * cols + c + 1}`);
- for (let c = 0; c < cols; c++)
- if (next[r][c] === undefined) next[r][c] = `Cell ${r * cols + c + 1}`;
- next[r].length = cols;
- }
- next.length = rows;
- return next;
- });
- }, [tableRowsInput, tableColsInput]);
-
- const closeTableModal = () => setShowTableModal(false);
-
- const confirmInsertTable = () => {
- const rows = parseInt(tableRowsInput, 10);
- const cols = parseInt(tableColsInput, 10);
- if (Number.isNaN(rows) || Number.isNaN(cols) || rows < 1 || cols < 1) {
- toast.error('Invalid table size. Rows and columns must be positive integers.');
- return;
- }
- const MAX = 20;
- const rClamped = Math.max(1, Math.min(MAX, rows));
- const cClamped = Math.max(1, Math.min(MAX, cols));
- const totalCols = (includeSerial ? 1 : 0) + cClamped;
+ // Clicking the outer editor container's empty space should move the cursor there.
+ const handleContainerClick = (e) => {
+ // Only handle clicks directly on the container (not children like textarea)
+ if (e.target !== e.currentTarget) return;
- const headerCells = [];
- if (includeSerial) headerCells.push(includeHeader ? '#' : '');
- for (let i = 0; i < cClamped; i++) {
- const hv = headerData[i];
- headerCells.push(includeHeader ? (hv ?? `Header ${i + 1}`) : '');
- }
- const headerRow = `| ${headerCells.join(' | ')} |`;
- const separatorCells = Array.from({ length: totalCols }, () => '---');
- const separatorRowStr = `| ${separatorCells.join(' | ')} |`;
+ const ta = editorRef.current;
+ if (!ta) return;
- const dataRows = [];
- for (let r = 0; r < rClamped; r++) {
- const rowCells = [];
- if (includeSerial) rowCells.push(`${r + 1}`);
- for (let c = 0; c < cClamped; c++) {
- const val =
- tableData[r] && tableData[r][c] ? tableData[r][c] : `Cell ${r * cClamped + c + 1}`;
- const safeVal = String(val).replace(/\|/g, '\\|');
- rowCells.push(safeVal);
- }
- dataRows.push(`| ${rowCells.join(' | ')} |`);
- }
+ // Append a couple of newlines to create an empty area and place cursor at end
+ const appended = '\n\n';
+ const newContent = `${editorContent}${appended}`;
+ setEditorContent(newContent);
+ addToHistory(newContent);
+ if (onContentChange) onContentChange(newContent);
- const tableMarkdown = `\n${headerRow}\n${separatorRowStr}\n${dataRows.join('\n')}\n`;
- insertAtCursor(tableMarkdown, 1);
- toast.success('Table inserted');
- closeTableModal();
+ // Focus and move caret to end after DOM updates
+ setTimeout(() => {
+ ta.focus();
+ ta.setSelectionRange(newContent.length, newContent.length);
+ }, 0);
};
- // Handle clipboard paste events
- useEffect(() => {
- const handlePaste = async (e) => {
- // Only process if we have an active page and the textarea is focused
- if (
- !activePage ||
- !activePage.id ||
- !editorRef.current ||
- document.activeElement !== editorRef.current
- )
- return;
-
- // Check if the clipboard contains image data
- const items = e.clipboardData && e.clipboardData.items;
- if (!items) return;
-
- let hasImageItem = false;
- for (let i = 0; i < items.length; i++) {
- const item = items[i];
-
- // Check if the item is an image
- if (item.type.indexOf('image') === 0) {
- hasImageItem = true;
- // Prevent default paste behavior
+ const handleKeyDown = (e) => {
+ if (e.ctrlKey || e.metaKey) {
+ switch (e.key.toLowerCase()) {
+ case 'z':
+ if (e.shiftKey) {
+ e.preventDefault();
+ handleRedo();
+ } else {
+ e.preventDefault();
+ handleUndo();
+ }
+ break;
+ case 'y':
e.preventDefault();
-
- // Get the image blob
- const blob = item.getAsFile();
- if (!blob) continue;
-
- try {
- setIsUploadingImage(true);
-
- // Show a loading toast
- const loadingToast = toast.loading('Uploading pasted image...');
-
- // Insert temporary placeholder at cursor position
- const position = editorRef.current.selectionStart;
- const tempPlaceholder = '![Uploading image...]()';
-
- const newContent =
- editorContent.substring(0, position) +
- tempPlaceholder +
- editorContent.substring(position);
-
- setEditorContent(newContent);
- if (onContentChange) onContentChange(newContent);
-
- // Convert blob to base64
- const reader = new FileReader();
- reader.readAsDataURL(blob);
- reader.onload = async () => {
- try {
- // Upload the image
- const response = await pagesAPI.uploadImage(reader.result, activePage.id);
-
- if (response.status === 200 && response.data && response.data.imageUrl) {
- // Replace the placeholder with actual image markdown
- const imageMarkdown = ``;
- const updatedContent = newContent.replace(tempPlaceholder, imageMarkdown);
-
- setEditorContent(updatedContent);
- addToHistory(updatedContent);
-
- if (onContentChange) onContentChange(updatedContent);
-
- toast.dismiss(loadingToast);
- toast.success('Image uploaded successfully');
- } else {
- throw new Error((response.data && response.data.message) || 'Upload failed');
- }
- } catch (error) {
- // Remove placeholder on error
- const updatedContent = newContent.replace(tempPlaceholder, '');
- setEditorContent(updatedContent);
- if (onContentChange) onContentChange(updatedContent);
-
- toast.dismiss(loadingToast);
- toast.error(`Failed to upload image: ${error.message || 'Unknown error'}`);
- console.error('Image upload error:', error);
- }
- };
-
- reader.onerror = () => {
- // Remove placeholder on error
- const updatedContent = newContent.replace(tempPlaceholder, '');
- setEditorContent(updatedContent);
- if (onContentChange) onContentChange(updatedContent);
-
- toast.dismiss(loadingToast);
- toast.error('Failed to read image data');
- };
- } finally {
- setIsUploadingImage(false);
+ handleRedo();
+ break;
+ case 'b':
+ e.preventDefault();
+ wrapSelectedText('**', '**', 'bold text');
+ break;
+ case 'i':
+ e.preventDefault();
+ wrapSelectedText('*', '*', 'italic text');
+ break;
+ case 's':
+ e.preventDefault();
+ if (onSave) {
+ onSave();
+ toast.success('Note saved!');
}
-
- // Only process the first image
break;
- }
+ default:
+ break;
}
-
- // If no image was found in clipboard, let the default paste behavior occur
- if (!hasImageItem) return;
- };
-
- // Add paste event listener to the document
- document.addEventListener('paste', handlePaste);
-
- // Clean up the event listener when component unmounts
- return () => document.removeEventListener('paste', handlePaste);
- }, [activePage, editorContent, onContentChange, addToHistory]);
-
- // Update the handleImageUpload function to use Cloudinary
- const handleImageUpload = () => {
- const input = document.createElement('input');
- input.type = 'file';
- input.accept = 'image/*';
- input.onchange = async (e) => {
- const file = e.target.files && e.target.files[0];
- if (!file || !activePage || !activePage.id) {
- toast.error('Please select an image or make sure a page is active');
- return;
+ } else if (e.key === 'Enter') {
+ // Handle list continuation
+ const textarea = editorRef.current;
+ if (!textarea) return;
+
+ const cursorPos = textarea.selectionStart;
+ const text = editorContent;
+
+ // Find the start of the current line
+ let lineStart = cursorPos;
+ while (lineStart > 0 && text[lineStart - 1] !== '\n') {
+ lineStart--;
}
- try {
- setIsUploadingImage(true);
- const loadingToast = toast.loading(`Uploading ${file.name}...`);
-
- // Insert temporary placeholder
- const cursorPos = editorRef.current
- ? editorRef.current.selectionStart
- : editorContent.length;
- const tempPlaceholder = `![Uploading ${file.name}...]()`;
-
- const newContent =
- editorContent.substring(0, cursorPos) +
- tempPlaceholder +
- editorContent.substring(cursorPos);
-
- setEditorContent(newContent);
- if (onContentChange) onContentChange(newContent);
-
- // Convert file to base64
- const reader = new FileReader();
- reader.readAsDataURL(file);
- reader.onload = async () => {
- try {
- // Upload the image
- const response = await pagesAPI.uploadImage(reader.result, activePage.id);
-
- if (response.status === 200 && response.data && response.data.imageUrl) {
- // Replace placeholder with actual image markdown
- const imageMarkdown = ``;
- const updatedContent = newContent.replace(tempPlaceholder, imageMarkdown);
-
- setEditorContent(updatedContent);
- addToHistory(updatedContent);
-
- if (onContentChange) onContentChange(updatedContent);
-
- toast.dismiss(loadingToast);
- toast.success('Image uploaded successfully');
- } else {
- throw new Error((response.data && response.data.message) || 'Upload failed');
- }
- } catch (error) {
- // Remove placeholder on error
- const updatedContent = newContent.replace(tempPlaceholder, '');
- setEditorContent(updatedContent);
- if (onContentChange) onContentChange(updatedContent);
-
- toast.dismiss(loadingToast);
- toast.error(`Failed to upload image: ${error.message || 'Unknown error'}`);
- console.error('Image upload error:', error);
- }
- };
-
- reader.onerror = () => {
- // Remove placeholder on error
- const updatedContent = newContent.replace(tempPlaceholder, '');
- setEditorContent(updatedContent);
- if (onContentChange) onContentChange(updatedContent);
-
- toast.dismiss(loadingToast);
- toast.error('Failed to read image file');
- };
- } finally {
- setIsUploadingImage(false);
+ // Get the current line
+ let lineEnd = cursorPos;
+ while (lineEnd < text.length && text[lineEnd] !== '\n') {
+ lineEnd++;
+ }
+ const currentLine = text.substring(lineStart, lineEnd);
+
+ // Check for list patterns
+ const bulletMatch = currentLine.match(/^(\s*)-(\s*.*)?$/);
+ const numberedMatch = currentLine.match(/^(\s*)(\d+)\.(\s*.*)?$/);
+ const taskMatch = currentLine.match(/^(\s*)-(\s*)\[([ x])\](\s*.*)?$/);
+
+ if (bulletMatch) {
+ e.preventDefault();
+ const indent = bulletMatch[1];
+ const hasContent = bulletMatch[2] && bulletMatch[2].trim();
+ if (hasContent) {
+ // Continue bullet list
+ insertAtCursor(`\n${indent}- `, 2);
+ } else {
+ // Exit list - insert newline with space
+ insertAtCursor('\n ', 1);
+ }
+ } else if (numberedMatch) {
+ e.preventDefault();
+ const indent = numberedMatch[1];
+ const number = parseInt(numberedMatch[2], 10);
+ const hasContent = numberedMatch[3] && numberedMatch[3].trim();
+ if (hasContent) {
+ // Continue numbered list with next number
+ insertAtCursor(`\n${indent}${number + 1}. `, 3);
+ } else {
+ // Exit list
+ insertAtCursor('\n ', 1);
+ }
+ } else if (taskMatch) {
+ e.preventDefault();
+ const indent = taskMatch[1];
+ const hasContent = taskMatch[4] && taskMatch[4].trim();
+ if (hasContent) {
+ // Continue task list
+ insertAtCursor(`\n${indent}- [ ] `, 6);
+ } else {
+ // Exit list
+ insertAtCursor('\n ', 1);
+ }
}
- };
- input.click();
+ // If not a list, let default behavior happen
+ }
};
- const toolbarGroups = [
- {
- name: 'History',
- color: 'primary',
- buttons: [
- {
- icon: FiRotateCcw,
- title: 'Undo (Ctrl+Z)',
- onClick: handleUndo,
- disabled: historyIndex <= 0,
- shortcut: 'Ctrl+Z',
- },
- {
- icon: FiRotateCw,
- title: 'Redo (Ctrl+Y)',
- onClick: handleRedo,
- disabled: historyIndex >= history.length - 1,
- shortcut: 'Ctrl+Y',
- },
- ],
- },
- {
- name: 'Format',
- color: 'secondary',
- buttons: [
- {
- icon: FiBold,
- title: 'Bold (Ctrl+B)',
- onClick: () => wrapSelectedText('**', '**', 'bold text'),
- shortcut: 'Ctrl+B',
- },
- {
- icon: FiItalic,
- title: 'Italic (Ctrl+I)',
- onClick: () => wrapSelectedText('*', '*', 'italic text'),
- shortcut: 'Ctrl+I',
- },
- {
- icon: FaStrikethrough,
- title: 'Strikethrough',
- onClick: () => wrapSelectedText('~~', '~~', 'strikethrough text'),
- },
- {
- icon: FiUnderline,
- title: 'Underline',
- onClick: () => wrapSelectedText('', '', 'underlined text'),
- },
- {
- icon: FaHighlighter,
- title: 'Highlight',
- onClick: () => wrapSelectedText('==', '==', 'highlighted text'),
- },
- {
- icon: FiCode,
- title: 'Inline Code',
- onClick: () => wrapSelectedText('`', '`', 'code'),
- },
- ],
- },
- {
- name: 'Structure',
- color: 'accent',
- buttons: [
- {
- icon: FiType,
- title: 'Heading 1',
- onClick: () => insertAtCursor('\n# ', 2),
- },
- {
- icon: FiType,
- title: 'Heading 2',
- onClick: () => insertAtCursor('\n## ', 3),
- variant: 'h2',
- },
- {
- icon: FiType,
- title: 'Heading 3',
- onClick: () => insertAtCursor('\n### ', 4),
- variant: 'h3',
- },
- {
- icon: FaQuoteRight,
- title: 'Blockquote',
- onClick: () => insertAtCursor('\n> ', 2),
- },
- {
- icon: BiCodeBlock,
- title: 'Code Block',
- onClick: () => insertAtCursor('\n```javascript\n\n```\n', 15),
- },
- {
- icon: FiMinus,
- title: 'Horizontal Rule',
- onClick: () => insertAtCursor('\n---\n', 1),
- },
- ],
- },
- {
- name: 'Lists',
- color: 'success',
- buttons: [
- {
- icon: FiList,
- title: 'Bullet List',
- onClick: () => insertAtCursor('\n- ', 2),
- },
- {
- icon: FaListOl,
- title: 'Numbered List',
- onClick: () => insertAtCursor('\n1. ', 3),
- },
- {
- icon: FiCheck,
- title: 'Task List',
- onClick: () => insertAtCursor('\n- [ ] ', 6),
- },
- {
- icon: FiStar,
- title: 'Definition List',
- onClick: () => insertAtCursor('\nTerm\n: Definition\n', 1),
- },
- ],
- },
- {
- name: 'Media',
- color: 'warning',
- buttons: [
- {
- icon: FiImage,
- title: 'Add Image',
- onClick: handleImageUpload,
- },
- {
- icon: FiLink,
- title: 'Add Link',
- onClick: () => wrapSelectedText('[', '](url)', 'Link text'),
- },
- {
- icon: FiTable,
- title: 'Add Table',
- onClick: openTableModal,
- },
- {
- icon: BiMath,
- title: 'Math Formula',
- onClick: () => wrapSelectedText('$', '$', 'x^2 + y^2 = z^2'),
- },
- ],
- },
- ];
-
if (!activePage) {
return (
@@ -809,7 +346,72 @@ const Note = ({ activePage, onContentChange, content = '', onSave }) => {
onClose={() => setToolbarVisible(false)}
>
{(() => {
- const allButtons = toolbarGroups.flatMap((group) => group.buttons);
+ const allButtons = [
+ {
+ icon: FiBold,
+ title: 'Bold',
+ onClick: () => wrapSelectedText('**', '**', 'bold text'),
+ },
+ {
+ icon: FiItalic,
+ title: 'Italic',
+ onClick: () => wrapSelectedText('*', '*', 'italic text'),
+ },
+ {
+ icon: FaStrikethrough,
+ title: 'Strikethrough',
+ onClick: () => wrapSelectedText('~~', '~~', 'strikethrough text'),
+ },
+ {
+ icon: FiUnderline,
+ title: 'Underline',
+ onClick: () => wrapSelectedText('
', '', 'underlined text'),
+ },
+ {
+ icon: FaHighlighter,
+ title: 'Highlight',
+ onClick: () => wrapSelectedText('==', '==', 'highlighted text'),
+ },
+ {
+ icon: FiCode,
+ title: 'Inline Code',
+ onClick: () => wrapSelectedText('`', '`', 'code'),
+ },
+ { icon: FiType, title: 'Heading 1', onClick: () => insertAtCursor('\n# ', 2) },
+ { icon: FiType, title: 'Heading 2', onClick: () => insertAtCursor('\n## ', 3) },
+ { icon: FiType, title: 'Heading 3', onClick: () => insertAtCursor('\n### ', 4) },
+ { icon: FaQuoteRight, title: 'Blockquote', onClick: () => insertAtCursor('\n> ', 2) },
+ {
+ icon: BiCodeBlock,
+ title: 'Code Block',
+ onClick: () => insertAtCursor('\n```javascript\n\n```\n', 15),
+ },
+ {
+ icon: FiMinus,
+ title: 'Horizontal Rule',
+ onClick: () => insertAtCursor('\n---\n', 1),
+ },
+ { icon: FiList, title: 'Bullet List', onClick: () => insertAtCursor('\n- ', 2) },
+ { icon: FaListOl, title: 'Numbered List', onClick: () => insertAtCursor('\n1. ', 3) },
+ { icon: FiCheck, title: 'Task List', onClick: () => insertAtCursor('\n- [ ] ', 6) },
+ {
+ icon: FiStar,
+ title: 'Definition List',
+ onClick: () => insertAtCursor('\nTerm\n: Definition\n', 1),
+ },
+ { icon: FiImage, title: 'Add Image', onClick: handleImageUpload },
+ {
+ icon: FiLink,
+ title: 'Add Link',
+ onClick: () => wrapSelectedText('[', '](url)', 'Link text'),
+ },
+ { icon: FiTable, title: 'Add Table', onClick: openTableModal },
+ {
+ icon: BiMath,
+ title: 'Math Formula',
+ onClick: () => wrapSelectedText('$', '$', 'x^2 + y^2 = z^2'),
+ },
+ ];
const rows = [allButtons.slice(0, 8), allButtons.slice(8, 16), allButtons.slice(16, 22)];
// Add Copy and Paste buttons to the last row
const copyPasteIcons = [
@@ -910,394 +512,58 @@ const Note = ({ activePage, onContentChange, content = '', onSave }) => {
);
})()}
- {/* Enhanced Toolbar */}
-
-
-
- {/* Enhanced Toolbar Groups */}
-
- {toolbarGroups.map((group, groupIndex) => (
-
- {group.buttons
- .slice(
- 0,
- window.innerWidth < 1024 && groupIndex > 2
- ? 1
- : window.innerWidth < 768 && groupIndex > 1
- ? 2
- : group.buttons.length
- )
- .map((button, buttonIndex) => {
- const Icon = button.icon;
- const colorClass = `hover:btn-${group.color}`;
-
- return (
-
- );
- })}
-
- ))}
-
- {/* Enhanced Preview Toggle */}
-
-
-
-
-
-
- {/* Status Bar */}
-
-
-
-
- {isPreview ? 'Preview Mode' : 'Edit Mode'}
-
-
{editorContent.length} characters
-
- {editorContent.split(/\s+/).filter((word) => word.length > 0).length} words
-
-
-
- Press
- Ctrl
- +
- S
- to save
-
-
-
-
+
{/* Enhanced Editor/Preview Area */}
{isPreview ? (
-
- {/* Preview Header */}
-
-
- {/* Enhanced Preview Content */}
-
-
+
) : (
-
- {/* Editor Header */}
-
-
-
-
-
- Markdown Enabled
-
- Auto-save: On
-
-
-
-
- {/* Enhanced Editor */}
-
{
- if (!floatingToolbarEnabled) return;
- e.preventDefault();
- setToolbarPos({ x: e.clientX, y: e.clientY });
- setToolbarVisible(true);
- }}
- className="bg-base-100 rounded-2xl border border-base-300 shadow-lg overflow-x-hidden overflow-y-auto max-h-[70vh] min-h-[70vh] relative flex note-container-scrollable"
- >
- {/* Line Numbers */}
-
- {Array.from({ length: lineCount }, (_, i) => (
-
- {i + 1}
-
- ))}
-
-
-
-
-
+
)}
- {/* Global modal overlay for table insertion to avoid clipping by sticky toolbar */}
- {showTableModal && (
-
-
- {/* Scrollable content area */}
-
-
Insert Table
-
- Specify rows and columns for your table.
-
-
-
-
-
-
-
-
-
-
-
-
- {/* Live preview grid */}
-
-
Preview:
-
-
-
- {includeHeader && (
-
- {includeSerial && (
- |
- #
- |
- )}
- {Array.from(
- { length: Math.max(1, parseInt(tableColsInput || '1', 10)) },
- (_, i) => (
-
- {
- const newHd = [...headerData];
- newHd[i] = e.target.value;
- setHeaderData(newHd);
- }}
- className="bg-transparent border-none p-0 text-sm w-full focus:outline-none"
- />
- |
- )
- )}
-
- )}
-
-
- {Array.from(
- { length: Math.max(1, parseInt(tableRowsInput || '1', 10)) },
- (_, r) => (
-
- {includeSerial && (
- |
- {r + 1}
- |
- )}
- {Array.from(
- { length: Math.max(1, parseInt(tableColsInput || '1', 10)) },
- (_, c) => (
-
- {
- const newTd = tableData.map((row) => [...row]);
- if (!newTd[r]) newTd[r] = [];
- newTd[r][c] = e.target.value;
- setTableData(newTd);
- }}
- className="bg-transparent border-none p-0 text-sm w-full focus:outline-none"
- />
- |
- )
- )}
-
- )
- )}
-
-
-
-
-
- {/* modal actions */}
-
-
-
-
-
- {/* Scroll-to-bottom button shown when content overflows; positioned above actions */}
- {showScrollToBottom && (
-
- )}
-
-
- )}
+
);
};
diff --git a/frontend/src/components/dashboard/NoteEditor.jsx b/frontend/src/components/dashboard/NoteEditor.jsx
new file mode 100644
index 0000000..8a5c527
--- /dev/null
+++ b/frontend/src/components/dashboard/NoteEditor.jsx
@@ -0,0 +1,133 @@
+import { useRef, useEffect, useState } from 'react';
+import propTypes from 'prop-types';
+
+const NoteEditor = ({
+ editorContent,
+ handleContentChange,
+ handleKeyDown,
+ handleContainerClick,
+ floatingToolbarEnabled,
+ setToolbarVisible,
+ setToolbarPos,
+}) => {
+ const editorRef = useRef(null);
+ const lineNumbersRef = useRef(null);
+ const [lineCount, setLineCount] = useState(20);
+
+ // Auto-resize textarea to match content so the outer container remains the single scroller
+ useEffect(() => {
+ const ta = editorRef.current;
+ if (!ta) return;
+ // Reset height to allow shrink when content is reduced
+ ta.style.height = 'auto';
+ // Set height to the scrollHeight so the textarea grows with content
+ ta.style.height = `${ta.scrollHeight}px`;
+ // Also sync the line numbers container height to match textarea for visual alignment
+ const ln = lineNumbersRef.current;
+ if (ln) {
+ ln.style.minHeight = `${ta.scrollHeight}px`;
+ }
+ // Compute how many line number rows are needed based on textarea's rendered height
+ const approxLineHeight = 24; // px - matches the visual line height (h-6 ~ 24px)
+ const requiredLines = Math.max(1, Math.floor(ta.scrollHeight / approxLineHeight));
+ setLineCount(requiredLines);
+ }, [editorContent]);
+
+ return (
+
+ {/* Editor Header */}
+
+
+
+
+
+ Markdown Enabled
+
+
Auto-save: On
+
+
+
+
+ {/* Enhanced Editor */}
+
{
+ if (!floatingToolbarEnabled) return;
+ e.preventDefault();
+ setToolbarPos({ x: e.clientX, y: e.clientY });
+ setToolbarVisible(true);
+ }}
+ className="bg-base-100 rounded-2xl border border-base-300 shadow-lg overflow-x-hidden overflow-y-auto max-h-[70vh] min-h-[70vh] relative flex note-container-scrollable"
+ >
+ {/* Line Numbers */}
+
+ {Array.from({ length: lineCount }, (_, i) => (
+
+ {i + 1}
+
+ ))}
+
+
+
+
+
+ );
+};
+
+NoteEditor.propTypes = {
+ editorContent: propTypes.string.isRequired,
+ handleContentChange: propTypes.func.isRequired,
+ handleKeyDown: propTypes.func.isRequired,
+ handleContainerClick: propTypes.func.isRequired,
+ floatingToolbarEnabled: propTypes.bool.isRequired,
+ setToolbarVisible: propTypes.func.isRequired,
+ setToolbarPos: propTypes.func.isRequired,
+};
+
+export default NoteEditor;
diff --git a/frontend/src/components/dashboard/NotePreview.jsx b/frontend/src/components/dashboard/NotePreview.jsx
new file mode 100644
index 0000000..401c3f2
--- /dev/null
+++ b/frontend/src/components/dashboard/NotePreview.jsx
@@ -0,0 +1,37 @@
+import { FiEye } from 'react-icons/fi';
+import { renderMarkdown } from '../../utils/markdownRenderer.js';
+import propTypes from 'prop-types';
+
+const NotePreview = ({ editorContent }) => {
+ return (
+
+ {/* Preview Header */}
+
+
+ {/* Enhanced Preview Content */}
+
+
+ );
+};
+
+NotePreview.propTypes = {
+ editorContent: propTypes.string.isRequired,
+};
+
+export default NotePreview;
diff --git a/frontend/src/components/dashboard/NoteToolbar.jsx b/frontend/src/components/dashboard/NoteToolbar.jsx
new file mode 100644
index 0000000..75716e1
--- /dev/null
+++ b/frontend/src/components/dashboard/NoteToolbar.jsx
@@ -0,0 +1,310 @@
+import {
+ FiBold,
+ FiItalic,
+ FiUnderline,
+ FiImage,
+ FiList,
+ FiCheck,
+ FiLink,
+ FiTable,
+ FiType,
+ FiRotateCcw,
+ FiRotateCw,
+ FiEye,
+ FiEdit,
+ FiCode,
+ FiMinus,
+ FiStar,
+} from 'react-icons/fi';
+import { FaQuoteRight, FaListOl, FaStrikethrough, FaHighlighter } from 'react-icons/fa';
+import { BiCodeBlock, BiMath } from 'react-icons/bi';
+import propTypes from 'prop-types';
+
+const NoteToolbar = ({
+ isPreview,
+ setIsPreview,
+ canUndo,
+ canRedo,
+ handleUndo,
+ handleRedo,
+ wrapSelectedText,
+ insertAtCursor,
+ handleImageUpload,
+ openTableModal,
+ editorContent,
+}) => {
+ const toolbarGroups = [
+ {
+ name: 'History',
+ color: 'primary',
+ buttons: [
+ {
+ icon: FiRotateCcw,
+ title: 'Undo (Ctrl+Z)',
+ onClick: handleUndo,
+ disabled: !canUndo,
+ shortcut: 'Ctrl+Z',
+ },
+ {
+ icon: FiRotateCw,
+ title: 'Redo (Ctrl+Y)',
+ onClick: handleRedo,
+ disabled: !canRedo,
+ shortcut: 'Ctrl+Y',
+ },
+ ],
+ },
+ {
+ name: 'Format',
+ color: 'secondary',
+ buttons: [
+ {
+ icon: FiBold,
+ title: 'Bold (Ctrl+B)',
+ onClick: () => wrapSelectedText('**', '**', 'bold text'),
+ shortcut: 'Ctrl+B',
+ },
+ {
+ icon: FiItalic,
+ title: 'Italic (Ctrl+I)',
+ onClick: () => wrapSelectedText('*', '*', 'italic text'),
+ shortcut: 'Ctrl+I',
+ },
+ {
+ icon: FaStrikethrough,
+ title: 'Strikethrough',
+ onClick: () => wrapSelectedText('~~', '~~', 'strikethrough text'),
+ },
+ {
+ icon: FiUnderline,
+ title: 'Underline',
+ onClick: () => wrapSelectedText('', '', 'underlined text'),
+ },
+ {
+ icon: FaHighlighter,
+ title: 'Highlight',
+ onClick: () => wrapSelectedText('==', '==', 'highlighted text'),
+ },
+ {
+ icon: FiCode,
+ title: 'Inline Code',
+ onClick: () => wrapSelectedText('`', '`', 'code'),
+ },
+ ],
+ },
+ {
+ name: 'Structure',
+ color: 'accent',
+ buttons: [
+ {
+ icon: FiType,
+ title: 'Heading 1',
+ onClick: () => insertAtCursor('\n# ', 2),
+ },
+ {
+ icon: FiType,
+ title: 'Heading 2',
+ onClick: () => insertAtCursor('\n## ', 3),
+ variant: 'h2',
+ },
+ {
+ icon: FiType,
+ title: 'Heading 3',
+ onClick: () => insertAtCursor('\n### ', 4),
+ variant: 'h3',
+ },
+ {
+ icon: FaQuoteRight,
+ title: 'Blockquote',
+ onClick: () => insertAtCursor('\n> ', 2),
+ },
+ {
+ icon: BiCodeBlock,
+ title: 'Code Block',
+ onClick: () => insertAtCursor('\n```javascript\n\n```\n', 15),
+ },
+ {
+ icon: FiMinus,
+ title: 'Horizontal Rule',
+ onClick: () => insertAtCursor('\n---\n', 1),
+ },
+ ],
+ },
+ {
+ name: 'Lists',
+ color: 'success',
+ buttons: [
+ {
+ icon: FiList,
+ title: 'Bullet List',
+ onClick: () => insertAtCursor('\n- ', 2),
+ },
+ {
+ icon: FaListOl,
+ title: 'Numbered List',
+ onClick: () => insertAtCursor('\n1. ', 3),
+ },
+ {
+ icon: FiCheck,
+ title: 'Task List',
+ onClick: () => insertAtCursor('\n- [ ] ', 6),
+ },
+ {
+ icon: FiStar,
+ title: 'Definition List',
+ onClick: () => insertAtCursor('\nTerm\n: Definition\n', 1),
+ },
+ ],
+ },
+ {
+ name: 'Media',
+ color: 'warning',
+ buttons: [
+ {
+ icon: FiImage,
+ title: 'Add Image',
+ onClick: handleImageUpload,
+ },
+ {
+ icon: FiLink,
+ title: 'Add Link',
+ onClick: () => wrapSelectedText('[', '](url)', 'Link text'),
+ },
+ {
+ icon: FiTable,
+ title: 'Add Table',
+ onClick: openTableModal,
+ },
+ {
+ icon: BiMath,
+ title: 'Math Formula',
+ onClick: () => wrapSelectedText('$', '$', 'x^2 + y^2 = z^2'),
+ },
+ ],
+ },
+ ];
+
+ return (
+
+
+
+ {/* Enhanced Toolbar Groups */}
+
+ {toolbarGroups.map((group, groupIndex) => (
+
+ {group.buttons
+ .slice(
+ 0,
+ window.innerWidth < 1024 && groupIndex > 2
+ ? 1
+ : window.innerWidth < 768 && groupIndex > 1
+ ? 2
+ : group.buttons.length
+ )
+ .map((button, buttonIndex) => {
+ const Icon = button.icon;
+ const colorClass = `hover:btn-${group.color}`;
+
+ return (
+
+ );
+ })}
+
+ ))}
+
+
+ {/* Enhanced Preview Toggle */}
+
+
+
+
+
+
+ {/* Status Bar */}
+
+
+
+
+ {isPreview ? 'Preview Mode' : 'Edit Mode'}
+
+
{editorContent.length} characters
+
{editorContent.split(/\s+/).filter((word) => word.length > 0).length} words
+
+
+ Press
+ Ctrl
+ +
+ S
+ to save
+
+
+
+
+ );
+};
+
+NoteToolbar.propTypes = {
+ isPreview: propTypes.bool.isRequired,
+ setIsPreview: propTypes.func.isRequired,
+ canUndo: propTypes.bool.isRequired,
+ canRedo: propTypes.bool.isRequired,
+ handleUndo: propTypes.func.isRequired,
+ handleRedo: propTypes.func.isRequired,
+ wrapSelectedText: propTypes.func.isRequired,
+ insertAtCursor: propTypes.func.isRequired,
+ handleImageUpload: propTypes.func.isRequired,
+ openTableModal: propTypes.func.isRequired,
+ editorContent: propTypes.string.isRequired,
+ onSave: propTypes.func,
+};
+
+export default NoteToolbar;
diff --git a/frontend/src/components/dashboard/TableModal.jsx b/frontend/src/components/dashboard/TableModal.jsx
new file mode 100644
index 0000000..f04f266
--- /dev/null
+++ b/frontend/src/components/dashboard/TableModal.jsx
@@ -0,0 +1,239 @@
+import { useRef, useEffect, useState } from 'react';
+import { FiChevronDown } from 'react-icons/fi';
+import propTypes from 'prop-types';
+
+const TableModal = ({
+ showTableModal,
+ tableRowsInput,
+ setTableRowsInput,
+ tableColsInput,
+ setTableColsInput,
+ includeHeader,
+ setIncludeHeader,
+ includeSerial,
+ setIncludeSerial,
+ headerData,
+ setHeaderData,
+ tableData,
+ setTableData,
+ closeTableModal,
+ confirmInsertTable,
+}) => {
+ const modalContentRef = useRef(null);
+ const [showScrollToBottom, setShowScrollToBottom] = useState(false);
+
+ // Update visibility of the scroll-to-bottom button when modal content changes
+ useEffect(() => {
+ if (!showTableModal) return;
+ const update = () => {
+ const el = modalContentRef.current;
+ if (!el) {
+ setShowScrollToBottom(false);
+ return;
+ }
+ setShowScrollToBottom(el.scrollHeight > el.clientHeight + 8);
+ };
+ const t = setTimeout(update, 50);
+ window.addEventListener('resize', update);
+ return () => {
+ clearTimeout(t);
+ window.removeEventListener('resize', update);
+ };
+ }, [showTableModal, tableRowsInput, tableColsInput, includeHeader, includeSerial]);
+
+ const scrollModalToBottom = () => {
+ const el = modalContentRef.current;
+ if (!el) return;
+ el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
+ };
+
+ if (!showTableModal) return null;
+
+ return (
+
+
+ {/* Scrollable content area */}
+
+
Insert Table
+
+ Specify rows and columns for your table.
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Live preview grid */}
+
+
Preview:
+
+
+
+ {includeHeader && (
+
+ {includeSerial && (
+ |
+ #
+ |
+ )}
+ {Array.from(
+ { length: Math.max(1, parseInt(tableColsInput || '1', 10)) },
+ (_, i) => (
+
+ {
+ const newHd = [...headerData];
+ newHd[i] = e.target.value;
+ setHeaderData(newHd);
+ }}
+ className="bg-transparent border-none p-0 text-sm w-full focus:outline-none"
+ />
+ |
+ )
+ )}
+
+ )}
+
+
+ {Array.from(
+ { length: Math.max(1, parseInt(tableRowsInput || '1', 10)) },
+ (_, r) => (
+
+ {includeSerial && (
+ |
+ {r + 1}
+ |
+ )}
+ {Array.from(
+ { length: Math.max(1, parseInt(tableColsInput || '1', 10)) },
+ (_, c) => (
+
+ {
+ const newTd = tableData.map((row) => [...row]);
+ if (!newTd[r]) newTd[r] = [];
+ newTd[r][c] = e.target.value;
+ setTableData(newTd);
+ }}
+ className="bg-transparent border-none p-0 text-sm w-full focus:outline-none"
+ />
+ |
+ )
+ )}
+
+ )
+ )}
+
+
+
+
+
+
+ {/* modal actions */}
+
+
+
+
+
+ {/* Scroll-to-bottom button shown when content overflows; positioned above actions */}
+ {showScrollToBottom && (
+
+ )}
+
+
+ );
+};
+
+TableModal.propTypes = {
+ showTableModal: propTypes.bool.isRequired,
+ tableRowsInput: propTypes.string.isRequired,
+ setTableRowsInput: propTypes.func.isRequired,
+ tableColsInput: propTypes.string.isRequired,
+ setTableColsInput: propTypes.func.isRequired,
+ includeHeader: propTypes.bool.isRequired,
+ setIncludeHeader: propTypes.func.isRequired,
+ includeSerial: propTypes.bool.isRequired,
+ setIncludeSerial: propTypes.func.isRequired,
+ headerData: propTypes.array.isRequired,
+ setHeaderData: propTypes.func.isRequired,
+ tableData: propTypes.array.isRequired,
+ setTableData: propTypes.func.isRequired,
+ closeTableModal: propTypes.func.isRequired,
+ confirmInsertTable: propTypes.func.isRequired,
+};
+
+export default TableModal;
diff --git a/frontend/src/hooks/useHistory.js b/frontend/src/hooks/useHistory.js
new file mode 100644
index 0000000..f3be571
--- /dev/null
+++ b/frontend/src/hooks/useHistory.js
@@ -0,0 +1,85 @@
+import { useState, useCallback, useRef } from 'react';
+
+export const useHistory = (initialContent = '', onContentChange) => {
+ const [history, setHistory] = useState([initialContent]);
+ const [historyIndex, setHistoryIndex] = useState(0);
+ const [isUpdatingFromHistory, setIsUpdatingFromHistory] = useState(false);
+ const lastLoadedPageRef = useRef(null);
+
+ const addToHistory = useCallback(
+ (newContent) => {
+ if (isUpdatingFromHistory) return;
+
+ setHistory((prev) => {
+ // Remove any history after current index (for when user types after undo)
+ const newHistory = prev.slice(0, historyIndex + 1);
+ newHistory.push(newContent);
+
+ // Limit history to 50 entries
+ if (newHistory.length > 50) {
+ newHistory.shift();
+ return newHistory;
+ }
+
+ return newHistory;
+ });
+
+ setHistoryIndex((prev) => {
+ const newIndex = prev + 1;
+ // Adjust index if we limited history size
+ return history.length >= 50 ? 49 : newIndex;
+ });
+ },
+ [historyIndex, isUpdatingFromHistory, history.length]
+ );
+
+ const handleUndo = () => {
+ if (historyIndex > 0) {
+ setIsUpdatingFromHistory(true);
+ const newIndex = historyIndex - 1;
+ setHistoryIndex(newIndex);
+ const previousContent = history[newIndex];
+
+ if (onContentChange) {
+ onContentChange(previousContent);
+ }
+
+ setTimeout(() => setIsUpdatingFromHistory(false), 0);
+ }
+ };
+
+ const handleRedo = () => {
+ if (historyIndex < history.length - 1) {
+ setIsUpdatingFromHistory(true);
+ const newIndex = historyIndex + 1;
+ setHistoryIndex(newIndex);
+ const nextContent = history[newIndex];
+
+ if (onContentChange) {
+ onContentChange(nextContent);
+ }
+
+ setTimeout(() => setIsUpdatingFromHistory(false), 0);
+ }
+ };
+
+ const resetHistory = (newContent, pageId) => {
+ if (lastLoadedPageRef.current !== pageId) {
+ setHistory([newContent]);
+ setHistoryIndex(0);
+ lastLoadedPageRef.current = pageId;
+ }
+ };
+
+ return {
+ history,
+ historyIndex,
+ isUpdatingFromHistory,
+ addToHistory,
+ handleUndo,
+ handleRedo,
+ resetHistory,
+ canUndo: historyIndex > 0,
+ canRedo: historyIndex < history.length - 1,
+ };
+};
diff --git a/frontend/src/hooks/useImageUpload.js b/frontend/src/hooks/useImageUpload.js
new file mode 100644
index 0000000..482e874
--- /dev/null
+++ b/frontend/src/hooks/useImageUpload.js
@@ -0,0 +1,133 @@
+import { useState, useEffect, useCallback } from 'react';
+import toast from 'react-hot-toast';
+import { pagesAPI } from '../utils/api';
+
+export const useImageUpload = (activePage, editorContent, onContentChange, addToHistory) => {
+ const [isUploadingImage, setIsUploadingImage] = useState(false);
+
+ const uploadImage = useCallback(
+ async (fileOrBase64, fileName = 'image') => {
+ if (!activePage || !activePage.id) {
+ toast.error('Please select an image or make sure a page is active');
+ return;
+ }
+
+ try {
+ setIsUploadingImage(true);
+ const loadingToast = toast.loading(`Uploading ${fileName}...`);
+
+ // Insert temporary placeholder
+ const cursorPos = document.activeElement?.selectionStart || editorContent.length;
+ const tempPlaceholder = `![Uploading ${fileName}...]()`;
+
+ const newContent =
+ editorContent.substring(0, cursorPos) +
+ tempPlaceholder +
+ editorContent.substring(cursorPos);
+
+ onContentChange(newContent);
+
+ // Upload the image
+ const response = await pagesAPI.uploadImage(fileOrBase64, activePage.id);
+
+ if (response.status === 200 && response.data && response.data.imageUrl) {
+ // Replace placeholder with actual image markdown
+ const imageMarkdown = ``;
+ const updatedContent = newContent.replace(tempPlaceholder, imageMarkdown);
+
+ onContentChange(updatedContent);
+ addToHistory(updatedContent);
+
+ toast.dismiss(loadingToast);
+ toast.success('Image uploaded successfully');
+ } else {
+ throw new Error((response.data && response.data.message) || 'Upload failed');
+ }
+ } catch (error) {
+ // Remove placeholder on error
+ const tempPlaceholder = `![Uploading ${fileName}...]()`;
+ const updatedContent = editorContent.replace(tempPlaceholder, '');
+ onContentChange(updatedContent);
+
+ toast.error(`Failed to upload image: ${error.message || 'Unknown error'}`);
+ console.error('Image upload error:', error);
+ } finally {
+ setIsUploadingImage(false);
+ }
+ },
+ [activePage, editorContent, onContentChange, addToHistory]
+ );
+
+ const handleImageUpload = () => {
+ const input = document.createElement('input');
+ input.type = 'file';
+ input.accept = 'image/*';
+ input.onchange = async (e) => {
+ const file = e.target.files && e.target.files[0];
+ if (!file) return;
+
+ // Convert file to base64
+ const reader = new FileReader();
+ reader.readAsDataURL(file);
+ reader.onload = async () => {
+ await uploadImage(reader.result, file.name);
+ };
+
+ reader.onerror = () => {
+ toast.error('Failed to read image file');
+ };
+ };
+ input.click();
+ };
+
+ // Handle clipboard paste events
+ useEffect(() => {
+ const handlePaste = async (e) => {
+ // Only process if we have an active page and the textarea is focused
+ if (!activePage || !activePage.id || !document.activeElement?.tagName === 'TEXTAREA') return;
+
+ // Check if the clipboard contains image data
+ const items = e.clipboardData && e.clipboardData.items;
+ if (!items) return;
+
+ for (let i = 0; i < items.length; i++) {
+ const item = items[i];
+
+ // Check if the item is an image
+ if (item.type.indexOf('image') === 0) {
+ // Prevent default paste behavior
+ e.preventDefault();
+
+ // Get the image blob
+ const blob = item.getAsFile();
+ if (!blob) continue;
+
+ // Convert blob to base64
+ const reader = new FileReader();
+ reader.readAsDataURL(blob);
+ reader.onload = async () => {
+ await uploadImage(reader.result, 'pasted-image');
+ };
+
+ reader.onerror = () => {
+ toast.error('Failed to read image data');
+ };
+
+ // Only process the first image
+ break;
+ }
+ }
+ };
+
+ // Add paste event listener to the document
+ document.addEventListener('paste', handlePaste);
+
+ // Clean up the event listener when component unmounts
+ return () => document.removeEventListener('paste', handlePaste);
+ }, [activePage, uploadImage]);
+
+ return {
+ isUploadingImage,
+ handleImageUpload,
+ };
+};
diff --git a/frontend/src/hooks/useTableModal.js b/frontend/src/hooks/useTableModal.js
new file mode 100644
index 0000000..b23f9aa
--- /dev/null
+++ b/frontend/src/hooks/useTableModal.js
@@ -0,0 +1,115 @@
+import { useState, useEffect } from 'react';
+import toast from 'react-hot-toast';
+
+export const useTableModal = (insertAtCursor) => {
+ const [showTableModal, setShowTableModal] = useState(false);
+ const [tableRowsInput, setTableRowsInput] = useState('3');
+ const [tableColsInput, setTableColsInput] = useState('3');
+ const [includeHeader, setIncludeHeader] = useState(true);
+ const [includeSerial, setIncludeSerial] = useState(true);
+ const [headerData, setHeaderData] = useState([]);
+ const [tableData, setTableData] = useState([]);
+
+ // When rows or cols inputs change, clamp to max 20 and resize header/table data accordingly
+ useEffect(() => {
+ const rows = Math.max(1, Math.min(20, parseInt(tableRowsInput || '1', 10)));
+ const cols = Math.max(1, Math.min(20, parseInt(tableColsInput || '1', 10)));
+
+ setHeaderData((prev) => {
+ const next = prev ? [...prev] : [];
+ for (let i = 0; i < cols; i++) if (next[i] === undefined) next[i] = `Header ${i + 1}`;
+ next.length = cols;
+ return next;
+ });
+
+ setTableData((prev) => {
+ const next = prev ? prev.map((r) => [...r]) : [];
+ for (let r = 0; r < rows; r++) {
+ if (!next[r]) next[r] = Array.from({ length: cols }, (_, c) => `Cell ${r * cols + c + 1}`);
+ for (let c = 0; c < cols; c++)
+ if (next[r][c] === undefined) next[r][c] = `Cell ${r * cols + c + 1}`;
+ next[r].length = cols;
+ }
+ next.length = rows;
+ return next;
+ });
+ }, [tableRowsInput, tableColsInput]);
+
+ const openTableModal = () => {
+ setTableRowsInput('3');
+ setTableColsInput('3');
+ setIncludeHeader(true);
+ setIncludeSerial(true);
+ const r = 3;
+ const c = 3;
+ setHeaderData(Array.from({ length: c }, (_, i) => `Header ${i + 1}`));
+ setTableData(
+ Array.from({ length: r }, (_, ri) =>
+ Array.from({ length: c }, (_, ci) => `Cell ${ri * c + ci + 1}`)
+ )
+ );
+ setShowTableModal(true);
+ };
+
+ const closeTableModal = () => setShowTableModal(false);
+
+ const confirmInsertTable = () => {
+ const rows = parseInt(tableRowsInput, 10);
+ const cols = parseInt(tableColsInput, 10);
+ if (Number.isNaN(rows) || Number.isNaN(cols) || rows < 1 || cols < 1) {
+ toast.error('Invalid table size. Rows and columns must be positive integers.');
+ return;
+ }
+ const MAX = 20;
+ const rClamped = Math.max(1, Math.min(MAX, rows));
+ const cClamped = Math.max(1, Math.min(MAX, cols));
+ const totalCols = (includeSerial ? 1 : 0) + cClamped;
+
+ const headerCells = [];
+ if (includeSerial) headerCells.push(includeHeader ? '#' : '');
+ for (let i = 0; i < cClamped; i++) {
+ const hv = headerData[i];
+ headerCells.push(includeHeader ? (hv ?? `Header ${i + 1}`) : '');
+ }
+ const headerRow = `| ${headerCells.join(' | ')} |`;
+ const separatorCells = Array.from({ length: totalCols }, () => '---');
+ const separatorRowStr = `| ${separatorCells.join(' | ')} |`;
+
+ const dataRows = [];
+ for (let r = 0; r < rClamped; r++) {
+ const rowCells = [];
+ if (includeSerial) rowCells.push(`${r + 1}`);
+ for (let c = 0; c < cClamped; c++) {
+ const val =
+ tableData[r] && tableData[r][c] ? tableData[r][c] : `Cell ${r * cClamped + c + 1}`;
+ const safeVal = String(val).replace(/\|/g, '\\|');
+ rowCells.push(safeVal);
+ }
+ dataRows.push(`| ${rowCells.join(' | ')} |`);
+ }
+
+ const tableMarkdown = `\n${headerRow}\n${separatorRowStr}\n${dataRows.join('\n')}\n`;
+ insertAtCursor(tableMarkdown, 1);
+ toast.success('Table inserted');
+ closeTableModal();
+ };
+
+ return {
+ showTableModal,
+ tableRowsInput,
+ setTableRowsInput,
+ tableColsInput,
+ setTableColsInput,
+ includeHeader,
+ setIncludeHeader,
+ includeSerial,
+ setIncludeSerial,
+ headerData,
+ setHeaderData,
+ tableData,
+ setTableData,
+ openTableModal,
+ closeTableModal,
+ confirmInsertTable,
+ };
+};