Skip to content

Commit f236b26

Browse files
committed
Add advanced editor suite with toolbar and search
Implement comprehensive editing enhancements including: - EditorToolbar: Formatting buttons for bold, italic, headings, code, tables, checklists, blockquotes, and more - DocumentStats: Real-time document statistics (words, characters, lines, reading time) - SearchBar: Full-featured search with Ctrl+F support, match navigation, and auto-scroll - ExportMenu: Export to standalone HTML and PDF via print - Synchronized scrolling between editor and preview panes - Keyboard shortcuts for search (Ctrl+F) and navigation - Full internationalization for all 6 supported language locales
1 parent 11749fb commit f236b26

15 files changed

Lines changed: 946 additions & 19 deletions

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
---
99

10+
## [0.1.2_RC2] — 2026-07-01
11+
12+
### ✨ Advanced Editor Suite & Productivity Enhancements
13+
14+
- **Markdown Formatting Toolbar (`EditorToolbar`)**: Added an interactive formatting toolbar in Edit mode with quick-insert buttons for Grassetto (`**`), Corsivo (`*`), Barrato (`~~`), Headings (H1-H3), Code Blocks (```), Inline Code, Links, Markdown Tables, Task Checklists (`- [ ]`), Blockquotes, and Horizontal Rules. Includes intelligent text-selection wrapping and auto-focusing.
15+
- **Real-Time Document Statistics (`DocumentStats`)**: Embedded a live statistics counter in the file info bar showing total words, characters, lines, and estimated reading time (~200 WPM).
16+
- **In-Document Search (`SearchBar`)**: Added a full-featured search bar accessible via `Ctrl+F` / `Cmd+F` or the new `🔍 Find` toolbar button. Supports case-insensitive matching, match count badge ("X of Y"), previous/next navigation (`Enter` / `Shift+Enter`), and automatic scroll-to-highlight in Edit mode.
17+
- **Standalone HTML & PDF Export (`ExportMenu`)**: Added an export dropdown menu to the file info bar:
18+
- **Standalone HTML**: Exports the currently rendered Markdown into a self-contained `.html` file with embedded GitHub-dark/light CSS and typography styling for offline distribution.
19+
- **Print / PDF**: Direct integration with `window.print()` for clean PDF document generation.
20+
- **Bidirectional Synchronized Scrolling**: Implemented smooth, feedback-loop-free synchronized scrolling between the Markdown editor textarea and the Live Preview pane.
21+
- **Symmetric i18n Expansion**: Updated all 6 supported language locales (`it`, `en-US`, `en-GB`, `es`, `fr`, `de`) with full translations for toolbar actions, statistics, search interface, and export options.
22+
23+
---
24+
1025
## [0.1.1_RC1] — 2026-07-01
1126

1227
### ✨ UX & Markdown Rendering Perfection

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
"react": "19.2.6",
2222
"react-dom": "19.2.6",
2323
"react-i18next": "^17.0.8",
24-
"react-markdown": "^10.1.1",
24+
"react-markdown": "^10.1.0",
2525
"rehype-highlight": "^7.0.2",
2626
"rehype-raw": "^7.0.0",
2727
"remark-gfm": "^4.0.1",

src/App.tsx

Lines changed: 77 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,13 @@ import { invoke } from '@tauri-apps/api/core';
99
import { getCurrentWebview } from '@tauri-apps/api/webview';
1010
import { listen } from '@tauri-apps/api/event';
1111
import appIcon from './icon.png';
12+
import { EditorToolbar } from './components/EditorToolbar';
13+
import { DocumentStats } from './components/DocumentStats';
14+
import { SearchBar } from './components/SearchBar';
15+
import { ExportMenu } from './components/ExportMenu';
1216

1317
// ─── Constants ────────────────────────────────────────────────────
14-
const APP_VERSION = '0.1.1_RC1';
18+
const APP_VERSION = '0.1.2_RC2';
1519
const APP_AUTHOR = 'PiBOH';
1620
const APP_WEBSITE = 'https://piboh.github.io/';
1721
const APP_REPO = 'https://github.com/PiBOH/multimdreader';
@@ -248,6 +252,34 @@ export default function App() {
248252
return window.matchMedia('(prefers-color-scheme: dark)').matches;
249253
});
250254
const [aboutOpen, setAboutOpen] = useState<boolean>(false);
255+
const [isSearchOpen, setIsSearchOpen] = useState<boolean>(false);
256+
const textareaRef = useRef<HTMLTextAreaElement>(null);
257+
const previewRef = useRef<HTMLDivElement>(null);
258+
const isScrollingRef = useRef<'editor' | 'preview' | null>(null);
259+
260+
const handleEditorScroll = (e: React.UIEvent<HTMLTextAreaElement>) => {
261+
if (isScrollingRef.current === 'preview' || !previewRef.current) return;
262+
isScrollingRef.current = 'editor';
263+
const textarea = e.currentTarget;
264+
const scrollRatio = textarea.scrollTop / (textarea.scrollHeight - textarea.clientHeight || 1);
265+
const preview = previewRef.current;
266+
preview.scrollTop = scrollRatio * (preview.scrollHeight - preview.clientHeight || 1);
267+
setTimeout(() => {
268+
if (isScrollingRef.current === 'editor') isScrollingRef.current = null;
269+
}, 50);
270+
};
271+
272+
const handlePreviewScroll = (e: React.UIEvent<HTMLDivElement>) => {
273+
if (isScrollingRef.current === 'editor' || !textareaRef.current) return;
274+
isScrollingRef.current = 'preview';
275+
const preview = e.currentTarget;
276+
const scrollRatio = preview.scrollTop / (preview.scrollHeight - preview.clientHeight || 1);
277+
const textarea = textareaRef.current;
278+
textarea.scrollTop = scrollRatio * (textarea.scrollHeight - textarea.clientHeight || 1);
279+
setTimeout(() => {
280+
if (isScrollingRef.current === 'preview') isScrollingRef.current = null;
281+
}, 50);
282+
};
251283
const [isDragging, setIsDragging] = useState<boolean>(false);
252284
const [langDropdownOpen, setLangDropdownOpen] = useState<boolean>(false);
253285
const [fileReadError, setFileReadError] = useState<string | null>(null);
@@ -333,10 +365,16 @@ export default function App() {
333365
e.preventDefault();
334366
setDarkMode(prev => !prev);
335367
}
336-
// Escape: Close about dialog
368+
// Ctrl/Cmd + F: Find in document
369+
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'f') {
370+
e.preventDefault();
371+
setIsSearchOpen(prev => !prev);
372+
}
373+
// Escape: Close about dialog or search
337374
if (e.key === 'Escape') {
338375
setAboutOpen(false);
339376
setLangDropdownOpen(false);
377+
setIsSearchOpen(false);
340378
}
341379
}
342380
document.addEventListener('keydown', handleKeyboard);
@@ -721,6 +759,7 @@ export default function App() {
721759
<div><kbd className="px-1 py-0.5 bg-gray-200 dark:bg-gray-700 rounded text-[10px]">Ctrl+S</kbd> {t('shortcuts.saveFile', 'Save file')}</div>
722760
<div><kbd className="px-1 py-0.5 bg-gray-200 dark:bg-gray-700 rounded text-[10px]">Ctrl+B</kbd> {t('shortcuts.toggleSidebar', 'Toggle sidebar')}</div>
723761
<div><kbd className="px-1 py-0.5 bg-gray-200 dark:bg-gray-700 rounded text-[10px]">Ctrl+D</kbd> {t('shortcuts.toggleDark', 'Toggle theme')}</div>
762+
<div><kbd className="px-1 py-0.5 bg-gray-200 dark:bg-gray-700 rounded text-[10px]">Ctrl+F</kbd> {t('shortcuts.find', 'Find in document')}</div>
724763
</div>
725764
</div>
726765
</aside>
@@ -757,8 +796,26 @@ export default function App() {
757796
<span>{t('reader.fileSize', 'Size')}: {formatFileSize(currentFileSize)}</span>
758797
<span>{t('reader.lastModified', 'Modified')}: {formatDate(currentFileModified, localeForDates)}</span>
759798

799+
<DocumentStats content={currentContent} />
800+
760801
<div className="flex-1" />
761802

803+
{/* Search Button */}
804+
<button
805+
onClick={() => setIsSearchOpen(prev => !prev)}
806+
className="flex items-center gap-1 px-2.5 py-1 text-xs rounded-lg font-medium bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors shadow-sm"
807+
title={t('shortcuts.find', 'Find in document') + ' (Ctrl+F)'}
808+
>
809+
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
810+
<circle cx="11" cy="11" r="8" />
811+
<line x1="21" y1="21" x2="16.65" y2="16.65" />
812+
</svg>
813+
<span>{t('shortcuts.find', 'Find')}</span>
814+
</button>
815+
816+
{/* Export Button */}
817+
<ExportMenu fileName={currentFileName} content={currentContent} />
818+
762819
{/* Save Button */}
763820
<button
764821
onClick={saveFile}
@@ -784,6 +841,12 @@ export default function App() {
784841
</div>
785842

786843
{/* Editing & Reading Content Area */}
844+
<SearchBar
845+
isOpen={isSearchOpen}
846+
onClose={() => setIsSearchOpen(false)}
847+
content={currentContent}
848+
textareaRef={textareaRef}
849+
/>
787850
<div className="flex-1 flex overflow-hidden">
788851
{isEditMode ? (
789852
<div className="flex-1 flex flex-col md:flex-row overflow-hidden w-full">
@@ -793,7 +856,17 @@ export default function App() {
793856
<span>{t('editor.editorTab', 'Markdown Editor')}</span>
794857
{hasUnsavedChanges && <span className="text-teal-600 dark:text-teal-400 font-normal lowercase">{t('editor.unsaved', 'unsaved')}</span>}
795858
</div>
859+
<EditorToolbar
860+
textareaRef={textareaRef}
861+
content={currentContent}
862+
onContentChange={(newVal) => {
863+
setCurrentContent(newVal);
864+
setHasUnsavedChanges(true);
865+
}}
866+
/>
796867
<textarea
868+
ref={textareaRef}
869+
onScroll={handleEditorScroll}
797870
value={currentContent}
798871
onChange={(e) => {
799872
setCurrentContent(e.target.value);
@@ -808,7 +881,7 @@ export default function App() {
808881
<div className="px-4 py-1.5 bg-gray-100 dark:bg-gray-800 text-xs font-semibold text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-gray-700 uppercase tracking-wider">
809882
{t('editor.previewTab', 'Live Preview')}
810883
</div>
811-
<div className="flex-1 overflow-y-auto px-6 py-6">
884+
<div ref={previewRef} onScroll={handlePreviewScroll} className="flex-1 overflow-y-auto px-6 py-6">
812885
<article className="prose prose-gray dark:prose-invert max-w-none prose-headings:scroll-mt-4 prose-a:text-blue-600 dark:prose-a:text-blue-400 prose-pre:p-0">
813886
<ReactMarkdown
814887
remarkPlugins={[remarkGfm]}
@@ -846,7 +919,7 @@ export default function App() {
846919
</div>
847920
) : (
848921
/* Standard Single-Column Read Mode */
849-
<div className="flex-1 overflow-y-auto px-6 py-8 w-full">
922+
<div ref={previewRef} className="flex-1 overflow-y-auto px-6 py-8 w-full">
850923
<article className="prose prose-gray dark:prose-invert max-w-none prose-headings:scroll-mt-4 prose-a:text-blue-600 dark:prose-a:text-blue-400 prose-pre:p-0 mx-auto">
851924
<ReactMarkdown
852925
remarkPlugins={[remarkGfm]}

src/components/DocumentStats.tsx

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import React, { useMemo } from 'react';
2+
import { useTranslation } from 'react-i18next';
3+
4+
interface DocumentStatsProps {
5+
content: string;
6+
}
7+
8+
export const DocumentStats: React.FC<DocumentStatsProps> = ({ content }) => {
9+
const { t } = useTranslation();
10+
11+
const stats = useMemo(() => {
12+
if (!content || !content.trim()) {
13+
return { words: 0, chars: 0, lines: 0, readingTime: 0 };
14+
}
15+
16+
const chars = content.length;
17+
const lines = content.split('\n').length;
18+
const wordsArray = content.trim().split(/\s+/).filter(Boolean);
19+
const words = wordsArray.length;
20+
const readingTime = Math.ceil(words / 200); // Average 200 words per minute
21+
22+
return { words, chars, lines, readingTime };
23+
}, [content]);
24+
25+
return (
26+
<div className="flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400 font-medium select-none bg-gray-100/80 dark:bg-gray-800/80 px-2.5 py-1 rounded-md border border-gray-200/60 dark:border-gray-700/60">
27+
<span>
28+
<strong className="text-gray-700 dark:text-gray-300 font-semibold">{stats.words}</strong> {t('stats.words', 'words')}
29+
</span>
30+
<span className="text-gray-300 dark:text-gray-600"></span>
31+
<span>
32+
<strong className="text-gray-700 dark:text-gray-300 font-semibold">{stats.chars}</strong> {t('stats.characters', 'characters')}
33+
</span>
34+
<span className="text-gray-300 dark:text-gray-600"></span>
35+
<span>
36+
<strong className="text-gray-700 dark:text-gray-300 font-semibold">{stats.lines}</strong> {t('stats.lines', 'lines')}
37+
</span>
38+
{stats.words > 0 && (
39+
<>
40+
<span className="text-gray-300 dark:text-gray-600"></span>
41+
<span className="text-teal-600 dark:text-teal-400">
42+
~{stats.readingTime} {t('stats.readingTime', 'min read')}
43+
</span>
44+
</>
45+
)}
46+
</div>
47+
);
48+
};

0 commit comments

Comments
 (0)