diff --git a/markdown-preview-plugin/.gitignore b/markdown-preview-plugin/.gitignore
new file mode 100644
index 0000000000..6af499b76d
--- /dev/null
+++ b/markdown-preview-plugin/.gitignore
@@ -0,0 +1,29 @@
+# Gradle
+.gradle/
+build/
+gradle-app.setting
+!gradle-wrapper.jar
+.gradletasknamecache
+
+# IDE
+.idea/
+*.iml
+*.ipr
+*.iws
+.project
+.classpath
+.settings/
+.kotlin/
+
+# Local configuration
+local.properties
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Logs
+*.log
+
+# Test outputs
+test-results/
diff --git a/markdown-preview-plugin/build.gradle.kts b/markdown-preview-plugin/build.gradle.kts
new file mode 100644
index 0000000000..e697a907d3
--- /dev/null
+++ b/markdown-preview-plugin/build.gradle.kts
@@ -0,0 +1,75 @@
+plugins {
+ id("com.android.application") version "8.8.2"
+ id("org.jetbrains.kotlin.android") version "2.1.21"
+ id("com.itsaky.androidide.plugins.build")
+}
+
+pluginBuilder {
+ pluginName = "markdown-previewer"
+}
+
+android {
+ namespace = "com.codeonthego.markdownpreviewer"
+ compileSdk = 34
+
+ defaultConfig {
+ applicationId = "com.codeonthego.markdownpreviewer"
+ minSdk = 26
+ targetSdk = 34
+ versionCode = 1
+ versionName = "1.0.0"
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+
+ packaging {
+ resources {
+ excludes += setOf(
+ "META-INF/versions/9/OSGI-INF/MANIFEST.MF",
+ "META-INF/DEPENDENCIES",
+ "META-INF/LICENSE",
+ "META-INF/LICENSE.txt",
+ "META-INF/NOTICE",
+ "META-INF/NOTICE.txt"
+ )
+ }
+ }
+}
+
+dependencies {
+ compileOnly(project(":plugin-api"))
+
+ implementation("androidx.appcompat:appcompat:1.6.1")
+ implementation("com.google.android.material:material:1.10.0")
+ implementation("androidx.fragment:fragment-ktx:1.8.8")
+ implementation("org.jetbrains.kotlin:kotlin-stdlib:2.1.21")
+ implementation("androidx.fragment:fragment:1.8.8")
+ implementation("androidx.webkit:webkit:1.9.0")
+
+ // Markdown parsing library
+ implementation("io.noties.markwon:core:4.6.2")
+ implementation("io.noties.markwon:ext-tables:4.6.2")
+ implementation("io.noties.markwon:ext-strikethrough:4.6.2")
+ implementation("io.noties.markwon:ext-tasklist:4.6.2")
+ implementation("io.noties.markwon:html:4.6.2")
+ implementation("io.noties.markwon:image:4.6.2")
+ implementation("io.noties.markwon:linkify:4.6.2")
+}
+
+tasks.wrapper {
+ gradleVersion = "8.10.2"
+ distributionType = Wrapper.DistributionType.BIN
+}
diff --git a/markdown-preview-plugin/gradle.properties b/markdown-preview-plugin/gradle.properties
new file mode 100644
index 0000000000..f0a2e55f89
--- /dev/null
+++ b/markdown-preview-plugin/gradle.properties
@@ -0,0 +1,4 @@
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+android.useAndroidX=true
+kotlin.code.style=official
+android.nonTransitiveRClass=true
diff --git a/markdown-preview-plugin/gradle/wrapper/gradle-wrapper.jar b/markdown-preview-plugin/gradle/wrapper/gradle-wrapper.jar
new file mode 100755
index 0000000000..d64cd49177
Binary files /dev/null and b/markdown-preview-plugin/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/markdown-preview-plugin/gradle/wrapper/gradle-wrapper.properties b/markdown-preview-plugin/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000000..c0cb293e89
--- /dev/null
+++ b/markdown-preview-plugin/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
\ No newline at end of file
diff --git a/markdown-preview-plugin/gradlew b/markdown-preview-plugin/gradlew
new file mode 100755
index 0000000000..1aa94a4269
--- /dev/null
+++ b/markdown-preview-plugin/gradlew
@@ -0,0 +1,249 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ org.gradle.wrapper.GradleWrapperMain \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/markdown-preview-plugin/gradlew.bat b/markdown-preview-plugin/gradlew.bat
new file mode 100755
index 0000000000..93e3f59f13
--- /dev/null
+++ b/markdown-preview-plugin/gradlew.bat
@@ -0,0 +1,92 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/markdown-preview-plugin/proguard-rules.pro b/markdown-preview-plugin/proguard-rules.pro
new file mode 100644
index 0000000000..b0ad322801
--- /dev/null
+++ b/markdown-preview-plugin/proguard-rules.pro
@@ -0,0 +1,10 @@
+# Add project specific ProGuard rules here.
+# By default, the flags in this file are appended to flags specified
+# in /sdk/tools/proguard/proguard-android.txt
+
+# Keep Markwon classes
+-keep class io.noties.markwon.** { *; }
+-keep class org.commonmark.** { *; }
+
+# Keep plugin classes
+-keep class com.codeonthego.markdownpreviewer.** { *; }
diff --git a/markdown-preview-plugin/settings.gradle.kts b/markdown-preview-plugin/settings.gradle.kts
new file mode 100644
index 0000000000..b7c4727d91
--- /dev/null
+++ b/markdown-preview-plugin/settings.gradle.kts
@@ -0,0 +1,22 @@
+rootProject.name = "markdown-previewer-plugin"
+
+pluginManagement {
+ includeBuild("../plugin-api/plugin-builder")
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS)
+ repositories {
+ mavenCentral()
+ google()
+ gradlePluginPortal()
+ }
+}
+
+include(":plugin-api")
+project(":plugin-api").projectDir = file("../plugin-api")
diff --git a/markdown-preview-plugin/src/main/AndroidManifest.xml b/markdown-preview-plugin/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..42774f7ac1
--- /dev/null
+++ b/markdown-preview-plugin/src/main/AndroidManifest.xml
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/markdown-preview-plugin/src/main/kotlin/com/codeonthego/markdownpreviewer/MarkdownPreviewerPlugin.kt b/markdown-preview-plugin/src/main/kotlin/com/codeonthego/markdownpreviewer/MarkdownPreviewerPlugin.kt
new file mode 100644
index 0000000000..936f44cf6e
--- /dev/null
+++ b/markdown-preview-plugin/src/main/kotlin/com/codeonthego/markdownpreviewer/MarkdownPreviewerPlugin.kt
@@ -0,0 +1,195 @@
+package com.codeonthego.markdownpreviewer
+
+import androidx.fragment.app.Fragment
+import com.itsaky.androidide.plugins.IPlugin
+import com.itsaky.androidide.plugins.PluginContext
+import com.itsaky.androidide.plugins.extensions.UIExtension
+import com.itsaky.androidide.plugins.extensions.EditorTabExtension
+import com.itsaky.androidide.plugins.extensions.MenuItem
+import com.itsaky.androidide.plugins.extensions.TabItem
+import com.itsaky.androidide.plugins.extensions.EditorTabItem
+import com.itsaky.androidide.plugins.extensions.NavigationItem
+import com.itsaky.androidide.plugins.extensions.ContextMenuContext
+import com.itsaky.androidide.plugins.services.IdeEditorTabService
+import com.codeonthego.markdownpreviewer.fragments.MarkdownPreviewFragment
+import java.io.File
+
+class MarkdownPreviewerPlugin : IPlugin, UIExtension, EditorTabExtension {
+
+ private lateinit var context: PluginContext
+
+ companion object {
+ const val PLUGIN_ID = "com.codeonthego.markdownpreviewer"
+ private val MARKDOWN_EXTENSIONS = setOf("md", "markdown", "mdown", "mkd", "mkdn")
+ private val HTML_EXTENSIONS = setOf("html", "htm", "xhtml")
+ val SUPPORTED_EXTENSIONS = MARKDOWN_EXTENSIONS + HTML_EXTENSIONS
+
+ fun isMarkdownFile(file: File): Boolean =
+ MARKDOWN_EXTENSIONS.contains(file.extension.lowercase())
+
+ fun isHtmlFile(file: File): Boolean =
+ HTML_EXTENSIONS.contains(file.extension.lowercase())
+
+ fun isSupportedFile(file: File): Boolean =
+ SUPPORTED_EXTENSIONS.contains(file.extension.lowercase())
+ }
+
+ override fun initialize(context: PluginContext): Boolean {
+ return try {
+ this.context = context
+ context.logger.info("MarkdownPreviewerPlugin: Plugin initialized successfully")
+ true
+ } catch (e: Exception) {
+ context.logger.error("MarkdownPreviewerPlugin: Plugin initialization failed", e)
+ false
+ }
+ }
+
+ override fun activate(): Boolean {
+ context.logger.info("MarkdownPreviewerPlugin: Activating plugin")
+ return true
+ }
+
+ override fun deactivate(): Boolean {
+ context.logger.info("MarkdownPreviewerPlugin: Deactivating plugin")
+ return true
+ }
+
+ override fun dispose() {
+ context.logger.info("MarkdownPreviewerPlugin: Disposing plugin")
+ }
+
+ // UIExtension - No main menu items (accessed via sidebar/context menu)
+ override fun getMainMenuItems(): List = emptyList()
+
+ // UIExtension - Context menu for files
+ override fun getContextMenuItems(context: ContextMenuContext): List {
+ val file = context.file ?: return emptyList()
+
+ if (!isSupportedFile(file)) {
+ return emptyList()
+ }
+
+ val fileType = when {
+ isMarkdownFile(file) -> "Markdown"
+ isHtmlFile(file) -> "HTML"
+ else -> "File"
+ }
+
+ return listOf(
+ MenuItem(
+ id = "preview_${file.name}",
+ title = "Preview $fileType",
+ isEnabled = true,
+ isVisible = true,
+ action = {
+ openPreviewerTabWithFile(file)
+ }
+ )
+ )
+ }
+
+ // UIExtension - Bottom sheet tab
+ override fun getEditorTabs(): List = emptyList()
+
+ // UIExtension - Sidebar navigation item
+ override fun getSideMenuItems(): List {
+ return listOf(
+ NavigationItem(
+ id = "markdown_preview_sidebar",
+ title = "Preview",
+ icon = android.R.drawable.ic_menu_gallery,
+ isEnabled = true,
+ isVisible = true,
+ group = "tools",
+ order = 10,
+ action = {
+ context.logger.info("Sidebar Preview clicked")
+ openPreviewerTab()
+ }
+ )
+ )
+ }
+
+ // EditorTabExtension - Main editor tab to display the preview
+ override fun getMainEditorTabs(): List {
+ return listOf(
+ EditorTabItem(
+ id = "markdown_preview_main_tab",
+ title = "Preview",
+ icon = android.R.drawable.ic_menu_gallery,
+ fragmentFactory = {
+ context.logger.debug("Creating MarkdownPreviewFragment")
+ MarkdownPreviewFragment()
+ },
+ isCloseable = true,
+ isPersistent = false,
+ order = 50,
+ isEnabled = true,
+ isVisible = true,
+ tooltip = "Preview Markdown and HTML files"
+ )
+ )
+ }
+
+ override fun onEditorTabSelected(tabId: String, fragment: Fragment) {
+ context.logger.info("Editor tab selected: $tabId")
+ if (fragment is MarkdownPreviewFragment) {
+ fragment.checkPendingFile()
+ }
+ }
+
+ override fun onEditorTabClosed(tabId: String) {
+ context.logger.info("Editor tab closed: $tabId")
+ }
+
+ override fun canCloseEditorTab(tabId: String): Boolean {
+ return true
+ }
+
+ private fun openPreviewerTab() {
+ context.logger.info("Opening Markdown Previewer tab")
+
+ val editorTabService = context.services.get(IdeEditorTabService::class.java)
+ if (editorTabService == null) {
+ context.logger.error("Editor tab service not available")
+ return
+ }
+
+ if (!editorTabService.isTabSystemAvailable()) {
+ context.logger.error("Editor tab system not available")
+ return
+ }
+
+ try {
+ if (editorTabService.selectPluginTab("markdown_preview_main_tab")) {
+ context.logger.info("Successfully opened Markdown Previewer tab")
+ } else {
+ context.logger.warn("Failed to open Markdown Previewer tab")
+ }
+ } catch (e: Exception) {
+ context.logger.error("Error opening Markdown Previewer tab", e)
+ }
+ }
+
+ private fun openPreviewerTabWithFile(file: File) {
+ context.logger.info("Opening Markdown Previewer tab with file: ${file.absolutePath}")
+
+ // Store the file path temporarily for the fragment to pick up
+ PreviewState.pendingFilePath = file.absolutePath
+
+ openPreviewerTab()
+ }
+}
+
+
+object PreviewState {
+ @Volatile
+ var pendingFilePath: String? = null
+
+ fun consumePendingFile(): String? {
+ val path = pendingFilePath
+ pendingFilePath = null
+ return path
+ }
+}
diff --git a/markdown-preview-plugin/src/main/kotlin/com/codeonthego/markdownpreviewer/fragments/MarkdownPreviewFragment.kt b/markdown-preview-plugin/src/main/kotlin/com/codeonthego/markdownpreviewer/fragments/MarkdownPreviewFragment.kt
new file mode 100644
index 0000000000..937298ca9d
--- /dev/null
+++ b/markdown-preview-plugin/src/main/kotlin/com/codeonthego/markdownpreviewer/fragments/MarkdownPreviewFragment.kt
@@ -0,0 +1,824 @@
+package com.codeonthego.markdownpreviewer.fragments
+
+import android.app.Activity
+import android.content.Intent
+import android.graphics.Color
+import android.net.Uri
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.webkit.WebSettings
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import android.widget.Button
+import android.widget.LinearLayout
+import android.widget.ProgressBar
+import android.widget.ScrollView
+import android.widget.TextView
+import android.widget.Toast
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.core.content.ContextCompat
+import androidx.fragment.app.Fragment
+import androidx.lifecycle.lifecycleScope
+import com.codeonthego.markdownpreviewer.MarkdownPreviewerPlugin
+import com.codeonthego.markdownpreviewer.PreviewState
+import com.codeonthego.markdownpreviewer.R
+import com.itsaky.androidide.plugins.base.PluginFragmentHelper
+import com.itsaky.androidide.plugins.services.IdeProjectService
+import com.itsaky.androidide.plugins.services.IdeFileService
+import io.noties.markwon.Markwon
+import io.noties.markwon.ext.tables.TablePlugin
+import io.noties.markwon.ext.strikethrough.StrikethroughPlugin
+import io.noties.markwon.ext.tasklist.TaskListPlugin
+import io.noties.markwon.html.HtmlPlugin
+import io.noties.markwon.image.ImagesPlugin
+import io.noties.markwon.linkify.LinkifyPlugin
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.io.File
+
+class MarkdownPreviewFragment : Fragment() {
+
+ companion object {
+ private const val PLUGIN_ID = MarkdownPreviewerPlugin.PLUGIN_ID
+ }
+
+ private var projectService: IdeProjectService? = null
+ private var fileService: IdeFileService? = null
+
+ // UI Components
+ private lateinit var webView: WebView
+ private lateinit var progressBar: ProgressBar
+ private lateinit var statusContainer: LinearLayout
+ private lateinit var statusText: TextView
+ private lateinit var btnSelectFile: Button
+ private lateinit var btnSelectFromStorage: Button
+ private lateinit var btnToggleView: Button
+ private lateinit var currentFileText: TextView
+ private lateinit var placeholderText: TextView
+ private lateinit var emptyStateContainer: LinearLayout
+ private lateinit var sourceScrollView: ScrollView
+ private lateinit var sourceCodeText: TextView
+
+ private var currentFile: File? = null
+ private var currentContent: String? = null
+ private var isShowingSource: Boolean = false
+ private lateinit var markwon: Markwon
+ private var pickFileLauncher: androidx.activity.result.ActivityResultLauncher? = null
+
+ override fun onGetLayoutInflater(savedInstanceState: Bundle?): LayoutInflater {
+ val inflater = super.onGetLayoutInflater(savedInstanceState)
+ return PluginFragmentHelper.getPluginInflater(PLUGIN_ID, inflater)
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ // Register activity result launcher early in lifecycle
+ pickFileLauncher = registerForActivityResult(
+ ActivityResultContracts.StartActivityForResult()
+ ) { result ->
+ if (result.resultCode == Activity.RESULT_OK) {
+ val uri = result.data?.data
+ if (uri != null) {
+ loadFileFromUri(uri)
+ }
+ }
+ }
+
+ // Get services from the plugin's service registry
+ runCatching {
+ val serviceRegistry = PluginFragmentHelper.getServiceRegistry(PLUGIN_ID)
+ projectService = serviceRegistry?.get(IdeProjectService::class.java)
+ fileService = serviceRegistry?.get(IdeFileService::class.java)
+ }
+
+ // Initialize Markwon with extensions
+ markwon = Markwon.builder(requireContext())
+ .usePlugin(TablePlugin.create(requireContext()))
+ .usePlugin(StrikethroughPlugin.create())
+ .usePlugin(TaskListPlugin.create(requireContext()))
+ .usePlugin(HtmlPlugin.create())
+ .usePlugin(ImagesPlugin.create())
+ .usePlugin(LinkifyPlugin.create())
+ .build()
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? {
+ return inflater.inflate(R.layout.fragment_markdown_preview, container, false)
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ initializeViews(view)
+ setupWebView()
+ setupClickListeners()
+ checkPendingFile()
+ }
+
+ fun checkPendingFile() {
+ PreviewState.consumePendingFile()?.let { path ->
+ loadFile(File(path))
+ }
+ }
+
+ private fun initializeViews(view: View) {
+ webView = view.findViewById(R.id.web_view)
+ progressBar = view.findViewById(R.id.progress_bar)
+ statusContainer = view.findViewById(R.id.status_container)
+ statusText = view.findViewById(R.id.tv_status)
+ btnSelectFile = view.findViewById(R.id.btn_select_file)
+ btnSelectFromStorage = view.findViewById(R.id.btn_select_storage)
+ btnToggleView = view.findViewById(R.id.btn_toggle_view)
+ currentFileText = view.findViewById(R.id.tv_current_file)
+ placeholderText = view.findViewById(R.id.tv_placeholder)
+ emptyStateContainer = view.findViewById(R.id.empty_state_container)
+ sourceScrollView = view.findViewById(R.id.source_scroll_view)
+ sourceCodeText = view.findViewById(R.id.tv_source_code)
+ }
+
+ private fun setupWebView() {
+ webView.settings.apply {
+ javaScriptEnabled = false
+ loadWithOverviewMode = true
+ useWideViewPort = true
+ builtInZoomControls = true
+ displayZoomControls = false
+ setSupportZoom(true)
+ cacheMode = WebSettings.LOAD_NO_CACHE
+ }
+
+ webView.webViewClient = object : WebViewClient() {
+ override fun onPageFinished(view: WebView?, url: String?) {
+ super.onPageFinished(view, url)
+ hideProgress()
+ }
+ }
+
+ // Set background color based on theme
+ val isDarkMode = isNightMode()
+ webView.setBackgroundColor(if (isDarkMode) Color.parseColor("#000000") else Color.WHITE)
+ }
+
+ private fun setupClickListeners() {
+ btnSelectFile.setOnClickListener {
+ showProjectFilePicker()
+ }
+
+ btnSelectFromStorage.setOnClickListener {
+ openFilePicker()
+ }
+
+ btnToggleView.setOnClickListener {
+ toggleSourcePreview()
+ }
+ }
+
+ private fun toggleSourcePreview() {
+ isShowingSource = !isShowingSource
+ updateViewMode()
+ }
+
+ private fun updateViewMode() {
+ if (isShowingSource) {
+ // Show source code
+ webView.visibility = View.GONE
+ sourceScrollView.visibility = View.VISIBLE
+ sourceCodeText.text = currentContent ?: ""
+ btnToggleView.text = "Preview"
+ } else {
+ // Show preview
+ sourceScrollView.visibility = View.GONE
+ webView.visibility = View.VISIBLE
+ btnToggleView.text = "Source"
+ }
+ }
+
+ private fun showProjectFilePicker() {
+ val project = projectService?.getCurrentProject()
+ if (project == null) {
+ showToast("No project available")
+ return
+ }
+
+ // Find all supported files in the project
+ viewLifecycleOwner.lifecycleScope.launch {
+ showProgress("Scanning project...")
+
+ val files = withContext(Dispatchers.IO) {
+ findSupportedFiles(project.rootDir)
+ }
+
+ hideProgress()
+
+ if (files.isEmpty()) {
+ showToast("No Markdown or HTML files found in project")
+ return@launch
+ }
+
+ // Show file selection dialog
+ showFileSelectionDialog(files, project.rootDir)
+ }
+ }
+
+ private fun findSupportedFiles(rootDir: File): List {
+ val supportedFiles = mutableListOf()
+
+ rootDir.walkTopDown()
+ .filter { it.isFile && MarkdownPreviewerPlugin.isSupportedFile(it) }
+ .filter { !it.absolutePath.contains("/build/") && !it.absolutePath.contains("/.") }
+ .forEach { supportedFiles.add(it) }
+
+ return supportedFiles.sortedBy { it.name.lowercase() }
+ }
+
+ private fun showFileSelectionDialog(files: List, rootDir: File) {
+ val fileNames = files.map { file ->
+ file.absolutePath.removePrefix(rootDir.absolutePath + "/")
+ }.toTypedArray()
+
+ android.app.AlertDialog.Builder(requireContext())
+ .setTitle("Select File to Preview")
+ .setItems(fileNames) { _, which ->
+ loadFile(files[which])
+ }
+ .setNegativeButton("Cancel", null)
+ .show()
+ }
+
+ private fun openFilePicker() {
+ val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
+ addCategory(Intent.CATEGORY_OPENABLE)
+ type = "*/*"
+ putExtra(Intent.EXTRA_MIME_TYPES, arrayOf(
+ "text/markdown",
+ "text/x-markdown",
+ "text/html",
+ "text/plain"
+ ))
+ }
+ pickFileLauncher?.launch(intent) ?: showToast("File picker not available")
+ }
+
+ private fun loadFileFromUri(uri: Uri) {
+ viewLifecycleOwner.lifecycleScope.launch {
+ showProgress("Loading file...")
+
+ val result = runCatching {
+ withContext(Dispatchers.IO) {
+ // Get actual filename from ContentResolver
+ val fileName = getDisplayNameFromUri(uri) ?: "file"
+
+ // Read content from URI
+ val content = requireContext().contentResolver.openInputStream(uri)?.use {
+ it.bufferedReader().readText()
+ } ?: throw Exception("Could not read file")
+
+ // Determine file type
+ val lowerName = fileName.lowercase()
+ val isMarkdown = lowerName.endsWith(".md") ||
+ lowerName.endsWith(".markdown") ||
+ lowerName.endsWith(".mdown") ||
+ lowerName.endsWith(".mkd") ||
+ lowerName.endsWith(".mkdn") ||
+ // Fallback: check if content looks like markdown
+ (!lowerName.endsWith(".html") && !lowerName.endsWith(".htm") &&
+ content.trim().startsWith("#"))
+
+ Triple(content, isMarkdown, fileName)
+ }
+ }
+
+ result.fold(
+ onSuccess = { (content, isMarkdown, fileName) ->
+ hideProgress()
+ currentContent = content
+ currentFileText.text = fileName
+ currentFileText.visibility = View.VISIBLE
+ placeholderText.visibility = View.GONE
+ emptyStateContainer.visibility = View.GONE
+ btnToggleView.visibility = View.VISIBLE
+ isShowingSource = false
+ webView.visibility = View.VISIBLE
+ sourceScrollView.visibility = View.GONE
+ btnToggleView.text = "Source"
+ renderContent(content, isMarkdown)
+ },
+ onFailure = { e ->
+ hideProgress()
+ showError("Failed to load file: ${e.message}")
+ }
+ )
+ }
+ }
+
+ private fun getDisplayNameFromUri(uri: Uri): String? {
+ // Try to get display name from ContentResolver
+ return try {
+ requireContext().contentResolver.query(uri, null, null, null, null)?.use { cursor ->
+ if (cursor.moveToFirst()) {
+ val nameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
+ if (nameIndex >= 0) {
+ cursor.getString(nameIndex)
+ } else null
+ } else null
+ }
+ } catch (e: Exception) {
+ // Fallback to path segment
+ uri.lastPathSegment?.substringAfterLast("/")
+ }
+ }
+
+ private fun loadFile(file: File) {
+ if (!file.exists()) {
+ showError("File not found: ${file.name}")
+ return
+ }
+
+ if (!file.canRead()) {
+ showError("Cannot read file: ${file.name}")
+ return
+ }
+
+ currentFile = file
+
+ viewLifecycleOwner.lifecycleScope.launch {
+ showProgress("Loading ${file.name}...")
+
+ val result = runCatching {
+ withContext(Dispatchers.IO) {
+ file.readText()
+ }
+ }
+
+ result.fold(
+ onSuccess = { content ->
+ hideProgress()
+ currentContent = content
+ currentFileText.text = file.name
+ currentFileText.visibility = View.VISIBLE
+ placeholderText.visibility = View.GONE
+ emptyStateContainer.visibility = View.GONE
+ btnToggleView.visibility = View.VISIBLE
+ isShowingSource = false
+ webView.visibility = View.VISIBLE
+ sourceScrollView.visibility = View.GONE
+ btnToggleView.text = "Source"
+
+ val isMarkdown = MarkdownPreviewerPlugin.isMarkdownFile(file)
+ renderContent(content, isMarkdown)
+ },
+ onFailure = { e ->
+ hideProgress()
+ showError("Failed to load file: ${e.message}")
+ }
+ )
+ }
+ }
+
+ private fun renderContent(content: String, isMarkdown: Boolean) {
+ val html = if (isMarkdown) {
+ convertMarkdownToHtml(content)
+ } else {
+ // Already HTML, just wrap with our styles
+ wrapHtmlContent(content)
+ }
+
+ webView.loadDataWithBaseURL(
+ null,
+ html,
+ "text/html",
+ "UTF-8",
+ null
+ )
+ }
+
+ private fun convertMarkdownToHtml(markdown: String): String {
+ // Use Markwon to convert to HTML-like content
+ // We'll render to a Spanned first, then create styled HTML
+ val styled = markwon.toMarkdown(markdown)
+
+ // Build HTML with proper styling
+ val isDarkMode = isNightMode()
+ val textColor = if (isDarkMode) "#E0E0E0" else "#121212"
+ val bgColor = if (isDarkMode) "#000000" else "#FFFFFF"
+ val codeBlockBg = if (isDarkMode) "#0D0D0D" else "#F5F5F5"
+ val codeBorder = if (isDarkMode) "#1A1A1A" else "#E0E0E0"
+ val linkColor = if (isDarkMode) "#64B5F6" else "#1976D2"
+ val blockquoteBorder = if (isDarkMode) "#616161" else "#BDBDBD"
+ val blockquoteBg = if (isDarkMode) "#0A0A0A" else "#FAFAFA"
+ val tableBorder = if (isDarkMode) "#1A1A1A" else "#DDDDDD"
+ val tableHeaderBg = if (isDarkMode) "#121212" else "#F0F0F0"
+
+ return """
+
+
+
+
+
+
+
+
+ ${convertMarkdownToBasicHtml(markdown)}
+
+
+ """.trimIndent()
+ }
+
+ private fun convertMarkdownToBasicHtml(markdown: String): String {
+ var html = markdown
+
+ // Code blocks (fenced with ```) - must be done first
+ html = html.replace(Regex("```(\\w*)\\n([\\s\\S]*?)```")) { match ->
+ val lang = match.groupValues[1]
+ val code = match.groupValues[2].trim()
+ .replace("&", "&")
+ .replace("<", "<")
+ .replace(">", ">")
+ "\n$code \n"
+ }
+
+ // Inline code
+ html = html.replace(Regex("`([^`]+)`")) { match ->
+ val code = match.groupValues[1]
+ .replace("&", "&")
+ .replace("<", "<")
+ .replace(">", ">")
+ "$code"
+ }
+
+ // Tables
+ html = convertTables(html)
+
+ // Headers
+ html = html.replace(Regex("^######\\s+(.*)$", RegexOption.MULTILINE)) { "${it.groupValues[1]} " }
+ html = html.replace(Regex("^#####\\s+(.*)$", RegexOption.MULTILINE)) { "${it.groupValues[1]} " }
+ html = html.replace(Regex("^####\\s+(.*)$", RegexOption.MULTILINE)) { "${it.groupValues[1]} " }
+ html = html.replace(Regex("^###\\s+(.*)$", RegexOption.MULTILINE)) { "${it.groupValues[1]} " }
+ html = html.replace(Regex("^##\\s+(.*)$", RegexOption.MULTILINE)) { "${it.groupValues[1]} " }
+ html = html.replace(Regex("^#\\s+(.*)$", RegexOption.MULTILINE)) { "${it.groupValues[1]} " }
+
+ // Bold and italic
+ html = html.replace(Regex("\\*\\*\\*(.+?)\\*\\*\\*")) { "${it.groupValues[1]} " }
+ html = html.replace(Regex("___(.+?)___")) { "${it.groupValues[1]} " }
+ html = html.replace(Regex("\\*\\*(.+?)\\*\\*")) { "${it.groupValues[1]} " }
+ html = html.replace(Regex("__(.+?)__")) { "${it.groupValues[1]} " }
+ html = html.replace(Regex("\\*([^*]+)\\*")) { "${it.groupValues[1]} " }
+ html = html.replace(Regex("_([^_]+)_")) { "${it.groupValues[1]} " }
+
+ // Strikethrough
+ html = html.replace(Regex("~~(.+?)~~")) { "${it.groupValues[1]}" }
+
+ // Links
+ html = html.replace(Regex("\\[([^\\]]+)\\]\\(([^)]+)\\)")) {
+ "${it.groupValues[1]} "
+ }
+
+ // Images
+ html = html.replace(Regex("!\\[([^\\]]*)]\\(([^)]+)\\)")) {
+ " "
+ }
+
+ // Blockquotes
+ html = html.replace(Regex("^>\\s+(.*)$", RegexOption.MULTILINE)) {
+ "${it.groupValues[1]} "
+ }
+
+ // Horizontal rules
+ html = html.replace(Regex("^(-{3,}|\\*{3,}|_{3,})$", RegexOption.MULTILINE)) { " " }
+
+ // Task lists (before regular lists)
+ html = html.replace(Regex("^\\s*-\\s+\\[x\\]\\s+(.*)$", RegexOption.MULTILINE)) {
+ " ${it.groupValues[1]} "
+ }
+ html = html.replace(Regex("^\\s*-\\s+\\[\\s\\]\\s+(.*)$", RegexOption.MULTILINE)) {
+ " ${it.groupValues[1]} "
+ }
+
+ // Unordered lists
+ html = html.replace(Regex("^\\s*[-*+]\\s+(.*)$", RegexOption.MULTILINE)) {
+ "${it.groupValues[1]} "
+ }
+
+ // Ordered lists
+ html = html.replace(Regex("^\\s*\\d+\\.\\s+(.*)$", RegexOption.MULTILINE)) {
+ "${it.groupValues[1]} "
+ }
+
+ // Wrap consecutive items in
+ html = html.replace(Regex("((?:]*>.*? \\s*)+)")) { match ->
+ val content = match.groupValues[1]
+ if (content.contains("task-list-item")) {
+ ""
+ } else {
+ ""
+ }
+ }
+
+ // Paragraphs (wrap remaining text blocks)
+ val lines = html.split("\n")
+ val result = StringBuilder()
+ var inParagraph = false
+
+ for (line in lines) {
+ val trimmed = line.trim()
+ val isBlockElement = trimmed.startsWith(" {
+ if (inParagraph) {
+ result.append("
\n")
+ inParagraph = false
+ }
+ result.append("\n")
+ }
+ isBlockElement -> {
+ if (inParagraph) {
+ result.append("\n")
+ inParagraph = false
+ }
+ result.append(trimmed).append("\n")
+ }
+ else -> {
+ if (!inParagraph) {
+ result.append("")
+ inParagraph = true
+ } else {
+ result.append(" ")
+ }
+ result.append(trimmed)
+ }
+ }
+ }
+ if (inParagraph) {
+ result.append("
")
+ }
+
+ return result.toString()
+ }
+
+ private fun convertTables(markdown: String): String {
+ val lines = markdown.split("\n")
+ val result = StringBuilder()
+ var inTable = false
+ var headerProcessed = false
+
+ for (i in lines.indices) {
+ val line = lines[i].trim()
+
+ // Check if this is a table row (contains |)
+ if (line.startsWith("|") && line.endsWith("|")) {
+ // Check if next line is separator (|---|---|)
+ val isHeader = i + 1 < lines.size &&
+ lines[i + 1].trim().matches(Regex("\\|[-:\\s|]+\\|"))
+
+ // Check if this line IS a separator
+ val isSeparator = line.matches(Regex("\\|[-:\\s|]+\\|"))
+
+ if (isSeparator) {
+ // Skip separator line
+ continue
+ }
+
+ if (!inTable) {
+ result.append("\n")
+ inTable = true
+ headerProcessed = false
+ }
+
+ val cells = line.split("|")
+ .filter { it.isNotBlank() }
+ .map { it.trim() }
+
+ if (isHeader && !headerProcessed) {
+ result.append("")
+ cells.forEach { cell -> result.append("$cell ") }
+ result.append(" \n\n")
+ headerProcessed = true
+ } else {
+ result.append("")
+ cells.forEach { cell -> result.append("$cell ") }
+ result.append(" \n")
+ }
+ } else {
+ if (inTable) {
+ result.append("
\n")
+ inTable = false
+ headerProcessed = false
+ }
+ result.append(line).append("\n")
+ }
+ }
+
+ if (inTable) {
+ result.append("\n")
+ }
+
+ return result.toString()
+ }
+
+ private fun wrapHtmlContent(html: String): String {
+ // If already has tag, return as-is with dark mode support injected
+ if (html.lowercase().contains("
+
+
+
+
+
+
+
+ $html
+
+
+ """.trimIndent()
+ }
+
+ private fun injectDarkModeStyles(html: String): String {
+ if (!isNightMode()) return html
+
+ val darkModeStyle = """
+
+ """.trimIndent()
+
+ return if (html.lowercase().contains("")) {
+ html.replace(Regex("", RegexOption.IGNORE_CASE)) {
+ "$darkModeStyle"
+ }
+ } else if (html.lowercase().contains("")) {
+ html.replace(Regex("", RegexOption.IGNORE_CASE)) {
+ "$darkModeStyle"
+ }
+ } else {
+ "$darkModeStyle$html"
+ }
+ }
+
+ private fun isNightMode(): Boolean {
+ return (resources.configuration.uiMode and
+ android.content.res.Configuration.UI_MODE_NIGHT_MASK) ==
+ android.content.res.Configuration.UI_MODE_NIGHT_YES
+ }
+
+ private fun showProgress(message: String) {
+ progressBar.visibility = View.VISIBLE
+ statusContainer.visibility = View.VISIBLE
+ statusText.text = message
+ }
+
+ private fun hideProgress() {
+ progressBar.visibility = View.GONE
+ statusContainer.visibility = View.GONE
+ }
+
+ private fun showError(message: String) {
+ statusContainer.visibility = View.VISIBLE
+ statusText.text = message
+ statusText.setTextColor(ContextCompat.getColor(requireContext(), R.color.status_error_text))
+ statusContainer.setBackgroundColor(ContextCompat.getColor(requireContext(), R.color.status_error_background))
+ }
+
+ private fun showToast(message: String) {
+ activity?.runOnUiThread {
+ Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
+ }
+ }
+}
diff --git a/markdown-preview-plugin/src/main/res/drawable/ic_preview.xml b/markdown-preview-plugin/src/main/res/drawable/ic_preview.xml
new file mode 100644
index 0000000000..4d5c08c1dc
--- /dev/null
+++ b/markdown-preview-plugin/src/main/res/drawable/ic_preview.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
diff --git a/markdown-preview-plugin/src/main/res/layout/fragment_markdown_preview.xml b/markdown-preview-plugin/src/main/res/layout/fragment_markdown_preview.xml
new file mode 100644
index 0000000000..63d37a9122
--- /dev/null
+++ b/markdown-preview-plugin/src/main/res/layout/fragment_markdown_preview.xml
@@ -0,0 +1,173 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/markdown-preview-plugin/src/main/res/values-night/colors.xml b/markdown-preview-plugin/src/main/res/values-night/colors.xml
new file mode 100644
index 0000000000..780e5797df
--- /dev/null
+++ b/markdown-preview-plugin/src/main/res/values-night/colors.xml
@@ -0,0 +1,26 @@
+
+
+
+ #64B5F6
+ #42A5F5
+ #90CAF9
+
+
+ #0D1117
+ #90CAF9
+
+ #0D1F12
+ #81C784
+
+ #1F0D0D
+ #EF9A9A
+
+ #1F170D
+ #FFCC80
+
+
+ #000000
+ #E0E0E0
+ #121212
+ #64B5F6
+
diff --git a/markdown-preview-plugin/src/main/res/values/colors.xml b/markdown-preview-plugin/src/main/res/values/colors.xml
new file mode 100644
index 0000000000..7d0442c4c1
--- /dev/null
+++ b/markdown-preview-plugin/src/main/res/values/colors.xml
@@ -0,0 +1,26 @@
+
+
+
+ #1976D2
+ #1565C0
+ #64B5F6
+
+
+ #E3F2FD
+ #1565C0
+
+ #E8F5E9
+ #2E7D32
+
+ #FFEBEE
+ #C62828
+
+ #FFF3E0
+ #EF6C00
+
+
+ #FFFFFF
+ #333333
+ #F5F5F5
+ #1976D2
+
diff --git a/markdown-preview-plugin/src/main/res/values/strings.xml b/markdown-preview-plugin/src/main/res/values/strings.xml
new file mode 100644
index 0000000000..983d1a7c49
--- /dev/null
+++ b/markdown-preview-plugin/src/main/res/values/strings.xml
@@ -0,0 +1,30 @@
+
+
+ Markdown Previewer
+ File Preview
+ Preview Markdown and HTML files with live rendering
+
+
+ Project
+ Storage
+ Refresh
+ Source
+ Preview
+
+
+ No File Selected
+ Select a file from your project or device storage to preview
+ Supported: .md, .markdown, .html, .htm
+
+
+ Loading…
+ No project available
+ No supported files found
+ File not found
+ Cannot read file
+
+
+ Preview File
+ Preview Markdown
+ Preview HTML
+
diff --git a/markdown-preview-plugin/src/main/res/values/styles.xml b/markdown-preview-plugin/src/main/res/values/styles.xml
new file mode 100644
index 0000000000..9b988b7ae8
--- /dev/null
+++ b/markdown-preview-plugin/src/main/res/values/styles.xml
@@ -0,0 +1,8 @@
+
+
+
+