Use javascript markdown engine

This commit is contained in:
William Brawner 2023-11-05 21:21:02 -07:00
parent 9f7142b2ab
commit 32b4518bf7
14 changed files with 3908 additions and 105 deletions

View file

@ -1,6 +1,6 @@
import java.io.FileInputStream
import java.io.FileNotFoundException
import java.util.*
import java.util.Properties
plugins {
id("com.android.application")
@ -133,7 +133,6 @@ dependencies {
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
implementation("com.google.android.material:material:1.9.0")
implementation("androidx.legacy:legacy-support-v13:1.0.0")
implementation("com.commonsware.cwac:anddown:0.4.0")
implementation("com.jakewharton.timber:timber:5.0.1")
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.browser:browser:1.6.0")

View file

@ -90,11 +90,11 @@ data 1|data 2|data 3
data 1|data 2|data 3
```
Left Content|Center Content|Right Content
:--------|:--------:|--------:
data 1|data 2|data 3
data 1|data 2|data 3
data 1|data 2|data 3
| Left Content | Center Content | Right Content |
|:-------------|:--------------:|--------------:|
| data 1 | data 2 | data 3 |
| data 1 | data 2 | data 3 |
| data 1 | data 2 | data 3 |
### Images
@ -116,7 +116,7 @@ In addition to the monospace inline element, code blocks can be created by inden
Or by wrapping the code in three backticks (\`\`\`):
```
```javascript
function helloWorld() {
console.log("Hello, world!")
}

View file

@ -0,0 +1,9 @@
/*!
Theme: Default
Description: Original highlight.js style
Author: (c) Ivan Sagalaev <maniac@softwaremaniacs.org>
Maintainer: @highlightjs/core-team
Website: https://highlightjs.org/
License: see project LICENSE
Touched: 2021
*/pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{background:#f3f3f3;color:#444}.hljs-comment{color:#697070}.hljs-punctuation,.hljs-tag{color:#444a}.hljs-tag .hljs-attr,.hljs-tag .hljs-name{color:#444}.hljs-attribute,.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-name,.hljs-selector-tag{font-weight:700}.hljs-deletion,.hljs-number,.hljs-quote,.hljs-selector-class,.hljs-selector-id,.hljs-string,.hljs-template-tag,.hljs-type{color:#800}.hljs-section,.hljs-title{color:#800;font-weight:700}.hljs-link,.hljs-operator,.hljs-regexp,.hljs-selector-attr,.hljs-selector-pseudo,.hljs-symbol,.hljs-template-variable,.hljs-variable{color:#ab5656}.hljs-literal{color:#695}.hljs-addition,.hljs-built_in,.hljs-bullet,.hljs-code{color:#397300}.hljs-meta{color:#1f7199}.hljs-meta .hljs-string{color:#38a}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,14 @@
const localMarked = new marked.Marked(
markedHighlight.markedHighlight({
langPrefix: 'hljs language-',
highlight(code, lang) {
console.log(`highlighing code with $lang`)
const language = hljs.getLanguage(lang) ? lang : 'plaintext';
return hljs.highlight(code, { language }).value;
}
})
);
function setMarkdown(markdown) {
document.getElementById('content').innerHTML = marked.parse(markdown)
}

View file

@ -0,0 +1,96 @@
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
typeof define === 'function' && define.amd ? define(['exports'], factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.markedHighlight = {}));
})(this, (function (exports) { 'use strict';
function markedHighlight(options) {
if (typeof options === 'function') {
options = {
highlight: options
};
}
if (!options || typeof options.highlight !== 'function') {
throw new Error('Must provide highlight function');
}
if (typeof options.langPrefix !== 'string') {
options.langPrefix = 'language-';
}
return {
async: !!options.async,
walkTokens(token) {
if (token.type !== 'code') {
return;
}
const lang = getLang(token);
if (options.async) {
return Promise.resolve(options.highlight(token.text, lang)).then(updateToken(token));
}
const code = options.highlight(token.text, lang);
if (code instanceof Promise) {
throw new Error('markedHighlight is not set to async but the highlight function is async. Set the async option to true on markedHighlight to await the async highlight function.');
}
updateToken(token)(code);
},
renderer: {
code(code, infoString, escaped) {
const lang = (infoString || '').match(/\S*/)[0];
const classAttr = lang
? ` class="${options.langPrefix}${escape(lang)}"`
: '';
code = code.replace(/\n$/, '');
return `<pre><code${classAttr}>${escaped ? code : escape(code, true)}\n</code></pre>`;
}
}
};
}
function getLang(token) {
return (token.lang || '').match(/\S*/)[0];
}
function updateToken(token) {
return (code) => {
if (typeof code === 'string' && code !== token.text) {
token.escaped = true;
token.text = code;
}
};
}
// copied from marked helpers
const escapeTest = /[&<>"']/;
const escapeReplace = new RegExp(escapeTest.source, 'g');
const escapeTestNoEncode = /[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/;
const escapeReplaceNoEncode = new RegExp(escapeTestNoEncode.source, 'g');
const escapeReplacements = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
};
const getEscapeReplacement = (ch) => escapeReplacements[ch];
function escape(html, encode) {
if (encode) {
if (escapeTest.test(html)) {
return html.replace(escapeReplace, getEscapeReplacement);
}
} else {
if (escapeTestNoEncode.test(html)) {
return html.replace(escapeReplaceNoEncode, getEscapeReplacement);
}
}
return html;
}
exports.markedHighlight = markedHighlight;
}));

File diff suppressed because it is too large Load diff

View file

@ -20,13 +20,13 @@ import androidx.compose.material.icons.filled.Help
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.PrivacyTip
import androidx.compose.material.icons.filled.Settings
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.core.app.ActivityCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.WindowCompat
import androidx.lifecycle.lifecycleScope
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
@ -39,42 +39,18 @@ import com.wbrawner.simplemarkdown.ui.SettingsScreen
import com.wbrawner.simplemarkdown.ui.SupportScreen
import com.wbrawner.simplemarkdown.ui.theme.SimpleMarkdownTheme
import com.wbrawner.simplemarkdown.utility.Preference
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class MainActivity : AppCompatActivity(), ActivityCompat.OnRequestPermissionsResultCallback {
private val viewModel: MarkdownViewModel by viewModels { MarkdownViewModel.factory(fileHelper, preferenceHelper) }
private val viewModel: MarkdownViewModel by viewModels {
MarkdownViewModel.factory(
fileHelper,
preferenceHelper
)
}
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)
lifecycleScope.launch {
val darkMode = withContext(Dispatchers.IO) {
val darkModeValue = preferenceHelper[Preference.DARK_MODE] as String
return@withContext when {
darkModeValue.equals(
getString(R.string.pref_value_light),
ignoreCase = true
) -> AppCompatDelegate.MODE_NIGHT_NO
darkModeValue.equals(
getString(R.string.pref_value_dark),
ignoreCase = true
) -> AppCompatDelegate.MODE_NIGHT_YES
else -> {
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY
} else {
AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
}
}
}
}
AppCompatDelegate.setDefaultNightMode(darkMode)
}
WindowCompat.setDecorFitsSystemWindows(window, false)
val preferences = mutableMapOf<String, String>()
preferences["Autosave"] = preferenceHelper[Preference.AUTOSAVE_ENABLED].toString()
@ -90,19 +66,45 @@ class MainActivity : AppCompatActivity(), ActivityCompat.OnRequestPermissionsRes
.collectAsState()
val readabilityEnabled by preferenceHelper.observe<Boolean>(Preference.READABILITY_ENABLED)
.collectAsState()
val darkModePreference by preferenceHelper.observe<String>(Preference.DARK_MODE)
.collectAsState()
LaunchedEffect(darkModePreference) {
val darkMode = when {
darkModePreference.equals(
getString(R.string.pref_value_light),
ignoreCase = true
) -> AppCompatDelegate.MODE_NIGHT_NO
darkModePreference.equals(
getString(R.string.pref_value_dark),
ignoreCase = true
) -> AppCompatDelegate.MODE_NIGHT_YES
else -> {
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY
} else {
AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
}
}
}
AppCompatDelegate.setDefaultNightMode(darkMode)
}
SimpleMarkdownTheme {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = Route.EDITOR.path,
enterTransition = { fadeIn(
animationSpec = tween(
300, easing = LinearEasing
enterTransition = {
fadeIn(
animationSpec = tween(
300, easing = LinearEasing
)
) + slideIntoContainer(
animationSpec = tween(300, easing = EaseIn),
towards = AnimatedContentTransitionScope.SlideDirection.Start
)
) + slideIntoContainer(
animationSpec = tween(300, easing = EaseIn),
towards = AnimatedContentTransitionScope.SlideDirection.Start
) },
},
popEnterTransition = { EnterTransition.None },
popExitTransition = {
fadeOut(
@ -120,7 +122,8 @@ class MainActivity : AppCompatActivity(), ActivityCompat.OnRequestPermissionsRes
navController = navController,
viewModel = viewModel,
enableAutosave = autosaveEnabled,
enableReadability = readabilityEnabled
enableReadability = readabilityEnabled,
darkMode = darkModePreference
)
}
composable(Route.SETTINGS.path) {
@ -130,13 +133,25 @@ class MainActivity : AppCompatActivity(), ActivityCompat.OnRequestPermissionsRes
SupportScreen(navController = navController)
}
composable(Route.HELP.path) {
MarkdownInfoScreen(title = Route.HELP.title, file = "Cheatsheet.md", navController = navController)
MarkdownInfoScreen(
title = Route.HELP.title,
file = "Cheatsheet.md",
navController = navController
)
}
composable(Route.ABOUT.path) {
MarkdownInfoScreen(title = Route.ABOUT.title, file = "Libraries.md", navController = navController)
MarkdownInfoScreen(
title = Route.ABOUT.title,
file = "Libraries.md",
navController = navController
)
}
composable(Route.PRIVACY.path) {
MarkdownInfoScreen(title = Route.PRIVACY.title, file = "Privacy Policy.md", navController = navController)
MarkdownInfoScreen(
title = Route.PRIVACY.title,
file = "Privacy Policy.md",
navController = navController
)
}
}
}

View file

@ -33,6 +33,12 @@ class MarkdownViewModel(
val effects = _effects.asSharedFlow()
private val saveMutex = Mutex()
init {
viewModelScope.launch {
load(null)
}
}
suspend fun updateMarkdown(markdown: String?) {
this@MarkdownViewModel._markdown.emit(markdown ?: "")
isDirty.set(true)

View file

@ -9,10 +9,11 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
@ -38,6 +39,7 @@ import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
@ -51,8 +53,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
@ -78,6 +80,7 @@ fun MainScreen(
viewModel: MarkdownViewModel,
enableAutosave: Boolean,
enableReadability: Boolean,
darkMode: String,
) {
var lockSwiping by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope()
@ -242,8 +245,15 @@ fun MainScreen(
if (page == 0) {
TextField(
modifier = Modifier
.fillMaxSize()
.padding(8.dp),
.fillMaxSize(),
colors = TextFieldDefaults.colors(
focusedContainerColor = MaterialTheme.colorScheme.surface,
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
disabledIndicatorColor = Color.Transparent,
errorIndicatorColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent
),
value = textFieldValue,
onValueChange = {
textFieldValue = if (enableReadability) {
@ -265,7 +275,7 @@ fun MainScreen(
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences)
)
} else {
MarkdownPreview(modifier = Modifier.fillMaxSize(), markdown)
MarkdownPreview(modifier = Modifier.fillMaxSize(), markdown, darkMode)
}
}
}
@ -293,6 +303,21 @@ fun MarkdownNavigationDrawer(
val coroutineScope = rememberCoroutineScope()
DismissibleNavigationDrawer(drawerState = drawerState, drawerContent = {
DismissibleDrawerSheet {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
modifier = Modifier.size(96.dp),
painter = painterResource(R.drawable.ic_launcher_foreground),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface
)
Text(
text = "Simple Markdown",
style = MaterialTheme.typography.titleLarge
)
}
Route.entries.forEach { route ->
if (route == Route.EDITOR) {
return@forEach

View file

@ -5,10 +5,8 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.navigation.NavController
@ -30,15 +28,16 @@ fun MarkdownInfoScreen(
}
) { paddingValues ->
val context = LocalContext.current
var markdown by remember { mutableStateOf("") }
val (markdown, setMarkdown) = remember { mutableStateOf("") }
LaunchedEffect(file) {
markdown = context.assets.readAssetToString(file)?.toHtml()?: "Failed to load $file"
setMarkdown(context.assets.readAssetToString(file)?.toHtml() ?: "Failed to load $file")
}
MarkdownPreview(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
markdown = markdown
markdown = markdown,
"Auto"
)
}
}

View file

@ -1,69 +1,93 @@
package com.wbrawner.simplemarkdown.ui
import android.content.res.Configuration
import android.content.Context
import android.view.ViewGroup
import android.webkit.WebView
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import androidx.preference.PreferenceManager
import com.wbrawner.simplemarkdown.BuildConfig
import com.wbrawner.simplemarkdown.R
import com.wbrawner.simplemarkdown.utility.toHtml
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.Reader
private const val container = "<main id=\"content\"></main>"
@OptIn(ExperimentalStdlibApi::class)
@Composable
fun MarkdownPreview(modifier: Modifier = Modifier, markdown: String) {
val coroutineScope = rememberCoroutineScope()
fun MarkdownPreview(modifier: Modifier = Modifier, markdown: String, darkMode: String) {
val materialColors = MaterialTheme.colorScheme
val style = remember(darkMode) {
"""body {
| background: #${materialColors.surface.toArgb().toHexString().substring(2)};
| color: #${materialColors.onSurface.toArgb().toHexString().substring(2)};
|}
|pre {
| background: #${materialColors.surfaceVariant.toArgb().toHexString().substring(2)};
| color: #${materialColors.onSurfaceVariant.toArgb().toHexString().substring(2)};
|}""".trimMargin().wrapTag("style")
}
var marked by remember { mutableStateOf("") }
var markedHighlight by remember { mutableStateOf("") }
var highlightJs by remember { mutableStateOf("") }
var highlightCss by remember { mutableStateOf("") }
var markdownJs by remember { mutableStateOf("") }
val markdownUpdateJs by remember(markdown) {
mutableStateOf(
"setMarkdown(`${
markdown.replace(
"`",
"\\`"
)
}`)".wrapTag("script")
)
}
val context = LocalContext.current
var style by remember { mutableStateOf("") }
LaunchedEffect(context) {
val isNightMode = AppCompatDelegate.getDefaultNightMode() ==
AppCompatDelegate.MODE_NIGHT_YES
|| context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
val defaultCssId = if (isNightMode) {
R.string.pref_custom_css_default_dark
} else {
R.string.pref_custom_css_default
withContext(Dispatchers.IO) {
marked = context.assetToString("marked.js").wrapTag("script")
markedHighlight = context.assetToString("marked-highlight.js").wrapTag("script")
highlightJs = context.assetToString("highlight.js").wrapTag("script")
highlightCss = context.assetToString("highlight.css").wrapTag("style")
markdownJs = context.assetToString("markdown.js").wrapTag("script")
}
val css = withContext(Dispatchers.IO) {
@Suppress("ConstantConditionIf")
if (!BuildConfig.ENABLE_CUSTOM_CSS) {
context.getString(defaultCssId)
} else {
PreferenceManager.getDefaultSharedPreferences(context)
.getString(
context.getString(R.string.pref_custom_css),
context.getString(defaultCssId)
)
}
}
style = "<style>$css</style>"
}
AndroidView(
modifier = modifier,
factory = { context ->
WebView(context)
},
update = { preview ->
coroutineScope.launch {
preview.loadDataWithBaseURL(null,
style + markdown.toHtml(),
"text/html",
"UTF-8", null
val content =
highlightCss + style + container + marked + markedHighlight + highlightJs + markdownJs + markdownUpdateJs
WebView(context).apply {
WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG)
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
preview.setBackgroundColor(0x01000000)
setBackgroundColor(Color.Transparent.toArgb())
isNestedScrollingEnabled = false
settings.javaScriptEnabled = true
loadDataWithBaseURL(null, content, "text/html", "UTF-8", null)
}
},
update = { webView ->
val content =
highlightCss + style + container + marked + markedHighlight + highlightJs + markdownJs + markdownUpdateJs
webView.loadDataWithBaseURL(null, content, "text/html", "UTF-8", null)
}
)
}
private fun String.wrapTag(tag: String) = "<$tag>$this</$tag>"
private fun Context.assetToString(fileName: String): String =
assets.open(fileName).reader().use(Reader::readText)

View file

@ -1,7 +1,7 @@
package com.wbrawner.simplemarkdown.utility
import android.content.Context
import android.net.Uri
import android.content.Intent
import androidx.core.net.toUri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@ -36,6 +36,10 @@ class AndroidFileHelper(private val context: Context) : FileHelper {
override suspend fun open(source: URI): Pair<String, String>? = withContext(Dispatchers.IO) {
val uri = source.toString().toUri()
context.contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
context.contentResolver.openFileDescriptor(uri, "r")
?.use {
uri.getName(context) to FileInputStream(it.fileDescriptor).reader()

View file

@ -17,9 +17,6 @@ allprojects {
repositories {
google()
mavenCentral()
maven {
url = uri("https://s3.amazonaws.com/repo.commonsware.com/")
}
maven {
url = uri("https://s01.oss.sonatype.org/content/repositories/snapshots/")
}