Skip to content

Commit e02df5c

Browse files
committed
Implement support for Markdown preview in TextEditorActivity
Uses Commonmark for Markdown parsing and rendering. Due to the way Commonmark works, it cannot support content streaming and update in realtime; user needs to let TextEditorActivity to load as much content as wish.
1 parent fdefae7 commit e02df5c

File tree

21 files changed

+697
-53
lines changed

21 files changed

+697
-53
lines changed

app/build.gradle

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,10 @@ dependencies {
261261

262262
implementation libs.gson
263263
implementation libs.amaze.trashBin
264+
265+
implementation libs.commonmark
266+
implementation libs.commonmark.ext.gfm.tables
267+
implementation libs.commonmark.ext.gfm.strikethrough
264268
}
265269

266270
kotlin {

app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/texteditor/read/FileWindowReader.kt

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ class FileWindowReader(
6464
* 4. Snaps the end to the last newline (unless at file end)
6565
* 5. Returns the decoded string and actual byte range consumed
6666
*/
67+
@Suppress("LongMethod")
6768
fun readWindow(
6869
byteOffset: Long,
6970
maxChars: Int,
@@ -161,7 +162,7 @@ class FileWindowReader(
161162
* past any continuation bytes (10xxxxxx pattern).
162163
*/
163164
private fun snapToCharBoundary(offset: Long): Long {
164-
if (offset <= 0L || offset >= fileSize) return offset.coerceIn(0L, fileSize)
165+
if (offset !in 1..<fileSize) return offset.coerceIn(0L, fileSize)
165166

166167
val buf = ByteBuffer.allocate(1)
167168
var pos = offset
@@ -226,16 +227,14 @@ class FileWindowReader(
226227
channel = channel,
227228
fileSize = size,
228229
closeable =
229-
object : Closeable {
230-
override fun close() {
231-
try {
232-
fis.close()
233-
} catch (_: Exception) {
234-
}
235-
try {
236-
pfd.close()
237-
} catch (_: Exception) {
238-
}
230+
Closeable {
231+
try {
232+
fis.close()
233+
} catch (_: Exception) {
234+
}
235+
try {
236+
pfd.close()
237+
} catch (_: Exception) {
239238
}
240239
},
241240
)

app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/texteditor/read/ReadTextFileCallable.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545

4646
public class ReadTextFileCallable implements Callable<ReturnedValueOnReadFile> {
4747

48-
public static final int MAX_FILE_SIZE_CHARS = 50 * 1024;
48+
public static final int MAX_FILE_SIZE_CHARS = 100 * 1024;
4949

5050
private final ContentResolver contentResolver;
5151
private final EditableFileAbstraction fileAbstraction;
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/*
2+
* Copyright (C) 2014-2024 Arpit Khurana <arpitkh96@gmail.com>, Vishal Nehra <vishalmeham2@gmail.com>,
3+
* Emmanuel Messulam<emmanuelbendavid@gmail.com>, Raymond Lai <airwave209gt at gmail.com> and Contributors.
4+
*
5+
* This file is part of Amaze File Manager.
6+
*
7+
* Amaze File Manager is free software: you can redistribute it and/or modify
8+
* it under the terms of the GNU General Public License as published by
9+
* the Free Software Foundation, either version 3 of the License, or
10+
* (at your option) any later version.
11+
*
12+
* This program is distributed in the hope that it will be useful,
13+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
14+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15+
* GNU General Public License for more details.
16+
*
17+
* You should have received a copy of the GNU General Public License
18+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
19+
*/
20+
21+
package com.amaze.filemanager.ui.activities.texteditor
22+
23+
import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension
24+
import org.commonmark.ext.gfm.tables.TablesExtension
25+
import org.commonmark.parser.Parser
26+
import org.commonmark.renderer.html.HtmlRenderer
27+
28+
/**
29+
* Utility for Markdown preview in the text editor.
30+
* Pure functions with no Android dependencies — easy to unit-test.
31+
*/
32+
object MarkdownHtmlGenerator {
33+
/**
34+
* Returns `true` if [fileName] ends with `.md` or `.markdown` (case-insensitive).
35+
*/
36+
@JvmStatic
37+
fun isMarkdownFile(fileName: String?): Boolean {
38+
if (fileName == null) return false
39+
val lower = fileName.lowercase()
40+
return lower.endsWith(".md") || lower.endsWith(".markdown")
41+
}
42+
43+
/**
44+
* Parse Markdown source text and return the rendered HTML body fragment.
45+
*/
46+
@JvmStatic
47+
fun renderToHtml(markdownSource: String): String {
48+
val extensions = listOf(TablesExtension.create(), StrikethroughExtension.create())
49+
val parser = Parser.builder().extensions(extensions).build()
50+
val document = parser.parse(markdownSource)
51+
val renderer = HtmlRenderer.builder().extensions(extensions).build()
52+
return renderer.render(document)
53+
}
54+
55+
/**
56+
* Wrap an HTML body fragment with a full HTML document including theme-aware CSS.
57+
*
58+
* FIXME: Move template to strings.xml
59+
* FIXME: Use Android/Material native Color constants for easy maintenance
60+
*
61+
* @param bodyHtml the rendered Markdown HTML fragment
62+
* @param isDarkTheme true for dark/black theme, false for light theme
63+
* @return a complete HTML document string
64+
*/
65+
@JvmStatic
66+
fun wrapWithBaseHtml(
67+
bodyHtml: String,
68+
isDarkTheme: Boolean,
69+
): String {
70+
val bgColor = if (isDarkTheme) "#1a1a1a" else "#ffffff"
71+
val textColor = if (isDarkTheme) "#e0e0e0" else "#212121"
72+
val linkColor = if (isDarkTheme) "#82b1ff" else "#1565c0"
73+
val codeBg = if (isDarkTheme) "#2d2d2d" else "#f5f5f5"
74+
val borderColor = if (isDarkTheme) "#444444" else "#dddddd"
75+
val quoteColor = if (isDarkTheme) "#aaaaaa" else "#666666"
76+
77+
return """<!DOCTYPE html>
78+
<html><head><meta charset="UTF-8">
79+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
80+
<style>
81+
body { font-family: sans-serif; padding: 16px; margin: 0; background-color: $bgColor; color: $textColor; line-height: 1.6; word-wrap: break-word; }
82+
a { color: $linkColor; }
83+
pre { background-color: $codeBg; padding: 12px; border-radius: 4px; overflow-x: auto; }
84+
code { background-color: $codeBg; padding: 2px 4px; border-radius: 2px; font-size: 90%; }
85+
pre code { padding: 0; background: none; }
86+
blockquote { border-left: 4px solid $borderColor; margin: 0; padding: 0 16px; color: $quoteColor; }
87+
table { border-collapse: collapse; width: 100%; }
88+
th, td { border: 1px solid $borderColor; padding: 8px; text-align: left; }
89+
th { background-color: $codeBg; }
90+
img { max-width: 100%; height: auto; }
91+
hr { border: none; border-top: 1px solid $borderColor; }
92+
h1, h2, h3, h4, h5, h6 { margin-top: 24px; margin-bottom: 16px; }
93+
</style></head><body>
94+
$bodyHtml
95+
</body></html>"""
96+
}
97+
}

app/src/main/java/com/amaze/filemanager/ui/activities/texteditor/TextEditorActivity.java

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
import android.view.animation.AnimationUtils;
6767
import android.view.inputmethod.EditorInfo;
6868
import android.view.inputmethod.InputMethodManager;
69+
import android.webkit.WebView;
6970
import android.widget.ScrollView;
7071
import android.widget.Toast;
7172

@@ -86,12 +87,14 @@ public class TextEditorActivity extends ThemedActivity
8687
private Typeface inputTypefaceMono;
8788
private androidx.appcompat.widget.Toolbar toolbar;
8889
ScrollView scrollView;
90+
private WebView markdownWebView;
8991

9092
private SearchTextTask searchTextTask;
9193
private static final String KEY_MODIFIED_TEXT = "modified";
9294
private static final String KEY_INDEX = "index";
9395
private static final String KEY_ORIGINAL_TEXT = "original";
9496
private static final String KEY_MONOFONT = "monofont";
97+
private static final String KEY_MARKDOWN_PREVIEW = "markdown_preview";
9598

9699
private ConstraintLayout searchViewLayout;
97100
public AppCompatImageButton upButton;
@@ -134,6 +137,8 @@ public void onCreate(Bundle savedInstanceState) {
134137
}
135138
mainTextView = findViewById(R.id.textEditorMainEditText);
136139
scrollView = findViewById(R.id.textEditorScrollView);
140+
markdownWebView = findViewById(R.id.textEditorMarkdownWebView);
141+
markdownWebView.getSettings().setJavaScriptEnabled(false);
137142

138143
final Uri uri = getIntent().getData();
139144
if (uri != null) {
@@ -178,6 +183,11 @@ public void onCreate(Bundle savedInstanceState) {
178183
if (savedInstanceState.getBoolean(KEY_MONOFONT)) {
179184
mainTextView.setTypeface(inputTypefaceMono);
180185
}
186+
// Restore markdown preview state
187+
if (savedInstanceState.getBoolean(KEY_MARKDOWN_PREVIEW, false)) {
188+
viewModel.setMarkdownPreviewEnabled(true);
189+
toggleMarkdownPreview(true);
190+
}
181191
// Restore windowed mode state after rotation
182192
if (viewModel.isWindowed()) {
183193
setReadOnly();
@@ -203,6 +213,7 @@ protected void onSaveInstanceState(@NonNull Bundle outState) {
203213
outState.putInt(KEY_INDEX, mainTextView.getScrollY());
204214
outState.putString(KEY_ORIGINAL_TEXT, viewModel.getOriginal());
205215
outState.putBoolean(KEY_MONOFONT, inputTypefaceMono.equals(mainTextView.getTypeface()));
216+
outState.putBoolean(KEY_MARKDOWN_PREVIEW, viewModel.getMarkdownPreviewEnabled());
206217
}
207218

208219
private void checkUnsavedChanges() {
@@ -309,6 +320,11 @@ public boolean onPrepareOptionsMenu(Menu menu) {
309320
// Hide search in windowed mode (search only works on in-memory text)
310321
menu.findItem(R.id.find).setVisible(!windowed);
311322

323+
// Show markdown preview item only for .md/.markdown files
324+
MenuItem markdownItem = menu.findItem(R.id.markdown_preview);
325+
markdownItem.setVisible(isMarkdownFile());
326+
markdownItem.setChecked(viewModel.getMarkdownPreviewEnabled());
327+
312328
menu.findItem(R.id.monofont).setChecked(inputTypefaceMono.equals(mainTextView.getTypeface()));
313329
return super.onPrepareOptionsMenu(menu);
314330
}
@@ -362,6 +378,11 @@ public boolean onOptionsItemSelected(MenuItem item) {
362378
} else if (item.getItemId() == R.id.monofont) {
363379
item.setChecked(!item.isChecked());
364380
mainTextView.setTypeface(item.isChecked() ? inputTypefaceMono : inputTypefaceDefault);
381+
} else if (item.getItemId() == R.id.markdown_preview) {
382+
boolean newState = !item.isChecked();
383+
item.setChecked(newState);
384+
viewModel.setMarkdownPreviewEnabled(newState);
385+
toggleMarkdownPreview(newState);
365386
} else {
366387
return false;
367388
}
@@ -661,7 +682,6 @@ private void observeWindowContent() {
661682

662683
// Determine an anchor: find the text line near the middle of the current viewport
663684
int oldScrollY = scrollView.getScrollY();
664-
Layout oldLayout = mainTextView.getLayout();
665685

666686
// Replace text (TextWatcher will fire but windowed-mode guard skips modification
667687
// tracking)
@@ -673,7 +693,6 @@ private void observeWindowContent() {
673693
Layout newLayout = mainTextView.getLayout();
674694
if (newLayout == null) return;
675695

676-
TextEditorActivityViewModel.Direction direction;
677696
// Infer direction from old scroll position
678697
int viewportHeight = scrollView.getHeight();
679698
if (oldScrollY > viewportHeight / 2) {
@@ -728,4 +747,46 @@ public void initWindowedScrollListener() {
728747

729748
scrollView.getViewTreeObserver().addOnScrollChangedListener(windowedScrollListener);
730749
}
750+
751+
// ── Markdown Preview Helpers ────────────────────────────────────────
752+
753+
/** Returns true if the currently opened file has a Markdown extension (.md or .markdown). */
754+
private boolean isMarkdownFile() {
755+
EditableFileAbstraction file = viewModel.getFile();
756+
if (file == null) return false;
757+
return MarkdownHtmlGenerator.isMarkdownFile(file.name);
758+
}
759+
760+
/**
761+
* Toggle between Markdown preview (WebView) and the normal EditText editor.
762+
*
763+
* @param enabled true to show the WebView with rendered Markdown; false to show EditText
764+
*/
765+
private void toggleMarkdownPreview(boolean enabled) {
766+
if (enabled) {
767+
renderMarkdownToWebView();
768+
scrollView.setVisibility(View.GONE);
769+
markdownWebView.setVisibility(View.VISIBLE);
770+
} else {
771+
markdownWebView.setVisibility(View.GONE);
772+
scrollView.setVisibility(View.VISIBLE);
773+
}
774+
invalidateOptionsMenu();
775+
}
776+
777+
/**
778+
* Parse the current EditText content as Markdown using commonmark, render to HTML, and load it
779+
* into the WebView.
780+
*/
781+
private void renderMarkdownToWebView() {
782+
String markdownSource = "";
783+
if (mainTextView.getText() != null) {
784+
markdownSource = mainTextView.getText().toString();
785+
}
786+
787+
String bodyHtml = MarkdownHtmlGenerator.renderToHtml(markdownSource);
788+
boolean isDark = getAppTheme().equals(AppTheme.DARK) || getAppTheme().equals(AppTheme.BLACK);
789+
String fullHtml = MarkdownHtmlGenerator.wrapWithBaseHtml(bodyHtml, isDark);
790+
markdownWebView.loadDataWithBaseURL(null, fullHtml, "text/html", "UTF-8", null);
791+
}
731792
}

app/src/main/java/com/amaze/filemanager/ui/activities/texteditor/TextEditorActivityViewModel.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,11 @@ class TextEditorActivityViewModel : ViewModel() {
6666

6767
var file: EditableFileAbstraction? = null
6868

69+
// ── Markdown preview state ────────────────────────────────────────
70+
71+
/** Whether Markdown preview mode is currently enabled. */
72+
var markdownPreviewEnabled = false
73+
6974
// ── Sliding window state ──────────────────────────────────────────
7075

7176
/** Whether the editor is in windowed (read-only) mode for large files. */

app/src/main/res/layout/search.xml

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -43,23 +43,36 @@
4343

4444
</LinearLayout>
4545

46-
<ScrollView
47-
android:id="@+id/textEditorScrollView"
46+
<FrameLayout
47+
android:id="@+id/textEditorContentFrame"
4848
android:layout_width="match_parent"
49-
android:layout_height="match_parent"
50-
android:fillViewport="true">
49+
android:layout_height="match_parent">
5150

52-
<androidx.appcompat.widget.AppCompatEditText
53-
android:id="@+id/textEditorMainEditText"
51+
<ScrollView
52+
android:id="@+id/textEditorScrollView"
5453
android:layout_width="match_parent"
55-
android:layout_height="wrap_content"
56-
android:fontFamily="sans-serif-light"
57-
android:gravity="start|top"
58-
android:inputType="textCapSentences|textMultiLine"
59-
android:lineSpacingExtra="1dp"
60-
android:padding="16dp"
61-
android:textSize="14sp" />
54+
android:layout_height="match_parent"
55+
android:fillViewport="true">
6256

63-
</ScrollView>
57+
<androidx.appcompat.widget.AppCompatEditText
58+
android:id="@+id/textEditorMainEditText"
59+
android:layout_width="match_parent"
60+
android:layout_height="wrap_content"
61+
android:fontFamily="sans-serif-light"
62+
android:gravity="start|top"
63+
android:inputType="textCapSentences|textMultiLine"
64+
android:lineSpacingExtra="1dp"
65+
android:padding="16dp"
66+
android:textSize="14sp" />
67+
68+
</ScrollView>
69+
70+
<WebView
71+
android:id="@+id/textEditorMarkdownWebView"
72+
android:layout_width="match_parent"
73+
android:layout_height="match_parent"
74+
android:visibility="gone" />
75+
76+
</FrameLayout>
6477

6578
</androidx.appcompat.widget.LinearLayoutCompat>

app/src/main/res/menu/text.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,4 +42,10 @@
4242
android:title="@string/monofont"
4343
android:checkable="true"
4444
app:showAsAction="never" />
45+
<item
46+
android:id="@+id/markdown_preview"
47+
android:title="@string/markdown_preview"
48+
android:checkable="true"
49+
android:visible="false"
50+
app:showAsAction="never" />
4551
</menu>

app/src/main/res/values-ja/strings.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -479,6 +479,7 @@
479479
<string name="copy_low_memory">メモリ不足。メモリを解放してください</string>
480480
<string name="cloud_token_lost">トークンを失われた。もう一度サインインしてください。</string>
481481
<string name="monofont">等幅フォント</string>
482+
<string name="markdown_preview">Markdown プレビュー</string>
482483
<string name="showHeaders">ヘッダーを表示する</string>
483484

484485
<!--FROM HERE ON STRINGS ARE EXCLUSIVELY USED IN plurals.xml-->

app/src/main/res/values-uk/strings.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -479,6 +479,7 @@
479479
<string name="copy_low_memory">Не вистачає пам\'яті, будь ласка звільніть оперативну пам\'ять</string>
480480
<string name="cloud_token_lost">Token втрачено, будь ласка авторизуйтесь знову</string>
481481
<string name="monofont">Моноширинний шрифт</string>
482+
<string name="markdown_preview">Попередній перегляд Markdown</string>
482483
<string name="showHeaders">Відображати заголовки</string>
483484

484485
<!--FROM HERE ON STRINGS ARE EXCLUSIVELY USED IN plurals.xml-->

0 commit comments

Comments
 (0)