diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ae751608..64701b23 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,9 +7,7 @@ on: jobs: build: - runs-on: ubuntu-latest - steps: - uses: actions/checkout@v1 - name: set up JDK 1.8 @@ -17,4 +15,4 @@ jobs: with: java-version: 1.8 - name: Build with Gradle - run: ./gradlew build + run: ./gradlew build -Prelease -PCI --stacktrace diff --git a/.github/workflows/develop.yml b/.github/workflows/develop.yml deleted file mode 100644 index dfb474a6..00000000 --- a/.github/workflows/develop.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Snapshot - -on: - push: - branches: - - develop - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v1 - - name: set up JDK 1.8 - uses: actions/setup-java@v1 - with: - java-version: 1.8 - - name: Build with Gradle - run: ./gradlew build -Prelease - - deploy: - needs: build - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v1 - - name: set up JDK 1.8 - uses: actions/setup-java@v1 - with: - java-version: 1.8 - - name: deploy snapshot - env: - NEXUS_USERNAME: ${{ secrets.NEXUS_USERNAME }} - NEXUS_PASSWORD: ${{ secrets.NEXUS_PASSWORD }} - run: ./gradlew upA -Prelease -PCI diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 5ec4cab8..f6c20313 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -14,4 +14,4 @@ jobs: with: java-version: 1.8 - name: Build with Gradle - run: ./gradlew build + run: ./gradlew build -PCI --stacktrace diff --git a/CHANGELOG.md b/CHANGELOG.md index 38b9e884..4e775722 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,39 @@ # Changelog +# SNAPSHOT + +#### Added +* `core` - `MovementMethodPlugin.none()`, `MovementMethodPlugin.link()` factory methods +* `core` - `CorePlugin` `hasExplicitMovementMethod` configuration method to **not** add implicit `LinkMovementMethod` in `afterSetText` +* `core` - `MarkwonTheme` `isLinkedUnderlined` attribute for links([#270])
Thanks to [@dallasgutauckis] +* `ext-latex` - `JLatexMathTheme.Padding.of(int,int,int,int)` factory method +* `app-sample` - example application + +#### Changed +* `html` - `SimpleTagHandler` visits children tags if supplied tag is block one ([#235]) +* `inline-parser` - `BangInlineProcessor` properly returns `null` if no image node is found (possible to define other inline parsers that use `!` as special character) +* `image` - `AsyncDrawable` won't trigger loading if it has result (aim: `RecyclerView` due to multiple attach/detach events of a View) +* `image` - `AsyncDrawable` will resume result if it is `Animatable` and was playing before detach event (aim: `RecyclerView`) ([#241]) +* `core` - `MarkwonReducer` filter out `LinkReferenceDefinition` nodes + +#### Fixed +* `image-glide` cache `RequestManager` in `GlideImagesPlugin#create(Context)` factory method ([#259]) + +#### Deprecated +* `core` - `MovementMethodPlugin.create()` use explicit `MovementMethodPlugin.link()` instead + +#### Removed +* `image` - `AsyncDrawable#hasKnownDimentions` (deprecated in `4.2.1`) +* `app` and `sample` applications (merged together in a `app-sample` single app) + +[#235]: https://github.com/noties/Markwon/issues/235 +[#241]: https://github.com/noties/Markwon/issues/241 +[#259]: https://github.com/noties/Markwon/issues/259 +[#270]: https://github.com/noties/Markwon/pull/270 + +[@dallasgutauckis]: https://github.com/dallasgutauckis + + # 4.4.0 * `TextViewSpan` to obtain `TextView` in which markdown is displayed (applied by `CorePlugin`) * `TextLayoutSpan` to obtain `Layout` in which markdown is displayed (applied by `TablePlugin`, more specifically `TableRowSpan` to propagate layout in which cell content is displayed) diff --git a/README.md b/README.md index 594d9781..db19a195 100644 --- a/README.md +++ b/README.md @@ -19,16 +19,16 @@ features listed in [commonmark-spec] are supported (including support for **inlined/block HTML code**, **markdown tables**, **images** and **syntax highlight**). +`Markwon` comes with a [sample application](./app-sample/). It is a +collection of library usages that comes with search and source code for +each code sample. + Since version **4.2.0** **Markwon** comes with an [editor](./markwon-editor/) to _highlight_ markdown input as user types (for example in **EditText**). [commonmark-spec]: https://spec.commonmark.org/0.28/ [commonmark-java]: https://github.com/atlassian/commonmark-java/blob/master/README.md -**This file is displayed by default in the [sample-apk] (`markwon-sample-{latest-version}-debug.apk`) application. Which is a generic markdown viewer with support to display markdown via `http`, `https` & `file` schemes and 2 themes included: Light & Dark* - -[sample-apk]: https://github.com/noties/Markwon/releases - ## Installation ![stable](https://img.shields.io/maven-central/v/io.noties.markwon/core.svg?label=stable) diff --git a/app-sample/README.md b/app-sample/README.md new file mode 100644 index 00000000..895b88bf --- /dev/null +++ b/app-sample/README.md @@ -0,0 +1,75 @@ +# Markwon sample app + +Collection of sample snippets showing different aspects of `Markwon` library usage. Includes +source code of samples, latest stable/snapshot version of the library and search functionality. +Additionally can check for updates. Can be used to preview markdown documents from the `Github.com`. + + + + + + +## Distribution + +Sample app is distributed via special parent-less branch [sample-store](https://github.com/noties/Markwon/tree/sample-store). +Inside the app, under version badges, tap `CHECK FOR UPDATES` to check for updates. Sample app +is not attached to main libraries versions and can be _released_ independently. + +Application is signed with `keystore.jks`, which fingerprints are: +* __SHA1__: `BA:70:A5:D2:40:65:F1:FA:88:90:59:BA:FC:B7:31:81:E6:37:D9:41` +* __SHA256__: `82:C9:61:C5:DF:35:B1:CB:29:D5:48:83:FB:EB:9F:3E:7D:52:67:63:4F:D2:CE:0A:2D:70:17:85:FF:48:67:51` + + +[Download latest APK](https://github.com/noties/Markwon/raw/sample-store/markwon-debug.apk) + + +## Deeplink + +Sample app handles special `markwon` scheme: +* `markwon://sample/{ID}` to open specific sample given the `{ID}` +* `markwon://search?q={TEXT TO SEARCH}&a={ARTIFACT}&t={TAG}` + +Please note that search deeplink can have one of type: artifact or tag (if both are specified artifact will be used). + +To test locally: + +``` +adb shell am start -a android.intent.action.ACTION_VIEW -d markwon://sample/ID +``` + +Please note that you might need to _url encode_ the `-d` argument + +## Building + +When adding/removing samples _most likely_ a clean build would be required. +First, for annotation processor to create `samples.json`. And secondly, +in order for Android Gradle plugin to bundle resources referenced via +symbolic links (the `sample.json` itself and `io.noties.markwon.app.samples.*` directory) + +``` +./gradlew :app-s:clean :app-s:asDe +``` + +## Sample id + +Sample `id` is generated manually when creating new sample. A `Live Template` can be used +to simplify generation part (for example `mid` shortcut with a single variable in `Java` and `Kotlin` scopes): + +``` +groovyScript("new Date().format('YYYYMMddHHmmss', TimeZone.getTimeZone('UTC'))") +``` + +## Tests + +This app uses [Robolectric](https://robolectric.org)(v3.8) for tests which is incompatible +with JDK > 1.8. In order to run tests from command line with IDEA-bundled JDK - a special argument is +required: + +``` +./gradlew :app-s:testDe -Dorg.gradle.java.home="{INSERT BUNDLED JDK PATH HERE}" +``` + +To obtain bundled JDK: +* open `Project Structure...` +* open `SDK Location` +* copy contents of the field under `JDK Location` \ No newline at end of file diff --git a/app-sample/build.gradle b/app-sample/build.gradle new file mode 100644 index 00000000..d6dec350 --- /dev/null +++ b/app-sample/build.gradle @@ -0,0 +1,164 @@ +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply plugin: 'kotlin-kapt' +apply plugin: 'kotlin-android-extensions' + +def gitSha = { -> + def output = new ByteArrayOutputStream() + exec { + commandLine 'git', 'rev-parse', '--short', 'HEAD' + standardOutput = output + } + return output.toString().trim() +}.memoize() + +android { + + compileSdkVersion config['compile-sdk'] + buildToolsVersion config['build-tools'] + + defaultConfig { + applicationId 'io.noties.markwon.app' + minSdkVersion 23 + targetSdkVersion config['target-sdk'] + versionCode 1 + versionName version + + resConfig 'en' + + setProperty("archivesBaseName", "markwon") + + buildConfigField 'String', 'GIT_SHA', "\"${gitSha()}\"" + buildConfigField 'String', 'GIT_REPOSITORY', '"https://github.com/noties/Markwon"' + + final def scheme = 'markwon' + buildConfigField 'String', 'DEEPLINK_SCHEME', "\"$scheme\"" + manifestPlaceholders += [ + 'deeplink_scheme': scheme + ] + } + + dexOptions { + preDexLibraries true + javaMaxHeapSize '5g' + } + + compileOptions { + targetCompatibility JavaVersion.VERSION_1_8 + sourceCompatibility JavaVersion.VERSION_1_8 + } + + sourceSets { + main { + java.srcDirs += '../sample-utils/annotations' + } + } + + // do not sign in CI + if (!project.hasProperty('CI')) { + signingConfigs { + config { + + final def keystoreFile = project.file('keystore.jks') + final def keystoreFilePassword = 'MARKWON_KEYSTORE_FILE_PASSWORD' + final def keystoreAlias = 'MARKWON_KEY_ALIAS' + final def keystoreAliasPassword = 'MARKWON_KEY_ALIAS_PASSWORD' + + final def properties = [ + keystoreFilePassword, + keystoreAlias, + keystoreAliasPassword + ] + + if (!keystoreFile.exists()) { + throw new IllegalStateException("No '${keystoreFile.name}' file is found.") + } + + final def missingProperties = properties.findAll { !project.hasProperty(it) } + if (!missingProperties.isEmpty()) { + throw new IllegalStateException("Missing required signing properties: $missingProperties") + } + + storeFile keystoreFile + storePassword project[keystoreFilePassword] + + keyAlias project[keystoreAlias] + keyPassword project[keystoreAliasPassword] + } + } + + buildTypes { + debug { + signingConfig signingConfigs.config + } + release { + signingConfig signingConfigs.config + } + } + } else { + // it seems to be a bug in NDK handling, github fail to build the project with the: + // `com.android.builder.errors.EvalIssueException: No version of NDK matched the requested version 21.0.6113669. Versions available locally: 21.3.6528147` + ndkVersion '21.3.6528147' + } +} + +kapt { + arguments { + arg('markwon.samples.file', "${projectDir}/samples.json".toString()) + } +} + +androidExtensions { + features = ["parcelize"] +} + +configurations.all { + exclude group: 'org.jetbrains', module: 'annotations-java5' +} + +dependencies { + kapt project(':sample-utils:processor') + deps['annotationProcessor'].with { + kapt it['prism4j-bundler'] + } + + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + + implementation project(':markwon-core') + implementation project(':markwon-editor') + implementation project(':markwon-ext-latex') + implementation project(':markwon-ext-strikethrough') + implementation project(':markwon-ext-tables') + implementation project(':markwon-ext-tasklist') + implementation project(':markwon-html') + implementation project(':markwon-image') + implementation project(':markwon-inline-parser') + implementation project(':markwon-linkify') + implementation project(':markwon-recycler') + implementation project(':markwon-recycler-table') + implementation project(':markwon-simple-ext') + implementation project(':markwon-syntax-highlight') + + implementation project(':markwon-image-picasso') + implementation project(':markwon-image-glide') + + deps.with { +// implementation it['x-appcompat'] + implementation it['x-recycler-view'] + implementation it['x-cardview'] + implementation it['x-fragment'] + implementation it['okhttp'] + implementation it['prism4j'] + implementation it['gson'] + implementation it['adapt'] + implementation it['debug'] + implementation it['android-svg'] + implementation it['android-gif-impl'] + } + + deps['test'].with { + testImplementation it['junit'] + testImplementation it['robolectric'] + testImplementation it['mockito'] + } +} diff --git a/app-sample/deploy.sh b/app-sample/deploy.sh new file mode 100755 index 00000000..f4a83d14 --- /dev/null +++ b/app-sample/deploy.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env sh + +# abort on errors +set -e + +# build +../gradlew :app-sample:clean +../gradlew :app-sample:assembleDebug + +# navigate into the build output directory +cd ./build/outputs/apk/debug/ + +revision=$(git rev-parse --short HEAD) + +echo "output.json" > ./.gitignore +echo "$revision" > ./version + +git init +git add -A +git commit -m "sample $revision" + +git push -f git@github.com:noties/Markwon.git master:sample-store + +cd - \ No newline at end of file diff --git a/app-sample/keystore.jks b/app-sample/keystore.jks new file mode 100644 index 00000000..df4eb4ca Binary files /dev/null and b/app-sample/keystore.jks differ diff --git a/app-sample/samples.json b/app-sample/samples.json new file mode 100644 index 00000000..5ae4e370 --- /dev/null +++ b/app-sample/samples.json @@ -0,0 +1,1163 @@ +[ + { + "javaClassName": "io.noties.markwon.app.samples.BlockHandlerSample", + "id": "20200729090524", + "title": "Block handler", + "description": "Custom block delimiters that control new lines after block nodes", + "artifacts": [ + "CORE" + ], + "tags": [ + "rendering" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.CacheMarkwonSample", + "id": "20200707102458", + "title": "Cache Markwon instance", + "description": "A static cache for `Markwon` instance to be associated with a `Context`", + "artifacts": [ + "CORE" + ], + "tags": [ + "cache" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.tasklist.TaskListMutateSample", + "id": "20200702140901", + "title": "GFM task list mutate", + "description": "", + "artifacts": [ + "EXT_TASKLIST" + ], + "tags": [ + "plugin" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.tasklist.TaskListCustomDrawableSample", + "id": "20200702140749", + "title": "GFM task list custom drawable", + "description": "", + "artifacts": [ + "EXT_TASKLIST" + ], + "tags": [ + "plugin" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.tasklist.TaskListCustomColorsSample", + "id": "20200702140536", + "title": "GFM task list custom colors", + "description": "Custom colors for task list extension", + "artifacts": [ + "EXT_TASKLIST" + ], + "tags": [ + "parsing" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.tasklist.TaskListSample", + "id": "20200702140352", + "title": "GFM task list", + "description": "Github Flavored Markdown (GFM) task list extension", + "artifacts": [ + "EXT_TASKLIST" + ], + "tags": [ + "plugin" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.table.TableLatexSample", + "id": "20200702140041", + "title": "LaTeX inside table", + "description": "Usage of LaTeX formulas inside markdown tables", + "artifacts": [ + "EXT_LATEX", + "EXT_TABLES", + "IMAGE" + ], + "tags": [ + "image" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.table.TableWithImagesSample", + "id": "20200702135932", + "title": "Images inside table", + "description": "Usage of images inside markdown tables", + "artifacts": [ + "EXT_TABLES", + "IMAGE" + ], + "tags": [ + "image" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.table.TableLinkifySample", + "id": "20200702135739", + "title": "Linkify table", + "description": "Automatically linkify markdown content including content inside tables", + "artifacts": [ + "EXT_TABLES", + "LINKIFY" + ], + "tags": [ + "links" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.table.TableCustomizeSample", + "id": "20200702135621", + "title": "Customize table theme", + "description": "", + "artifacts": [ + "EXT_TABLES" + ], + "tags": [ + "theme" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.RecyclerSample", + "id": "20200702101750", + "title": "RecyclerView", + "description": "Usage with `RecyclerView`", + "artifacts": [ + "RECYCLER", + "RECYCLER_TABLE" + ], + "tags": [ + "recycler-view" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.LinkRemoveUnderlineSample", + "id": "20200702101224", + "title": "Remove link underline", + "description": "", + "artifacts": [ + "CORE" + ], + "tags": [ + "links", + "rendering", + "span" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.PrecomputedFutureSample", + "id": "20200702092446", + "title": "PrecomputedFutureTextSetterCompat", + "description": "Usage of `PrecomputedFutureTextSetterCompat` inside a `RecyclerView` with appcompat", + "artifacts": [ + "RECYCLER" + ], + "tags": [ + "precomputed-text", + "recycler-view" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.PrecomputedSample", + "id": "20200702091654", + "title": "PrecomputedTextSetterCompat", + "description": "`TextSetter` to use `PrecomputedTextSetterCompat`", + "artifacts": [ + "CORE" + ], + "tags": [ + "precomputed-text" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.notification.RemoteViewsSample", + "id": "20200702090140", + "title": "RemoteViews in notification", + "description": "Display markdown with platform (system) spans in notification via `RemoteViews`", + "artifacts": [ + "CORE" + ], + "tags": [ + "hack" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.notification.NotificationSample", + "id": "20200701130729", + "title": "Markdown in Notification", + "description": "Proof of concept of using `Markwon` with `android.app.Notification`", + "artifacts": [ + "CORE" + ], + "tags": [ + "hack" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.latex.LatexErrorSample", + "id": "20200701122624", + "title": "LaTeX error handling", + "description": "Log error when parsing LaTeX and display error drawable", + "artifacts": [ + "EXT_LATEX" + ], + "tags": [ + "rendering" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.latex.LatexThemeSample", + "id": "20200701121528", + "title": "LaTeX theme", + "description": "Sample of theme customization for LaTeX", + "artifacts": [ + "EXT_LATEX", + "INLINE_PARSER" + ], + "tags": [ + "rendering" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.latex.LatexDefaultTextColorSample", + "id": "20200701120848", + "title": "LaTeX default text color", + "description": "LaTeX will use text color of `TextView` by default", + "artifacts": [ + "EXT_LATEX" + ], + "tags": [ + "rendering" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.latex.LatexDarkSample", + "id": "20200701094225", + "title": "LaTeX dark", + "description": "LaTeX automatically uses `TextView` text color if not configured explicitly", + "artifacts": [ + "EXT_LATEX" + ], + "tags": [ + "rendering" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.latex.LatexDifferentTextSizesSample", + "id": "20200701093504", + "title": "LaTeX inline/block different text size", + "description": "", + "artifacts": [ + "EXT_LATEX", + "INLINE_PARSER" + ], + "tags": [ + "rendering" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.latex.LatexOmegaSample", + "id": "20200701090618", + "title": "LaTeX omega symbol", + "description": "Bug rendering omega symbol in LaTeX", + "artifacts": [ + "EXT_LATEX", + "INLINE_PARSER" + ], + "tags": [ + "known-bug", + "rendering" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.latex.LatexLegacySample", + "id": "20200701090335", + "title": "LaTeX blocks in legacy mode", + "description": "Sample using _legacy_ LaTeX block parsing (pre `4.3.0` Markwon version)", + "artifacts": [ + "EXT_LATEX" + ], + "tags": [ + "rendering" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.latex.LatexInlineSample", + "id": "20200701085820", + "title": "LaTeX inline", + "description": "Display LaTeX inline", + "artifacts": [ + "EXT_LATEX", + "INLINE_PARSER" + ], + "tags": [ + "rendering" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.latex.LatexBlockSample", + "id": "20200630200257", + "title": "LaTex block", + "description": "Render LaTeX block", + "artifacts": [ + "EXT_LATEX" + ], + "tags": [ + "rendering" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.inlineparsing.InlineParsingTooltipSample", + "id": "20200630195409", + "title": "Tooltip with inline parser", + "description": "", + "artifacts": [ + "INLINE_PARSER" + ], + "tags": [ + "parsing", + "rendering" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.SimpleExtensionSample", + "id": "20200630194335", + "title": "Delimiter processor simple-ext", + "description": "Custom delimiter processor implemented with a `SimpleExtPlugin`", + "artifacts": [ + "SIMPLE_EXT" + ], + "tags": [ + "parsing" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.DelimiterProcessorSample", + "id": "20200630194017", + "title": "Custom delimiter processor", + "description": "Custom parsing delimiter processor with `?` character", + "artifacts": [ + "CORE" + ], + "tags": [ + "parsing" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.html.HtmlDisableSanitizeSample", + "id": "20200630171424", + "title": "Disable HTML", + "description": "Disable HTML via replacing special `\u003c` and `\u003e` symbols", + "artifacts": [ + "CORE" + ], + "tags": [ + "HTML", + "parsing", + "plugin", + "rendering" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.inlineparsing.InlineParsingNoHtmlSample", + "id": "20200630171239", + "title": "Inline parsing exclude HTML", + "description": "", + "artifacts": [ + "INLINE_PARSER" + ], + "tags": [ + "block", + "inline", + "parsing" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.inlineparsing.InlineParsingNoDefaultsSample", + "id": "20200630170823", + "title": "Inline parsing no defaults", + "description": "Parsing only inline code and disable all the rest", + "artifacts": [ + "INLINE_PARSER" + ], + "tags": [ + "inline", + "parsing" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.inlineparsing.InlineParsingWithDefaultsSample", + "id": "20200630170723", + "title": "Inline parsing with defaults", + "description": "Parsing with all defaults except links", + "artifacts": [ + "INLINE_PARSER" + ], + "tags": [ + "inline", + "parsing" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.inlineparsing.InlineParsingDisableCodeSample", + "id": "20200630170607", + "title": "Disable code inline parsing", + "description": "", + "artifacts": [ + "INLINE_PARSER" + ], + "tags": [ + "inline", + "parsing" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.inlineparsing.InlineParsingLinksOnlySample", + "id": "20200630170412", + "title": "Links only inline parsing", + "description": "", + "artifacts": [ + "INLINE_PARSER" + ], + "tags": [ + "inline", + "parsing" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.image.GlidePlaceholderImageSample", + "id": "20200630170241", + "title": "Glide image with placeholder", + "description": "", + "artifacts": [ + "IMAGE_GLIDE" + ], + "tags": [ + "image" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.image.GlideImageSample", + "id": "20200630170112", + "title": "Glide image", + "description": "", + "artifacts": [ + "IMAGE_GLIDE" + ], + "tags": [ + "image" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.image.ErrorImageSample", + "id": "20200630165828", + "title": "Image error handler", + "description": "", + "artifacts": [ + "IMAGE" + ], + "tags": [ + "image" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.image.PlaceholderImageSample", + "id": "20200630165504", + "title": "Image with placeholder", + "description": "", + "artifacts": [ + "IMAGE" + ], + "tags": [ + "image" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.image.GifImageSample", + "id": "20200630162214", + "title": "GIF image", + "description": "", + "artifacts": [ + "IMAGE" + ], + "tags": [ + "GIF", + "image" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.image.SvgImageSample", + "id": "20200630161952", + "title": "SVG image", + "description": "", + "artifacts": [ + "IMAGE" + ], + "tags": [ + "SVG", + "image" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.image.ImageSample", + "id": "20200630144659", + "title": "Markdown image", + "description": "", + "artifacts": [ + "IMAGE" + ], + "tags": [ + "image" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.html.HtmlDetailsSample", + "id": "20200630120752", + "title": "Details HTML tag", + "description": "Handling of `details` HTML tag", + "artifacts": [ + "HTML", + "IMAGE" + ], + "tags": [ + "HTML", + "image", + "rendering" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.html.HtmlCenterTagSample", + "id": "20200630120101", + "title": "Center HTML tag", + "description": "Handling of `center` HTML tag", + "artifacts": [ + "HTML", + "IMAGE" + ], + "tags": [ + "HTML", + "rendering" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.html.HtmlEmptyTagReplacementSample", + "id": "20200630115725", + "title": "HTML empty tag replacement", + "description": "Render custom content when HTML tag contents is empty, in case of self-closed HTML tags or tags without content (closed right after opened)", + "artifacts": [ + "HTML" + ], + "tags": [ + "HTML", + "rendering" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.html.HtmlIFrameSample", + "id": "20200630115521", + "title": "IFrame HTML tag", + "description": "Handling of `iframe` HTML tag", + "artifacts": [ + "HTML", + "IMAGE" + ], + "tags": [ + "HTML", + "image", + "rendering" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.html.HtmlImageSample", + "id": "20200630115300", + "title": "Html images", + "description": "Usage of HTML images", + "artifacts": [ + "HTML", + "IMAGE" + ], + "tags": [ + "HTML", + "image", + "rendering" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.html.HtmlEnhanceSample", + "id": "20200630115103", + "title": "Enhance custom HTML tag", + "description": "Custom HTML tag implementation that _enhances_ a part of text given start and end indices", + "artifacts": [ + "HTML" + ], + "tags": [ + "HTML", + "rendering", + "span" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.html.HtmlRandomCharSize", + "id": "20200630114923", + "title": "Random char size HTML tag", + "description": "Implementation of a custom HTML tag handler that assigns each character a random size", + "artifacts": [ + "HTML" + ], + "tags": [ + "HTML", + "rendering", + "span" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.html.HtmlAlignSample", + "id": "20200630114630", + "title": "Align HTML tag", + "description": "Implement custom HTML tag handling", + "artifacts": [ + "HTML" + ], + "tags": [ + "HTML", + "rendering", + "span" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.editor.EditorHeadingSample", + "id": "20200630113954", + "title": "Heading edit handler", + "description": "Handling of heading node in editor", + "artifacts": [ + "EDITOR", + "INLINE_PARSER" + ], + "tags": [ + "editor" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.NoParsingSample", + "id": "20200629171212", + "title": "No parsing", + "description": "All commonmark parsing is disabled (both inlines and blocks)", + "artifacts": [ + "CORE" + ], + "tags": [ + "parsing", + "rendering" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.InlinePluginNoDefaultsSample", + "id": "20200629170857", + "title": "Inline parsing without defaults", + "description": "Configure inline parser plugin to **not** have any **inline** parsing", + "artifacts": [ + "INLINE_PARSER" + ], + "tags": [ + "parsing" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.editor.EditorNewLineContinuationSample", + "id": "20200629170348", + "title": "Editor new line continuation", + "description": "Sample of how new line character can be handled in order to add a _continuation_, for example adding a new bullet list item if current line starts with one", + "artifacts": [ + "EDITOR", + "INLINE_PARSER" + ], + "tags": [ + "editor" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.editor.EditorMultipleEditSpansSample", + "id": "20200629165920", + "title": "Multiple edit spans", + "description": "Additional multiple edit spans for editor", + "artifacts": [ + "EDITOR", + "INLINE_PARSER" + ], + "tags": [ + "editor" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.editor.EditorAdditionalPluginSample", + "id": "20200629165347", + "title": "Additional plugin", + "description": "Additional plugin for editor", + "artifacts": [ + "EDITOR", + "EXT_STRIKETHROUGH", + "INLINE_PARSER" + ], + "tags": [ + "editor" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.editor.EditorAdditionalEditSpan", + "id": "20200629165136", + "title": "Additional edit span", + "description": "Additional _edit_ span (span that is present in `EditText` along with punctuation", + "artifacts": [ + "EDITOR", + "INLINE_PARSER" + ], + "tags": [ + "editor", + "span" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.editor.EditorCustomPunctuationSample", + "id": "20200629164627", + "title": "Custom punctuation span", + "description": "Custom span for punctuation in editor", + "artifacts": [ + "EDITOR", + "INLINE_PARSER" + ], + "tags": [ + "editor", + "span" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.editor.EditorPreRenderSample", + "id": "20200629164422", + "title": "Editor with pre-render (async)", + "description": "Editor functionality with highlight taking place in another thread", + "artifacts": [ + "EDITOR", + "INLINE_PARSER" + ], + "tags": [ + "editor" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.editor.EditorSimpleSample", + "id": "20200629164227", + "title": "Simple editor", + "description": "Simple usage of editor with markdown highlight", + "artifacts": [ + "EDITOR", + "INLINE_PARSER" + ], + "tags": [ + "editor" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.CustomExtensionSample", + "id": "20200629163248", + "title": "Custom extension", + "description": "Custom extension that adds an icon from resources and renders it as image with `@ic-name` syntax", + "artifacts": [ + "CORE" + ], + "tags": [ + "extension", + "image", + "parsing", + "plugin", + "rendering", + "span" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.GithubUserIssueOnTextAddedSample", + "id": "20200629162024", + "title": "User mention and issue (via text)", + "description": "Github-like user mention and issue rendering via `CorePlugin.OnTextAddedListener`", + "artifacts": [ + "CORE" + ], + "tags": [ + "parsing", + "rendering", + "text-added-listener" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.GithubUserIssueInlineParsingSample", + "id": "20200629162024", + "title": "User mention and issue (via text)", + "description": "Github-like user mention and issue rendering via `CorePlugin.OnTextAddedListener`", + "artifacts": [ + "CORE", + "INLINE_PARSER" + ], + "tags": [ + "parsing", + "rendering", + "text-added-listener" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.ReadMorePluginSample", + "id": "20200629161505", + "title": "Read more plugin", + "description": "Plugin that adds expand/collapse (\"show all\"/\"show less\")", + "artifacts": [ + "CORE" + ], + "tags": [ + "plugin" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.plugins.TableOfContentsSample", + "id": "20200629161226", + "title": "Table of contents", + "description": "Sample plugin that adds a table of contents header", + "artifacts": [ + "CORE" + ], + "tags": [ + "plugin", + "rendering" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.LetterOrderedListSample", + "id": "20200629130954", + "title": "Letter ordered list", + "description": "Render bullet list inside an ordered list with letters instead of bullets", + "artifacts": [ + "CORE" + ], + "tags": [ + "lists", + "plugin", + "rendering" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.plugins.AnchorSample", + "id": "20200629130728", + "title": "Anchor plugin", + "description": "HTML-like anchor links plugin, which scrolls to clicked anchor", + "artifacts": [ + "CORE" + ], + "tags": [ + "anchor", + "links", + "plugin" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.AllBlocksNoForcedNewLineSample", + "id": "20200629130227", + "title": "All blocks no padding", + "description": "Do not render new lines (padding) after all blocks", + "artifacts": [ + "CORE" + ], + "tags": [ + "block", + "padding", + "rendering", + "spacing" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.HeadingNoSpaceBlockHandlerSample", + "id": "20200629125924", + "title": "Heading no padding (block handler)", + "description": "Process padding (spacing) after heading with a `BlockHandler`", + "artifacts": [ + "CORE" + ], + "tags": [ + "block", + "heading", + "padding", + "rendering", + "spacing" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.HeadingNoSpaceSample", + "id": "20200629125622", + "title": "Heading no padding", + "description": "Do not add a new line after heading node", + "artifacts": [ + "CORE" + ], + "tags": [ + "padding", + "rendering", + "spacing" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.AdditionalSpacingSample", + "id": "20200629125321", + "title": "Additional spacing after block", + "description": "Add additional spacing (padding) after last line of a block", + "artifacts": [ + "CORE" + ], + "tags": [ + "padding", + "spacing", + "span" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.SoftBreakAddsNewLineSample", + "id": "20200629125040", + "title": "Soft break new line", + "description": "Add a new line for a markdown soft-break node", + "artifacts": [ + "CORE" + ], + "tags": [ + "new-line", + "soft-break" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.SoftBreakAddsSpace", + "id": "20200629124706", + "title": "Soft break adds space", + "description": "By default a soft break (`\n`) will add a space character instead of new line", + "artifacts": [ + "CORE" + ], + "tags": [ + "defaults", + "new-line", + "soft-break" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.image.ImagesCustomSchemeSample", + "id": "20200629124201", + "title": "Image destination custom scheme", + "description": "Example of handling custom scheme (`https`, `ftp`, `whatever`, etc.) for images destination URLs with `ImagesPlugin`", + "artifacts": [ + "IMAGE" + ], + "tags": [ + "image" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.LinkWithoutSchemeSample", + "id": "20200629124005", + "title": "Links without scheme", + "description": "Links without scheme are considered to be `https`", + "artifacts": [ + "CORE" + ], + "tags": [ + "defaults", + "links" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.CustomizeThemeSample", + "id": "20200629123617", + "title": "Customize theme", + "description": "Customize `MarkwonTheme` styling", + "artifacts": [ + "CORE" + ], + "tags": [ + "plugin", + "style", + "theme" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.DisableNodeSample", + "id": "20200629123308", + "title": "Disable node from rendering", + "description": "Disable _parsed_ node from being rendered (markdown syntax is still consumed)", + "artifacts": [ + "CORE" + ], + "tags": [ + "parsing", + "rendering" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.ParagraphSpanStyle", + "id": "20200629122647", + "title": "Paragraph style", + "description": "Apply a style (via span) to a paragraph", + "artifacts": [ + "CORE" + ], + "tags": [ + "paragraph", + "span", + "style" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.LinkTitleSample", + "id": "20200629122230", + "title": "Obtain link title", + "description": "Obtain title (text) of clicked link, `[title](#destination)`", + "artifacts": [ + "CORE" + ], + "tags": [ + "links", + "span" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.movementmethod.DisableImplicitMovementMethodPluginSample", + "id": "20200629121803", + "title": "Disable implicit movement method via plugin", + "description": "Disable implicit movement method via `MovementMethodPlugin`", + "artifacts": [ + "CORE" + ], + "tags": [ + "links", + "movement-method", + "recycler-view" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.movementmethod.MovementMethodPluginSample", + "id": "20200627081631", + "title": "MovementMethodPlugin", + "description": "Plugin to control movement method", + "artifacts": [ + "CORE" + ], + "tags": [ + "links", + "movement-method", + "plugin" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.movementmethod.DisableImplicitMovementMethodSample", + "id": "20200627081256", + "title": "Disable implicit movement method", + "description": "Configure `Markwon` to **not** apply implicit movement method, which consumes touch events when used in a `RecyclerView` even when markdown does not contain links", + "artifacts": [ + "CORE" + ], + "tags": [ + "links", + "movement-method", + "plugin", + "recycler-view" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.movementmethod.ExplicitMovementMethodSample", + "id": "20200627080007", + "title": "Explicit movement method", + "description": "When a movement method already applied to a `TextView``Markwon` won\u0027t try to apply own (implicit) one", + "artifacts": [ + "CORE" + ], + "tags": [ + "links", + "movement-method" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.movementmethod.ImplicitMovementMethodSample", + "id": "20200627075524", + "title": "Implicit movement method", + "description": "By default movement method is applied for links to be clickable", + "artifacts": [ + "CORE" + ], + "tags": [ + "defaults", + "links", + "movement-method" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.EnabledBlockTypesSample", + "id": "20200627075012", + "title": "Enabled markdown blocks", + "description": "Modify/inspect enabled by `CorePlugin` block types. Disable quotes or other blocks from being parsed", + "artifacts": [ + "CORE" + ], + "tags": [ + "block", + "parsing", + "plugin" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.ToastDynamicContentSample", + "id": "20200627074017", + "title": "Markdown in Toast (with dynamic content)", + "description": "Display markdown in a `android.widget.Toast` with dynamic content (image)", + "artifacts": [ + "CORE", + "IMAGE" + ], + "tags": [ + "hack", + "toast" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.ToastSample", + "id": "20200627072642", + "title": "Markdown in Toast", + "description": "Display _static_ markdown content in a `android.widget.Toast`", + "artifacts": [ + "CORE" + ], + "tags": [ + "toast" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.basics.SimpleWalkthrough", + "id": "20200626153426", + "title": "Simple with walk-through", + "description": "Walk-through for simple use case", + "artifacts": [ + "CORE" + ], + "tags": [ + "basics" + ] + }, + { + "javaClassName": "io.noties.markwon.app.samples.basics.Simple", + "id": "20200626152255", + "title": "Simple", + "description": "The most primitive and simple way to apply markdown to a `TextView`", + "artifacts": [ + "CORE" + ], + "tags": [ + "basics" + ] + } +] \ No newline at end of file diff --git a/app-sample/src/debug/res/layout/flowlayout_preview.xml b/app-sample/src/debug/res/layout/flowlayout_preview.xml new file mode 100644 index 00000000..c78c9d10 --- /dev/null +++ b/app-sample/src/debug/res/layout/flowlayout_preview.xml @@ -0,0 +1,166 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app-sample/src/main/AndroidManifest.xml b/app-sample/src/main/AndroidManifest.xml new file mode 100644 index 00000000..0c265b10 --- /dev/null +++ b/app-sample/src/main/AndroidManifest.xml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/assets/README.md b/app-sample/src/main/assets/README.md similarity index 100% rename from app/src/main/assets/README.md rename to app-sample/src/main/assets/README.md diff --git a/app-sample/src/main/assets/samples b/app-sample/src/main/assets/samples new file mode 120000 index 00000000..8de4a4a9 --- /dev/null +++ b/app-sample/src/main/assets/samples @@ -0,0 +1 @@ +../../main/java/io/noties/markwon/app/samples/ \ No newline at end of file diff --git a/app-sample/src/main/assets/samples.json b/app-sample/src/main/assets/samples.json new file mode 120000 index 00000000..3fa0b845 --- /dev/null +++ b/app-sample/src/main/assets/samples.json @@ -0,0 +1 @@ +../../../samples.json \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/App.kt b/app-sample/src/main/java/io/noties/markwon/app/App.kt new file mode 100644 index 00000000..682a7a20 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/App.kt @@ -0,0 +1,58 @@ +package io.noties.markwon.app + +import android.app.Application +import android.content.ComponentName +import android.content.Intent +import android.content.pm.ShortcutInfo +import android.content.pm.ShortcutManager +import android.os.Build +import io.noties.debug.AndroidLogDebugOutput +import io.noties.debug.Debug +import io.noties.markwon.app.readme.ReadMeActivity +import io.noties.markwon.app.sample.SampleManager +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + +@Suppress("unused") +// `open` is required for tests (to create a spy mockito instance) +open class App : Application() { + + override fun onCreate() { + super.onCreate() + + Debug.init(AndroidLogDebugOutput(BuildConfig.DEBUG)) + + executorService = Executors.newCachedThreadPool() + sampleManager = SampleManager(this, executorService) + + ensureReadmeShortcut() + } + + private fun ensureReadmeShortcut() { + if (Build.VERSION.SDK_INT < 25) { + return + } + + val manager = getSystemService(ShortcutManager::class.java) ?: return + + @Suppress("ReplaceNegatedIsEmptyWithIsNotEmpty") + if (!manager.dynamicShortcuts.isEmpty()) { + return + } + + val intent = Intent(Intent.ACTION_VIEW).apply { + component = ComponentName(this@App, ReadMeActivity::class.java) + } + + val shortcut = ShortcutInfo.Builder(this, "readme") + .setShortLabel("README") + .setIntent(intent) + .build() + manager.addDynamicShortcuts(mutableListOf(shortcut)) + } + + companion object { + lateinit var executorService: ExecutorService + lateinit var sampleManager: SampleManager + } +} \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/readme/GithubImageDestinationProcessor.kt b/app-sample/src/main/java/io/noties/markwon/app/readme/GithubImageDestinationProcessor.kt new file mode 100644 index 00000000..f94f3a5d --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/readme/GithubImageDestinationProcessor.kt @@ -0,0 +1,25 @@ +package io.noties.markwon.app.readme + +import android.net.Uri +import android.text.TextUtils +import io.noties.markwon.image.destination.ImageDestinationProcessor +import io.noties.markwon.image.destination.ImageDestinationProcessorRelativeToAbsolute + +class GithubImageDestinationProcessor( + username: String = "noties", + repository: String = "Markwon", + branch: String = "master" +) : ImageDestinationProcessor() { + + private val processor = ImageDestinationProcessorRelativeToAbsolute("https://github.com/$username/$repository/raw/$branch/") + + override fun process(destination: String): String { + // process only images without scheme information + val uri = Uri.parse(destination) + return if (TextUtils.isEmpty(uri.scheme)) { + processor.process(destination) + } else { + destination + } + } +} \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/readme/ReadMeActivity.kt b/app-sample/src/main/java/io/noties/markwon/app/readme/ReadMeActivity.kt new file mode 100644 index 00000000..77d5cd59 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/readme/ReadMeActivity.kt @@ -0,0 +1,178 @@ +package io.noties.markwon.app.readme + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.View +import android.widget.TextView +import androidx.recyclerview.widget.DefaultItemAnimator +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import io.noties.debug.Debug +import io.noties.markwon.AbstractMarkwonPlugin +import io.noties.markwon.Markwon +import io.noties.markwon.MarkwonConfiguration +import io.noties.markwon.MarkwonVisitor +import io.noties.markwon.app.R +import io.noties.markwon.app.utils.ReadMeUtils +import io.noties.markwon.app.utils.hidden +import io.noties.markwon.app.utils.loadReadMe +import io.noties.markwon.app.utils.textOrHide +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.recycler.MarkwonAdapter +import io.noties.markwon.recycler.SimpleEntry +import io.noties.markwon.recycler.table.TableEntry +import io.noties.markwon.recycler.table.TableEntryPlugin +import io.noties.markwon.syntax.Prism4jThemeDefault +import io.noties.markwon.syntax.SyntaxHighlightPlugin +import io.noties.prism4j.Prism4j +import io.noties.prism4j.annotations.PrismBundle +import okhttp3.Call +import okhttp3.Callback +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import org.commonmark.ext.gfm.tables.TableBlock +import org.commonmark.node.FencedCodeBlock +import java.io.IOException + +@PrismBundle(includeAll = true) +class ReadMeActivity : Activity() { + + private lateinit var progressBar: View + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_read_me) + + progressBar = findViewById(R.id.progress_bar) + + val data = intent.data + + Debug.i(data) + + initAppBar(data) + + initRecyclerView(data) + } + + private val markwon: Markwon + get() = Markwon.builder(this) + .usePlugin(ImagesPlugin.create()) + .usePlugin(HtmlPlugin.create()) + .usePlugin(TableEntryPlugin.create(this)) + .usePlugin(SyntaxHighlightPlugin.create(Prism4j(GrammarLocatorDef()), Prism4jThemeDefault.create(0))) + .usePlugin(TaskListPlugin.create(this)) + .usePlugin(StrikethroughPlugin.create()) + .usePlugin(ReadMeImageDestinationPlugin(intent.data)) + .usePlugin(object : AbstractMarkwonPlugin() { + override fun configureVisitor(builder: MarkwonVisitor.Builder) { + builder.on(FencedCodeBlock::class.java) { visitor, block -> + // we actually won't be applying code spans here, as our custom view will + // draw background and apply mono typeface + // + // NB the `trim` operation on literal (as code will have a new line at the end) + val code = visitor.configuration() + .syntaxHighlight() + .highlight(block.info, block.literal.trim()) + visitor.builder().append(code) + } + } + + override fun configureConfiguration(builder: MarkwonConfiguration.Builder) { + builder.linkResolver(ReadMeLinkResolver()) + } + }) + .build() + + private fun initAppBar(data: Uri?) { + val appBar = findViewById(R.id.app_bar) + appBar.findViewById(R.id.app_bar_icon).setOnClickListener { onBackPressed() } + + val (title: String, subtitle: String?) = if (data == null) { + Pair("README.md", null) + } else { + Pair(data.lastPathSegment ?: "", data.toString()) + } + + appBar.findViewById(R.id.title).text = title + appBar.findViewById(R.id.subtitle).textOrHide(subtitle) + } + + private fun initRecyclerView(data: Uri?) { + + val adapter = MarkwonAdapter.builder(R.layout.adapter_node, R.id.text_view) + .include(FencedCodeBlock::class.java, SimpleEntry.create(R.layout.adapter_node_code_block, R.id.text_view)) + .include(TableBlock::class.java, TableEntry.create { + it + .tableLayout(R.layout.adapter_node_table_block, R.id.table_layout) + .textLayoutIsRoot(R.layout.view_table_entry_cell) + }) + .build() + + val recyclerView: RecyclerView = findViewById(R.id.recycler_view) + recyclerView.layoutManager = LinearLayoutManager(this) + recyclerView.setHasFixedSize(true) + recyclerView.itemAnimator = DefaultItemAnimator() + recyclerView.adapter = adapter + + load(applicationContext, data) { result -> + + when (result) { + is Result.Failure -> Debug.e(result.throwable) + is Result.Success -> { + val markwon = markwon + val node = markwon.parse(result.markdown) + if (window != null) { + recyclerView.post { + adapter.setParsedMarkdown(markwon, node) + adapter.notifyDataSetChanged() + progressBar.hidden = true + } + } + } + } + } + } + + private sealed class Result { + data class Success(val markdown: String) : Result() + data class Failure(val throwable: Throwable) : Result() + } + + companion object { + fun makeIntent(context: Context): Intent { + return Intent(context, ReadMeActivity::class.java) + } + + private fun load(context: Context, data: Uri?, callback: (Result) -> Unit) = try { + + if (data == null) { + callback.invoke(Result.Success(loadReadMe(context))) + } else { + val request = Request.Builder() + .get() + .url(ReadMeUtils.buildRawGithubUrl(data)) + .build() + OkHttpClient().newCall(request).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + callback.invoke(Result.Failure(e)) + } + + override fun onResponse(call: Call, response: Response) { + val md = response.body()?.string() ?: "" + callback.invoke(Result.Success(md)) + } + }) + } + + } catch (t: Throwable) { + callback.invoke(Result.Failure(t)) + } + } +} \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/readme/ReadMeImageDestinationPlugin.kt b/app-sample/src/main/java/io/noties/markwon/app/readme/ReadMeImageDestinationPlugin.kt new file mode 100644 index 00000000..a4d25210 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/readme/ReadMeImageDestinationPlugin.kt @@ -0,0 +1,21 @@ +package io.noties.markwon.app.readme + +import android.net.Uri +import io.noties.markwon.AbstractMarkwonPlugin +import io.noties.markwon.MarkwonConfiguration +import io.noties.markwon.app.utils.ReadMeUtils + +class ReadMeImageDestinationPlugin(private val data: Uri?) : AbstractMarkwonPlugin() { + override fun configureConfiguration(builder: MarkwonConfiguration.Builder) { + val info = ReadMeUtils.parseInfo(data) + if (info == null) { + builder.imageDestinationProcessor(GithubImageDestinationProcessor()) + } else { + builder.imageDestinationProcessor(GithubImageDestinationProcessor( + username = info.username, + repository = info.repository, + branch = info.branch + )) + } + } +} \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/readme/ReadMeLinkResolver.kt b/app-sample/src/main/java/io/noties/markwon/app/readme/ReadMeLinkResolver.kt new file mode 100644 index 00000000..4320047c --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/readme/ReadMeLinkResolver.kt @@ -0,0 +1,18 @@ +package io.noties.markwon.app.readme + +import android.view.View +import io.noties.markwon.LinkResolverDef +import io.noties.markwon.app.utils.ReadMeUtils + +class ReadMeLinkResolver : LinkResolverDef() { + + override fun resolve(view: View, link: String) { + val info = ReadMeUtils.parseRepository(link) + val url = if (info != null) { + ReadMeUtils.buildRepositoryReadMeUrl(info.first, info.second) + } else { + link + } + super.resolve(view, url) + } +} \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/sample/Deeplink.kt b/app-sample/src/main/java/io/noties/markwon/app/sample/Deeplink.kt new file mode 100644 index 00000000..fe05cb3a --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/sample/Deeplink.kt @@ -0,0 +1,104 @@ +package io.noties.markwon.app.sample + +import android.net.Uri +import io.noties.debug.Debug +import io.noties.markwon.app.BuildConfig +import io.noties.markwon.sample.annotations.MarkwonArtifact + +sealed class Deeplink { + data class Sample(val id: String) : Deeplink() + data class Search(val search: SampleSearch) : Deeplink() + + companion object { + fun parse(data: Uri?): Deeplink? { + Debug.i(data) + @Suppress("NAME_SHADOWING") + val data = data ?: return null + return when (data.scheme) { + // local deeplink with custom scheme (`markwon://`) + BuildConfig.DEEPLINK_SCHEME -> { + when (data.host) { + "sample" -> parseSample(data.lastPathSegment) + "search" -> parseSearch(data.query) + else -> null + } + } + // https deeplink, `https://noties.io/Markwon/sample` + "https" -> { + // https://noties.io/Markwon/app/sample/ID + // https://noties.io/Markwon/app/search?a=core + val segments = data.pathSegments + if (segments.size == 3 + && "Markwon" == segments[0] + && "app" == segments[1]) { + when (segments[2]) { + "sample" -> parseSample(data.lastPathSegment) + "search" -> parseSearch(data.query) + else -> null + } + } else { + null + } + } + else -> null + } + } + + private fun parseSample(id: String?): Sample? { + if (id == null) return null + return Sample(id) + } + + private fun parseSearch(query: String?): Search? { + Debug.i("query: '$query'") + + val params = query + ?.let { + // `https:.*` has query with `search?a=core` + val index = it.indexOf('?') + if (index > -1) { + it.substring(index + 1) + } else { + it + } + } + ?.split("&") + ?.map { + val (k, v) = it.split("=") + Pair(k, v) + } + ?.toMap() + ?: return null + + Debug.i("params: $params") + + val artifact = params["a"] + val tag = params["t"] + val search = params["q"] + + Debug.i("artifact: '$artifact', tag: '$tag', search: '$search'") + + val sampleSearch: SampleSearch? = if (artifact != null) { + val encodedArtifact = MarkwonArtifact.values() + .firstOrNull { it.artifactName() == artifact } + if (encodedArtifact != null) { + SampleSearch.Artifact(search, encodedArtifact) + } else { + null + } + } else if (tag != null) { + SampleSearch.Tag(search, tag) + } else if (search != null) { + SampleSearch.All(search) + } else { + null + } + + if (sampleSearch == null) { + return null + } + + return Search(sampleSearch) + } + } +} \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/sample/MainActivity.kt b/app-sample/src/main/java/io/noties/markwon/app/sample/MainActivity.kt new file mode 100644 index 00000000..c3405494 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/sample/MainActivity.kt @@ -0,0 +1,40 @@ +package io.noties.markwon.app.sample + +import android.os.Bundle +import android.view.Window +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import io.noties.markwon.app.App +import io.noties.markwon.app.sample.ui.SampleFragment +import io.noties.markwon.app.sample.ui.SampleListFragment + +class MainActivity : FragmentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + if (supportFragmentManager.findFragmentById(Window.ID_ANDROID_CONTENT) == null) { + + supportFragmentManager.beginTransaction() + .add(Window.ID_ANDROID_CONTENT, SampleListFragment.init()) + .commitNowAllowingStateLoss() + + // process deeplink if we are not restored + val deeplink = Deeplink.parse(intent.data) + val deepLinkFragment: Fragment? = if (deeplink != null) { + when (deeplink) { + is Deeplink.Sample -> App.sampleManager.sample(deeplink.id) + ?.let { SampleFragment.init(it) } + is Deeplink.Search -> SampleListFragment.init(deeplink.search) + } + } else null + + if (deepLinkFragment != null) { + supportFragmentManager.beginTransaction() + .replace(Window.ID_ANDROID_CONTENT, deepLinkFragment) + .addToBackStack(null) + .commit() + } + } + } +} \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/sample/Sample.kt b/app-sample/src/main/java/io/noties/markwon/app/sample/Sample.kt new file mode 100644 index 00000000..9ba5cd02 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/sample/Sample.kt @@ -0,0 +1,22 @@ +package io.noties.markwon.app.sample + +import android.os.Parcelable +import io.noties.markwon.sample.annotations.MarkwonArtifact +import kotlinx.android.parcel.Parcelize + +@Parcelize +data class Sample( + val javaClassName: String, + val id: String, + val title: String, + val description: String, + val artifacts: List, + val tags: List +) : Parcelable { + + enum class Language { + JAVA, KOTLIN + } + + data class Code(val language: Language, val sourceCode: String) +} \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/sample/SampleManager.kt b/app-sample/src/main/java/io/noties/markwon/app/sample/SampleManager.kt new file mode 100644 index 00000000..d0021d53 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/sample/SampleManager.kt @@ -0,0 +1,75 @@ +package io.noties.markwon.app.sample + +import android.content.Context +import io.noties.markwon.app.utils.Cancellable +import io.noties.markwon.app.utils.SampleUtils +import io.noties.markwon.sample.annotations.MarkwonArtifact +import java.util.concurrent.ExecutorService + +class SampleManager( + private val context: Context, + private val executorService: ExecutorService +) { + + private val samples: List by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { + SampleUtils.readSamples(context) + } + + fun sample(id: String): Sample? { + return samples.firstOrNull { id == it.id } + } + + fun samples(search: SampleSearch?, callback: (List) -> Unit): Cancellable { + + var action: ((List) -> Unit)? = callback + + val future = executorService.submit { + + val source = when (search) { + is SampleSearch.Artifact -> samples.filter { it.artifacts.contains(search.artifact) } + is SampleSearch.Tag -> samples.filter { it.tags.contains(search.tag) } + else -> samples.toList() // just copy all + } + + val text = search?.text + val results = if (text == null) { + // no further filtering, just return the full source here + source + } else { + source.filter { filter(it, text) } + } + + action?.invoke(results) + } + + return object : Cancellable { + override val isCancelled: Boolean + get() = future.isDone + + override fun cancel() { + action = null + future.cancel(true) + } + } + } + + // if title contains, + // if description contains, + // if tags contains + // if artifacts contains, + private fun filter(sample: Sample, text: String): Boolean { + return sample.javaClassName.contains(text, true) + || sample.title.contains(text, true) + || sample.description.contains(text, true) + || filterTags(sample.tags, text) + || filterArtifacts(sample.artifacts, text) + } + + private fun filterTags(tags: List, text: String): Boolean { + return tags.firstOrNull { it.contains(text, true) } != null + } + + private fun filterArtifacts(artifacts: List, text: String): Boolean { + return artifacts.firstOrNull { it.artifactName().contains(text, true) } != null + } +} \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/sample/SampleSearch.kt b/app-sample/src/main/java/io/noties/markwon/app/sample/SampleSearch.kt new file mode 100644 index 00000000..2cc7a26e --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/sample/SampleSearch.kt @@ -0,0 +1,13 @@ +package io.noties.markwon.app.sample + +import io.noties.markwon.sample.annotations.MarkwonArtifact + +sealed class SampleSearch(val text: String?) { + class Artifact(text: String?, val artifact: MarkwonArtifact) : SampleSearch(text) + class Tag(text: String?, val tag: String) : SampleSearch(text) + class All(text: String?) : SampleSearch(text) + + override fun toString(): String { + return "SampleSearch(text=$text,type=${javaClass.simpleName})" + } +} \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/sample/Tags.kt b/app-sample/src/main/java/io/noties/markwon/app/sample/Tags.kt new file mode 100644 index 00000000..3c3d9f51 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/sample/Tags.kt @@ -0,0 +1,37 @@ +package io.noties.markwon.app.sample + +object Tags { + const val basics = "basics" + const val toast = "toast" + const val hack = "hack" + const val parsing = "parsing" + const val block = "block" + const val movementMethod = "movement-method" + const val links = "links" + const val plugin = "plugin" + const val recyclerView = "recycler-view" + const val paragraph = "paragraph" + const val rendering = "rendering" + const val style = "style" + const val theme = "theme" + const val image = "image" + const val newLine = "new-line" + const val softBreak = "soft-break" + const val defaults = "defaults" + const val spacing = "spacing" + const val padding = "padding" + const val heading = "heading" + const val anchor = "anchor" + const val lists = "lists" + const val extension = "extension" + const val textAddedListener = "text-added-listener" + const val editor = "editor" + const val span = "span" + const val svg = "SVG" + const val gif = "GIF" + const val inline = "inline" + const val html = "HTML" + const val knownBug = "known-bug" + const val precomputedText = "precomputed-text" + const val cache = "cache" +} \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/sample/ui/MarkwonRecyclerViewSample.kt b/app-sample/src/main/java/io/noties/markwon/app/sample/ui/MarkwonRecyclerViewSample.kt new file mode 100644 index 00000000..9defb91b --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/sample/ui/MarkwonRecyclerViewSample.kt @@ -0,0 +1,23 @@ +package io.noties.markwon.app.sample.ui + +import android.content.Context +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import io.noties.markwon.app.R + +abstract class MarkwonRecyclerViewSample : MarkwonSample() { + + protected lateinit var context: Context + protected lateinit var recyclerView: RecyclerView + + override fun onViewCreated(view: View) { + context = view.context + recyclerView = view.findViewById(R.id.recycler_view) + render() + } + + override val layoutResId: Int + get() = R.layout.sample_recycler_view + + abstract fun render() +} \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/sample/ui/MarkwonSample.kt b/app-sample/src/main/java/io/noties/markwon/app/sample/ui/MarkwonSample.kt new file mode 100644 index 00000000..522acac1 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/sample/ui/MarkwonSample.kt @@ -0,0 +1,16 @@ +package io.noties.markwon.app.sample.ui + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup + +abstract class MarkwonSample { + + fun createView(inflater: LayoutInflater, container: ViewGroup?): View { + return inflater.inflate(layoutResId, container, false) + } + + abstract fun onViewCreated(view: View) + + protected abstract val layoutResId: Int +} \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/sample/ui/MarkwonTextViewSample.kt b/app-sample/src/main/java/io/noties/markwon/app/sample/ui/MarkwonTextViewSample.kt new file mode 100644 index 00000000..2594abd7 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/sample/ui/MarkwonTextViewSample.kt @@ -0,0 +1,26 @@ +package io.noties.markwon.app.sample.ui + +import android.content.Context +import android.view.View +import android.widget.ScrollView +import android.widget.TextView + +import io.noties.markwon.app.R + +abstract class MarkwonTextViewSample : MarkwonSample() { + + protected lateinit var context: Context + protected lateinit var scrollView: ScrollView + protected lateinit var textView: TextView + + override val layoutResId: Int = R.layout.sample_text_view + + override fun onViewCreated(view: View) { + context = view.context + scrollView = view.findViewById(R.id.scroll_view) + textView = view.findViewById(R.id.text_view) + render() + } + + abstract fun render() +} \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/sample/ui/SampleCodeFragment.kt b/app-sample/src/main/java/io/noties/markwon/app/sample/ui/SampleCodeFragment.kt new file mode 100644 index 00000000..a827f53b --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/sample/ui/SampleCodeFragment.kt @@ -0,0 +1,75 @@ +package io.noties.markwon.app.sample.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.fragment.app.Fragment +import io.noties.markwon.app.App +import io.noties.markwon.app.R +import io.noties.markwon.app.sample.Sample +import io.noties.markwon.app.utils.hidden +import io.noties.markwon.app.utils.readCode +import io.noties.markwon.syntax.Prism4jSyntaxHighlight +import io.noties.markwon.syntax.Prism4jThemeDefault +import io.noties.prism4j.Prism4j +import io.noties.prism4j.annotations.PrismBundle + +@PrismBundle(include = ["java", "kotlin"], grammarLocatorClassName = ".GrammarLocatorSourceCode") +class SampleCodeFragment : Fragment() { + + private lateinit var progressBar: View + private lateinit var textView: TextView + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.fragment_sample_code, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + progressBar = view.findViewById(R.id.progress_bar) + textView = view.findViewById(R.id.text_view) + + load() + } + + private fun load() { + App.executorService.submit { + val code = sample.readCode(requireContext()) + val prism = Prism4j(GrammarLocatorSourceCode()) + val highlight = Prism4jSyntaxHighlight.create(prism, Prism4jThemeDefault.create(0)) + val language = when (code.language) { + Sample.Language.KOTLIN -> "kotlin" + Sample.Language.JAVA -> "java" + } + val text = highlight.highlight(language, code.sourceCode) + + textView.post { + //attached + if (context != null) { + progressBar.hidden = true + textView.text = text + } + } + } + } + + private val sample: Sample by lazy(LazyThreadSafetyMode.NONE) { + val temp: Sample = (arguments!!.getParcelable(ARG_SAMPLE))!! + temp + } + + companion object { + private const val ARG_SAMPLE = "arg.Sample" + + fun init(sample: Sample): SampleCodeFragment { + return SampleCodeFragment().apply { + arguments = Bundle().apply { + putParcelable(ARG_SAMPLE, sample) + } + } + } + } +} \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/sample/ui/SampleFragment.kt b/app-sample/src/main/java/io/noties/markwon/app/sample/ui/SampleFragment.kt new file mode 100644 index 00000000..0f4f61e0 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/sample/ui/SampleFragment.kt @@ -0,0 +1,135 @@ +package io.noties.markwon.app.sample.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentTransaction +import io.noties.markwon.app.R +import io.noties.markwon.app.sample.Sample +import io.noties.markwon.app.utils.active + +class SampleFragment : Fragment() { + + private lateinit var container: ViewGroup + + private var isCodeSelected = false + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.fragment_sample, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + container = view.findViewById(R.id.container) + isCodeSelected = savedInstanceState?.getBoolean(KEY_CODE_SELECTED) ?: false + + initAppBar(view) + initTabBar(view) + } + + private fun initAppBar(view: View) { + val appBar: View = view.findViewById(R.id.app_bar) + val icon: View = appBar.findViewById(R.id.app_bar_icon) + val title: TextView = appBar.findViewById(R.id.app_bar_title) + + icon.setOnClickListener { + activity?.onBackPressed() + } + + title.text = sample.title + } + + private fun initTabBar(view: View) { + val tabBar: View = view.findViewById(R.id.tab_bar) + val preview: View = tabBar.findViewById(R.id.tab_bar_preview) + val code: View = tabBar.findViewById(R.id.tab_bar_code) + + preview.setOnClickListener { + if (!it.active) { + it.active = true + code.active = false + showPreview() + } + } + + code.setOnClickListener { + if (!it.active) { + it.active = true + preview.active = false + showCode() + } + } + + // maybe check state (in case of restoration) + + // initial values + preview.active = !isCodeSelected + code.active = isCodeSelected + + if (isCodeSelected) { + showCode() + } else { + showPreview() + } + } + + private fun showPreview() { + isCodeSelected = false + showFragment(TAG_PREVIEW, TAG_CODE) { SamplePreviewFragment.init(sample) } + } + + private fun showCode() { + isCodeSelected = true + showFragment(TAG_CODE, TAG_PREVIEW) { SampleCodeFragment.init(sample) } + } + + private fun showFragment(showTag: String, hideTag: String, provider: () -> Fragment) { + val manager = childFragmentManager + manager.beginTransaction().apply { + setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN) + + val existing = manager.findFragmentByTag(showTag) + if (existing != null) { + show(existing) + } else { + add(container.id, provider(), showTag) + } + + manager.findFragmentByTag(hideTag)?.also { + hide(it) + } + + commitAllowingStateLoss() + } + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + + outState.putBoolean(KEY_CODE_SELECTED, isCodeSelected) + } + + private val sample: Sample by lazy(LazyThreadSafetyMode.NONE) { + val temp: Sample = (arguments!!.getParcelable(ARG_SAMPLE))!! + temp + } + + companion object { + private const val ARG_SAMPLE = "arg.Sample" + private const val TAG_PREVIEW = "tag.Preview" + private const val TAG_CODE = "tag.Code" + private const val KEY_CODE_SELECTED = "key.Selected" + + fun init(sample: Sample): SampleFragment { + val fragment = SampleFragment() + fragment.arguments = Bundle().apply { + putParcelable(ARG_SAMPLE, sample) + } + return fragment + } + } +} \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/sample/ui/SampleListFragment.kt b/app-sample/src/main/java/io/noties/markwon/app/sample/ui/SampleListFragment.kt new file mode 100644 index 00000000..b9a753db --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/sample/ui/SampleListFragment.kt @@ -0,0 +1,459 @@ +package io.noties.markwon.app.sample.ui + +import android.app.AlertDialog +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.os.Parcelable +import android.text.TextUtils +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.Window +import android.widget.ImageView +import android.widget.TextView +import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.DefaultItemAnimator +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import io.noties.adapt.Adapt +import io.noties.adapt.DiffUtilDataSetChanged +import io.noties.adapt.Item +import io.noties.debug.Debug +import io.noties.markwon.Markwon +import io.noties.markwon.app.App +import io.noties.markwon.app.BuildConfig +import io.noties.markwon.app.R +import io.noties.markwon.app.readme.ReadMeActivity +import io.noties.markwon.app.sample.Sample +import io.noties.markwon.app.sample.SampleManager +import io.noties.markwon.app.sample.SampleSearch +import io.noties.markwon.app.sample.ui.adapt.CheckForUpdateItem +import io.noties.markwon.app.sample.ui.adapt.SampleItem +import io.noties.markwon.app.sample.ui.adapt.VersionItem +import io.noties.markwon.app.utils.Cancellable +import io.noties.markwon.app.utils.UpdateUtils +import io.noties.markwon.app.utils.displayName +import io.noties.markwon.app.utils.hidden +import io.noties.markwon.app.utils.onPreDraw +import io.noties.markwon.app.utils.recyclerView +import io.noties.markwon.app.utils.stackTraceString +import io.noties.markwon.app.utils.tagDisplayName +import io.noties.markwon.app.widget.SearchBar +import io.noties.markwon.movement.MovementMethodPlugin +import io.noties.markwon.sample.annotations.MarkwonArtifact +import kotlinx.android.parcel.Parcelize + +class SampleListFragment : Fragment() { + + private val adapt: Adapt = Adapt.create(DiffUtilDataSetChanged.create()) + private lateinit var markwon: Markwon + + private val type: Type by lazy(LazyThreadSafetyMode.NONE) { + parseType(arguments!!) + } + + private var search: String? = null + + // postpone state restoration + private var pendingRecyclerScrollPosition: RecyclerScrollPosition? = null + + private var cancellable: Cancellable? = null + private var checkForUpdateCancellable: Cancellable? = null + + private lateinit var progressBar: View + + private val versionItem: VersionItem by lazy(LazyThreadSafetyMode.NONE) { + VersionItem() + } + + private val sampleManager: SampleManager + get() = App.sampleManager + + override fun onAttach(context: Context) { + super.onAttach(context) + + context.also { + markwon = markwon(it) + } + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.fragment_sample_list, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + initAppBar(view) + + val context = requireContext() + + progressBar = view.findViewById(R.id.progress_bar) + + val searchBar: SearchBar = view.findViewById(R.id.search_bar) + searchBar.onSearchListener = { + search = it + fetch() + } + + val recyclerView: RecyclerView = view.findViewById(R.id.recycler_view) + recyclerView.layoutManager = LinearLayoutManager(context) + recyclerView.itemAnimator = DefaultItemAnimator() + recyclerView.setHasFixedSize(true) + recyclerView.adapter = adapt + +// // additional padding for RecyclerView + // greatly complicates state restoration (items jump and a lot of times state cannot be + // even restored (layout manager scrolls to top item and that's it) +// searchBar.onPreDraw { +// recyclerView.setPadding( +// recyclerView.paddingLeft, +// recyclerView.paddingTop + searchBar.height, +// recyclerView.paddingRight, +// recyclerView.paddingBottom +// ) +// } + + val state: State? = arguments?.getParcelable(STATE) + val initialSearch = arguments?.getString(ARG_SEARCH) + + // clear it anyway + arguments?.remove(ARG_SEARCH) + + Debug.i(state, initialSearch) + + pendingRecyclerScrollPosition = state?.recyclerScrollPosition + + val search = listOf(state?.search, initialSearch) + .firstOrNull { it != null } + + if (search != null) { + searchBar.search(search) + } else { + fetch() + } + } + + override fun onDestroyView() { + + val state = State( + search, + adapt.recyclerView?.scrollPosition + ) + Debug.i(state) + arguments?.putParcelable(STATE, state) + + val cancellable = this.cancellable + if (cancellable != null && !cancellable.isCancelled) { + cancellable.cancel() + this.cancellable = null + } + super.onDestroyView() + } + + // not called? yeah, whatever +// override fun onSaveInstanceState(outState: Bundle) { +// super.onSaveInstanceState(outState) +// +// val state = State( +// search, +// adapt.recyclerView?.scrollPosition +// ) +// Debug.i(state) +// outState.putParcelable(STATE, state) +// } + + private fun initAppBar(view: View) { + val appBar = view.findViewById(R.id.app_bar) + + val appBarIcon: ImageView = appBar.findViewById(R.id.app_bar_icon) + val appBarTitle: TextView = appBar.findViewById(R.id.app_bar_title) + val appBarIconReadme: ImageView = appBar.findViewById(R.id.app_bar_icon_readme) + + val isInitialScreen = fragmentManager?.backStackEntryCount == 0 + + appBarIcon.hidden = isInitialScreen + appBarIconReadme.hidden = !isInitialScreen + + val type = this.type + + val (text, background) = when (type) { + is Type.Artifact -> Pair(type.artifact.displayName, R.drawable.bg_artifact) + is Type.Tag -> Pair(type.tag.tagDisplayName, R.drawable.bg_tag) + is Type.All -> Pair(resources.getString(R.string.app_name), 0) + } + + appBarTitle.text = text + + if (background != 0) { + appBarTitle.setBackgroundResource(background) + } + + if (isInitialScreen) { + appBarIconReadme.setOnClickListener { + context?.let { + val intent = ReadMeActivity.makeIntent(it) + it.startActivity(intent) + } + } + } else { + appBarIcon.setImageResource(R.drawable.ic_arrow_back_white_24dp) + appBarIcon.setOnClickListener { + requireActivity().onBackPressed() + } + } + } + + private fun bindSamples(samples: List, addVersion: Boolean) { + + val items: List> = samples + .map { + SampleItem( + markwon, + it, + { artifact -> openArtifact(artifact) }, + { tag -> openTag(tag) }, + { sample -> openSample(sample) } + ) + } + .let { + if (addVersion) { + val list: List> = it + list.toMutableList().apply { + add(0, CheckForUpdateItem(this@SampleListFragment::checkForUpdate)) + add(0, versionItem) + } + } else { + it + } + } + + adapt.setItems(items) + + val recyclerView = adapt.recyclerView ?: return + + val scrollPosition = pendingRecyclerScrollPosition + + Debug.i(scrollPosition) + + if (scrollPosition != null) { + pendingRecyclerScrollPosition = null + recyclerView.onPreDraw { + (recyclerView.layoutManager as? LinearLayoutManager) + ?.scrollToPositionWithOffset(scrollPosition.position, scrollPosition.offset) + } + } else { + recyclerView.onPreDraw { + recyclerView.scrollToPosition(0) + } + } + } + + private fun checkForUpdate() { + val current = checkForUpdateCancellable + if (current != null && !current.isCancelled) { + return + } + + progressBar.hidden = false + checkForUpdateCancellable = UpdateUtils.checkForUpdate { result -> + progressBar.post { + processUpdateResult(result) + } + } + } + + private fun processUpdateResult(result: UpdateUtils.Result) { + val context = context ?: return + + progressBar.hidden = true + + val builder = AlertDialog.Builder(context) + + when (result) { + is UpdateUtils.Result.UpdateAvailable -> { + val md = """ + ## Update available + Would you like to download it? + """.trimIndent() + builder.setMessage(markwon.toMarkdown(md)) + builder.setNegativeButton(android.R.string.cancel, null) + builder.setPositiveButton("Download") { _, _ -> + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(result.url)) + startActivity(Intent.createChooser(intent, null)) + } + } + + is UpdateUtils.Result.NoUpdate -> { + val md = """ + ## No update + You are using latest version (${BuildConfig.GIT_SHA}) + """.trimIndent() + builder.setMessage(markwon.toMarkdown(md)) + builder.setPositiveButton(android.R.string.ok, null) + } + + is UpdateUtils.Result.Error -> { + // trimIndent is confused by tabs in stack trace + val md = """ +## Error +``` +${result.throwable.stackTraceString()} +``` +""" + builder.setMessage(markwon.toMarkdown(md)) + builder.setPositiveButton(android.R.string.ok, null) + } + } + + builder.show() + } + + private fun openArtifact(artifact: MarkwonArtifact) { + Debug.i(artifact) + openResultFragment(init(artifact)) + } + + private fun openTag(tag: String) { + Debug.i(tag) + openResultFragment(init(tag)) + } + + private fun openResultFragment(fragment: SampleListFragment) { + openFragment(fragment) + } + + private fun openSample(sample: Sample) { + openFragment(SampleFragment.init(sample)) + } + + private fun openFragment(fragment: Fragment) { + fragmentManager!!.beginTransaction() + .setCustomAnimations(R.anim.screen_in, R.anim.screen_out, R.anim.screen_in_pop, R.anim.screen_out_pop) + .replace(Window.ID_ANDROID_CONTENT, fragment) + .addToBackStack(null) + .commitAllowingStateLoss() + } + + private fun fetch() { + + val sampleSearch: SampleSearch = when (val type = this.type) { + is Type.Artifact -> SampleSearch.Artifact(search, type.artifact) + is Type.Tag -> SampleSearch.Tag(search, type.tag) + else -> SampleSearch.All(search) + } + + Debug.i(sampleSearch) + + // clear current + cancellable?.let { + if (!it.isCancelled) { + it.cancel() + } + } + + cancellable = sampleManager.samples(sampleSearch) { + val addVersion = sampleSearch is SampleSearch.All && TextUtils.isEmpty(sampleSearch.text) + bindSamples(it, addVersion) + } + } + + companion object { + private const val ARG_ARTIFACT = "arg.Artifact" + private const val ARG_TAG = "arg.Tag" + private const val ARG_SEARCH = "arg.Search" + private const val STATE = "key.State" + + fun init(): SampleListFragment { + val fragment = SampleListFragment() + fragment.arguments = Bundle() + return fragment + } + + fun init(artifact: MarkwonArtifact): SampleListFragment { + val fragment = SampleListFragment() + fragment.arguments = Bundle().apply { + putString(ARG_ARTIFACT, artifact.name) + } + return fragment + } + + fun init(tag: String): SampleListFragment { + val fragment = SampleListFragment() + fragment.arguments = Bundle().apply { + putString(ARG_TAG, tag) + } + return fragment + } + + fun init(search: SampleSearch): SampleListFragment { + val fragment = SampleListFragment() + fragment.arguments = Bundle().apply { + + when (search) { + is SampleSearch.Artifact -> putString(ARG_ARTIFACT, search.artifact.name) + is SampleSearch.Tag -> putString(ARG_TAG, search.tag) + } + + val query = search.text + if (query != null) { + putString(ARG_SEARCH, query) + } + } + return fragment + } + + fun markwon(context: Context): Markwon { + return Markwon.builder(context) + .usePlugin(MovementMethodPlugin.none()) + .build() + } + + private fun parseType(arguments: Bundle): Type { + val name = arguments.getString(ARG_ARTIFACT) + val tag = arguments.getString(ARG_TAG) + return when { + name != null -> Type.Artifact(MarkwonArtifact.valueOf(name)) + tag != null -> Type.Tag(tag) + else -> Type.All + } + } + } + + @Parcelize + private data class State( + val search: String?, + val recyclerScrollPosition: RecyclerScrollPosition? + ) : Parcelable + + @Parcelize + private data class RecyclerScrollPosition( + val position: Int, + val offset: Int + ) : Parcelable + + private val RecyclerView.scrollPosition: RecyclerScrollPosition? + get() { + val holder = findFirstVisibleViewHolder() ?: return null + val position = holder.adapterPosition + val offset = holder.itemView.top + return RecyclerScrollPosition(position, offset) + } + + // because findViewHolderForLayoutPosition doesn't work :'( + private fun RecyclerView.findFirstVisibleViewHolder(): RecyclerView.ViewHolder? { + if (childCount > 0) { + val child = getChildAt(0) + return findContainingViewHolder(child) + } + return null + } + + private sealed class Type { + class Artifact(val artifact: MarkwonArtifact) : Type() + class Tag(val tag: String) : Type() + object All : Type() + } +} \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/sample/ui/SamplePreviewFragment.kt b/app-sample/src/main/java/io/noties/markwon/app/sample/ui/SamplePreviewFragment.kt new file mode 100644 index 00000000..24e12c9f --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/sample/ui/SamplePreviewFragment.kt @@ -0,0 +1,38 @@ +package io.noties.markwon.app.sample.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import io.noties.markwon.app.sample.Sample + +class SamplePreviewFragment : Fragment() { + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return markwonSample.createView(inflater, container) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + markwonSample.onViewCreated(view) + } + + private val markwonSample: MarkwonSample by lazy(LazyThreadSafetyMode.NONE) { + val sample: Sample = arguments!!.getParcelable(ARG_SAMPLE)!! + Class.forName(sample.javaClassName).newInstance() as MarkwonSample + } + + companion object { + private const val ARG_SAMPLE = "arg.Sample" + + fun init(sample: Sample): SamplePreviewFragment { + return SamplePreviewFragment().apply { + arguments = Bundle().apply { + putParcelable(ARG_SAMPLE, sample) + } + } + } + } +} \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/sample/ui/adapt/CheckForUpdateItem.kt b/app-sample/src/main/java/io/noties/markwon/app/sample/ui/adapt/CheckForUpdateItem.kt new file mode 100644 index 00000000..562aa123 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/sample/ui/adapt/CheckForUpdateItem.kt @@ -0,0 +1,22 @@ +package io.noties.markwon.app.sample.ui.adapt + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import io.noties.adapt.Item +import io.noties.markwon.app.R + +class CheckForUpdateItem(private val action: () -> Unit) : Item(43L) { + + override fun createHolder(inflater: LayoutInflater, parent: ViewGroup): Holder { + return Holder(inflater.inflate(R.layout.adapt_check_for_update, parent, false)) + } + + override fun render(holder: Holder) { + holder.button.setOnClickListener { action() } + } + + class Holder(view: View) : Item.Holder(view) { + val button: View = requireView(R.id.button) + } +} \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/sample/ui/adapt/SampleItem.kt b/app-sample/src/main/java/io/noties/markwon/app/sample/ui/adapt/SampleItem.kt new file mode 100644 index 00000000..8c6eb5e5 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/sample/ui/adapt/SampleItem.kt @@ -0,0 +1,115 @@ +package io.noties.markwon.app.sample.ui.adapt + +import android.text.Spanned +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import io.noties.adapt.Item +import io.noties.markwon.Markwon +import io.noties.markwon.app.R +import io.noties.markwon.app.sample.Sample +import io.noties.markwon.app.utils.displayName +import io.noties.markwon.app.utils.hidden +import io.noties.markwon.app.utils.tagDisplayName +import io.noties.markwon.app.widget.FlowLayout +import io.noties.markwon.sample.annotations.MarkwonArtifact +import io.noties.markwon.utils.NoCopySpannableFactory + +class SampleItem( + private val markwon: Markwon, + private val sample: Sample, + private val onArtifactClick: (MarkwonArtifact) -> Unit, + private val onTagClick: (String) -> Unit, + private val onSampleClick: (Sample) -> Unit +) : Item(sample.id.hashCode().toLong()) { + +// var search: String? = null + + private val text: Spanned by lazy(LazyThreadSafetyMode.NONE) { + markwon.toMarkdown(sample.description) + } + + override fun createHolder(inflater: LayoutInflater, parent: ViewGroup): Holder { + return Holder(inflater.inflate(R.layout.adapt_sample, parent, false)).apply { + description.setSpannableFactory(NoCopySpannableFactory.getInstance()) + } + } + + override fun render(holder: Holder) { + holder.apply { + title.text = sample.title + + val text = this@SampleItem.text + if (text.isEmpty()) { + description.text = "" + description.hidden = true + } else { + markwon.setParsedMarkdown(description, text) + description.hidden = false + } + + // there is no need to display the core artifact (it is implicit), + // hide if empty (removed core) + artifacts.ensure(sample.artifacts.size, R.layout.view_artifact) + .zip(sample.artifacts) + .forEach { (view, artifact) -> + (view as TextView).text = artifact.displayName + view.setOnClickListener { + onArtifactClick(artifact) + } + } + + tags.ensure(sample.tags.size, R.layout.view_tag) + .zip(sample.tags) + .forEach { (view, tag) -> + (view as TextView).text = tag.tagDisplayName + view.setOnClickListener { + onTagClick(tag) + } + } + + itemView.setOnClickListener { + onSampleClick(sample) + } + } + } + + class Holder(itemView: View) : Item.Holder(itemView) { + val title: TextView = requireView(R.id.title) + val description: TextView = requireView(R.id.description) + val artifacts: FlowLayout = requireView(R.id.artifacts) + val tags: FlowLayout = requireView(R.id.tags) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SampleItem + + if (sample != other.sample) return false + + return true + } + + override fun hashCode(): Int { + return sample.hashCode() + } +} + +private fun FlowLayout.ensure(viewsCount: Int, layoutResId: Int): List { + if (viewsCount > childCount) { + // inflate new views + val inflater = LayoutInflater.from(context) + for (i in 0 until (viewsCount - childCount)) { + addView(inflater.inflate(layoutResId, this, false)) + } + } else { + // return requested vies and GONE the rest + for (i in viewsCount until childCount) { + getChildAt(i).hidden = true + } + } + return (0 until viewsCount).map { getChildAt(it) } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/sample/ui/adapt/VersionItem.kt b/app-sample/src/main/java/io/noties/markwon/app/sample/ui/adapt/VersionItem.kt new file mode 100644 index 00000000..6c090714 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/sample/ui/adapt/VersionItem.kt @@ -0,0 +1,84 @@ +package io.noties.markwon.app.sample.ui.adapt + +import android.content.Context +import android.text.Spanned +import android.text.TextPaint +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import io.noties.adapt.Item +import io.noties.markwon.AbstractMarkwonPlugin +import io.noties.markwon.LinkResolver +import io.noties.markwon.Markwon +import io.noties.markwon.MarkwonSpansFactory +import io.noties.markwon.app.BuildConfig +import io.noties.markwon.app.R +import io.noties.markwon.core.CoreProps +import io.noties.markwon.core.MarkwonTheme +import io.noties.markwon.core.spans.LinkSpan +import io.noties.markwon.html.HtmlPlugin +import io.noties.markwon.image.ImagesPlugin +import io.noties.markwon.movement.MovementMethodPlugin +import org.commonmark.node.Link + +class VersionItem : Item(42L) { + + private lateinit var context: Context + + private val markwon: Markwon by lazy(LazyThreadSafetyMode.NONE) { + Markwon.builder(context) + .usePlugin(ImagesPlugin.create()) + .usePlugin(MovementMethodPlugin.link()) + .usePlugin(HtmlPlugin.create()) + .usePlugin(object : AbstractMarkwonPlugin() { + override fun configureSpansFactory(builder: MarkwonSpansFactory.Builder) { + builder.setFactory(Link::class.java) { configuration, props -> + LinkSpanNoUnderline( + configuration.theme(), + CoreProps.LINK_DESTINATION.require(props), + configuration.linkResolver() + ) + } + } + }) + .build() + } + + private val text: Spanned by lazy(LazyThreadSafetyMode.NONE) { + val md = """ + + + + ![stable](https://img.shields.io/maven-central/v/io.noties.markwon/core.svg?label=stable) + ![snapshot](https://img.shields.io/nexus/s/https/oss.sonatype.org/io.noties.markwon/core.svg?label=snapshot) + ![changelog](https://fonts.gstatic.com/s/i/materialicons/open_in_browser/v6/24px.svg?download=true) + + """.trimIndent() + markwon.toMarkdown(md) + } + + override fun createHolder(inflater: LayoutInflater, parent: ViewGroup): Holder { + context = parent.context + return Holder(inflater.inflate(R.layout.adapt_version, parent, false)) + } + + override fun render(holder: Holder) { + markwon.setParsedMarkdown(holder.textView, text) + } + + class Holder(view: View) : Item.Holder(view) { + val textView: TextView = requireView(R.id.text_view) + } + + class LinkSpanNoUnderline( + theme: MarkwonTheme, + destination: String, + resolver: LinkResolver + ) : LinkSpan(theme, destination, resolver) { + override fun updateDrawState(ds: TextPaint) { + super.updateDrawState(ds) + ds.isUnderlineText = false + } + } +} \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/.editorconfig b/app-sample/src/main/java/io/noties/markwon/app/samples/.editorconfig new file mode 100644 index 00000000..90da45d5 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/.editorconfig @@ -0,0 +1,3 @@ +[{*.java, *.kt}] +indent_style = space +indent_size = 2 \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/AdditionalSpacingSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/AdditionalSpacingSample.java new file mode 100644 index 00000000..471765e2 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/AdditionalSpacingSample.java @@ -0,0 +1,51 @@ +package io.noties.markwon.app.samples; + +import androidx.annotation.NonNull; + +import org.commonmark.node.Heading; + +import io.noties.markwon.AbstractMarkwonPlugin; +import io.noties.markwon.Markwon; +import io.noties.markwon.MarkwonSpansFactory; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.core.MarkwonTheme; +import io.noties.markwon.core.spans.LastLineSpacingSpan; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200629125321", + title = "Additional spacing after block", + description = "Add additional spacing (padding) after last line of a block", + artifacts = MarkwonArtifact.CORE, + tags = {Tags.spacing, Tags.padding, Tags.span} +) +public class AdditionalSpacingSample extends MarkwonTextViewSample { + @Override + public void render() { + final String md = "" + + "# Title title title title title title title title title title \n\ntext text text text"; + + // please note that bottom line (after 1 & 2 levels) will be drawn _AFTER_ padding + final int spacing = (int) (128 * context.getResources().getDisplayMetrics().density + .5F); + + final Markwon markwon = Markwon.builder(context) + .usePlugin(new AbstractMarkwonPlugin() { + @Override + public void configureTheme(@NonNull MarkwonTheme.Builder builder) { + builder.headingBreakHeight(0); + } + + @Override + public void configureSpansFactory(@NonNull MarkwonSpansFactory.Builder builder) { + builder.appendFactory( + Heading.class, + (configuration, props) -> new LastLineSpacingSpan(spacing)); + } + }) + .build(); + + markwon.setMarkdown(textView, md); + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/AllBlocksNoForcedNewLineSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/AllBlocksNoForcedNewLineSample.java new file mode 100644 index 00000000..c5f25a2b --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/AllBlocksNoForcedNewLineSample.java @@ -0,0 +1,59 @@ +package io.noties.markwon.app.samples; + +import androidx.annotation.NonNull; + +import org.commonmark.node.Node; + +import io.noties.markwon.AbstractMarkwonPlugin; +import io.noties.markwon.BlockHandlerDef; +import io.noties.markwon.Markwon; +import io.noties.markwon.MarkwonVisitor; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200629130227", + title = "All blocks no padding", + description = "Do not render new lines (padding) after all blocks", + artifacts = MarkwonArtifact.CORE, + tags = {Tags.block, Tags.spacing, Tags.padding, Tags.rendering} +) +public class AllBlocksNoForcedNewLineSample extends MarkwonTextViewSample { + @Override + public void render() { + final String md = "" + + "# Hello there!\n\n" + + "* a first\n" + + "* second\n" + + "- third\n" + + "* * nested one\n\n" + + "> block quote\n\n" + + "> > and nested one\n\n" + + "```java\n" + + "final int i = 0;\n" + + "```\n\n"; + + // extend default block handler + final MarkwonVisitor.BlockHandler blockHandler = new BlockHandlerDef() { + @Override + public void blockEnd(@NonNull MarkwonVisitor visitor, @NonNull Node node) { + if (visitor.hasNext(node)) { + visitor.ensureNewLine(); + } + } + }; + + final Markwon markwon = Markwon.builder(context) + .usePlugin(new AbstractMarkwonPlugin() { + @Override + public void configureVisitor(@NonNull MarkwonVisitor.Builder builder) { + builder.blockHandler(blockHandler); + } + }) + .build(); + + markwon.setMarkdown(textView, md); + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/BlockHandlerSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/BlockHandlerSample.java new file mode 100644 index 00000000..d9e78d04 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/BlockHandlerSample.java @@ -0,0 +1,66 @@ +package io.noties.markwon.app.samples; + +import androidx.annotation.NonNull; + +import org.commonmark.node.Node; + +import io.noties.markwon.AbstractMarkwonPlugin; +import io.noties.markwon.Markwon; +import io.noties.markwon.MarkwonVisitor; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200729090524", + title = "Block handler", + description = "Custom block delimiters that control new lines after block nodes", + artifacts = MarkwonArtifact.CORE, + tags = Tags.rendering +) +public class BlockHandlerSample extends MarkwonTextViewSample { + @Override + public void render() { + final String md = "" + + "# Heading\n" + + "* one\n" + + "* two\n" + + "* three\n" + + "---\n" + + "> a quote\n\n" + + "```\n" + + "code\n" + + "```\n" + + "some text after"; + + final Markwon markwon = Markwon.builder(context) + .usePlugin(new AbstractMarkwonPlugin() { + @Override + public void configureVisitor(@NonNull MarkwonVisitor.Builder builder) { + builder.blockHandler(new BlockHandlerNoAdditionalNewLines()); + } + }) + .build(); + + markwon.setMarkdown(textView, md); + } +} + +class BlockHandlerNoAdditionalNewLines implements MarkwonVisitor.BlockHandler { + + @Override + public void blockStart(@NonNull MarkwonVisitor visitor, @NonNull Node node) { + // ensure that content rendered on a new line + visitor.ensureNewLine(); + } + + @Override + public void blockEnd(@NonNull MarkwonVisitor visitor, @NonNull Node node) { + if (visitor.hasNext(node)) { + visitor.ensureNewLine(); + // by default markwon here also has: + // visitor.forceNewLine(); + } + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/CacheMarkwonSample.kt b/app-sample/src/main/java/io/noties/markwon/app/samples/CacheMarkwonSample.kt new file mode 100644 index 00000000..b30503ee --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/CacheMarkwonSample.kt @@ -0,0 +1,59 @@ +package io.noties.markwon.app.samples + +import android.content.Context +import io.noties.debug.Debug +import io.noties.markwon.Markwon +import io.noties.markwon.app.sample.Tags +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample +import io.noties.markwon.ext.strikethrough.StrikethroughPlugin +import io.noties.markwon.sample.annotations.MarkwonArtifact +import io.noties.markwon.sample.annotations.MarkwonSampleInfo +import java.util.Collections +import java.util.WeakHashMap + +@MarkwonSampleInfo( + id = "20200707102458", + title = "Cache Markwon instance", + description = "A static cache for `Markwon` instance " + + "to be associated with a `Context`", + artifacts = [MarkwonArtifact.CORE], + tags = [Tags.cache] +) +class CacheMarkwonSample : MarkwonTextViewSample() { + override fun render() { + render("# First!") + render("## Second!!") + render("### Third!!!") + } + + fun render(md: String) { + val markwon = MarkwonCache.with(context) + Debug.i("markwon: ${markwon.hashCode()}, md: '$md'") + markwon.setMarkdown(textView, md) + } +} + +object MarkwonCache { + private val cache = Collections.synchronizedMap(WeakHashMap()) + + fun with(context: Context): Markwon { + // yeah, why work as expected? new value is returned each time, no caching occur + // kotlin: 1.3.72 + // intellij plugin: 1.3.72-release-Studio4.0-5 +// return cache.getOrPut(context) { +// // create your markwon instance here +// return Markwon.builder(context) +// .usePlugin(StrikethroughPlugin.create()) +// .build() +// } + + return cache[context] ?: { + Markwon.builder(context) + .usePlugin(StrikethroughPlugin.create()) + .build() + .also { + cache[context] = it + } + }.invoke() + } +} \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/CustomExtensionSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/CustomExtensionSample.java new file mode 100644 index 00000000..fe47a642 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/CustomExtensionSample.java @@ -0,0 +1,436 @@ +package io.noties.markwon.app.samples; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.text.TextUtils; +import android.text.style.ReplacementSpan; + +import androidx.annotation.DrawableRes; +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.commonmark.node.CustomNode; +import org.commonmark.node.Delimited; +import org.commonmark.node.Node; +import org.commonmark.node.Text; +import org.commonmark.parser.Parser; +import org.commonmark.parser.delimiter.DelimiterProcessor; +import org.commonmark.parser.delimiter.DelimiterRun; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import io.noties.markwon.AbstractMarkwonPlugin; +import io.noties.markwon.Markwon; +import io.noties.markwon.MarkwonVisitor; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200629163248", + title = "Custom extension", + description = "Custom extension that adds an " + + "icon from resources and renders it as image with " + + "`@ic-name` syntax", + artifacts = MarkwonArtifact.CORE, + tags = {Tags.parsing, Tags.rendering, Tags.plugin, Tags.image, Tags.extension, Tags.span} +) +public class CustomExtensionSample extends MarkwonTextViewSample { + @Override + public void render() { + final String md = "" + + "# Hello! @ic-android-black-24\n\n" + + "" + + "Home 36 black: @ic-home-black-36\n\n" + + "" + + "Memory 48 black: @ic-memory-black-48\n\n" + + "" + + "### I AM ANOTHER HEADER\n\n" + + "" + + "Sentiment Satisfied 64 red: @ic-sentiment_satisfied-red-64" + + ""; + + // note that we haven't registered CorePlugin, as it's the only one that can be + // implicitly deducted and added automatically. All other plugins require explicit + // `usePlugin` call + final Markwon markwon = Markwon.builder(context) + .usePlugin(IconPlugin.create(IconSpanProvider.create(context, 0))) + .build(); + + markwon.setMarkdown(textView, md); + } +} + +class IconPlugin extends AbstractMarkwonPlugin { + + @NonNull + public static IconPlugin create(@NonNull IconSpanProvider iconSpanProvider) { + return new IconPlugin(iconSpanProvider); + } + + private final IconSpanProvider iconSpanProvider; + + IconPlugin(@NonNull IconSpanProvider iconSpanProvider) { + this.iconSpanProvider = iconSpanProvider; + } + + @Override + public void configureParser(@NonNull Parser.Builder builder) { + builder.customDelimiterProcessor(IconProcessor.create()); + } + + @Override + public void configureVisitor(@NonNull MarkwonVisitor.Builder builder) { + builder.on(IconNode.class, (visitor, iconNode) -> { + + final String name = iconNode.name(); + final String color = iconNode.color(); + final String size = iconNode.size(); + + if (!TextUtils.isEmpty(name) + && !TextUtils.isEmpty(color) + && !TextUtils.isEmpty(size)) { + + final int length = visitor.length(); + + visitor.builder().append(name); + visitor.setSpans(length, iconSpanProvider.provide(name, color, size)); + visitor.builder().append(' '); + } + }); + } + + @NonNull + @Override + public String processMarkdown(@NonNull String markdown) { + return IconProcessor.prepare(markdown); + } +} + +abstract class IconSpanProvider { + + @SuppressWarnings("SameParameterValue") + @NonNull + public static IconSpanProvider create(@NonNull Context context, @DrawableRes int fallBack) { + return new Impl(context, fallBack); + } + + + @NonNull + public abstract IconSpan provide(@NonNull String name, @NonNull String color, @NonNull String size); + + + private static class Impl extends IconSpanProvider { + + private final Context context; + private final Resources resources; + private final int fallBack; + + Impl(@NonNull Context context, @DrawableRes int fallBack) { + this.context = context; + this.resources = context.getResources(); + this.fallBack = fallBack; + } + + @NonNull + @Override + public IconSpan provide(@NonNull String name, @NonNull String color, @NonNull String size) { + final String resName = iconName(name, color, size); + int resId = resources.getIdentifier(resName, "drawable", context.getPackageName()); + if (resId == 0) { + resId = fallBack; + } + return new IconSpan(getDrawable(resId), IconSpan.ALIGN_CENTER); + } + + + @NonNull + private static String iconName(@NonNull String name, @NonNull String color, @NonNull String size) { + return "ic_" + name + "_" + color + "_" + size + "dp"; + } + + @NonNull + private Drawable getDrawable(int resId) { + //noinspection ConstantConditions + return context.getDrawable(resId); + } + } +} + +class IconSpan extends ReplacementSpan { + + @IntDef({ALIGN_BOTTOM, ALIGN_BASELINE, ALIGN_CENTER}) + @Retention(RetentionPolicy.CLASS) + @interface Alignment { + } + + public static final int ALIGN_BOTTOM = 0; + public static final int ALIGN_BASELINE = 1; + public static final int ALIGN_CENTER = 2; // will only center if drawable height is less than text line height + + + private final Drawable drawable; + + private final int alignment; + + public IconSpan(@NonNull Drawable drawable, @Alignment int alignment) { + this.drawable = drawable; + this.alignment = alignment; + if (drawable.getBounds().isEmpty()) { + drawable.setBounds(0, 0, drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight()); + } + } + + @Override + public int getSize(@NonNull Paint paint, CharSequence text, int start, int end, @Nullable Paint.FontMetricsInt fm) { + + final Rect rect = drawable.getBounds(); + + if (fm != null) { + fm.ascent = -rect.bottom; + fm.descent = 0; + + fm.top = fm.ascent; + fm.bottom = 0; + } + + return rect.right; + } + + @Override + public void draw(@NonNull Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, @NonNull Paint paint) { + + final int b = bottom - drawable.getBounds().bottom; + + final int save = canvas.save(); + try { + final int translationY; + if (ALIGN_CENTER == alignment) { + translationY = b - ((bottom - top - drawable.getBounds().height()) / 2); + } else if (ALIGN_BASELINE == alignment) { + translationY = b - paint.getFontMetricsInt().descent; + } else { + translationY = b; + } + canvas.translate(x, translationY); + drawable.draw(canvas); + } finally { + canvas.restoreToCount(save); + } + } +} + +class IconProcessor implements DelimiterProcessor { + + @NonNull + public static IconProcessor create() { + return new IconProcessor(); + } + + // ic-home-black-24 + private static final Pattern PATTERN = Pattern.compile("ic-(\\w+)-(\\w+)-(\\d+)"); + + private static final String TO_FIND = IconNode.DELIMITER_STRING + "ic-"; + + /** + * Should be used when input string does not wrap icon definition with `@` from both ends. + * So, `@ic-home-white-24` would become `@ic-home-white-24@`. This way parsing is easier + * and more predictable (cannot specify multiple ending delimiters, as we would require them: + * space, newline, end of a document, and a lot of more) + * + * @param input to process + * @return processed string + * @see #prepare(StringBuilder) + */ + @NonNull + public static String prepare(@NonNull String input) { + final StringBuilder builder = new StringBuilder(input); + prepare(builder); + return builder.toString(); + } + + public static void prepare(@NonNull StringBuilder builder) { + + int start = builder.indexOf(TO_FIND); + int end; + + while (start > -1) { + + end = iconDefinitionEnd(start + TO_FIND.length(), builder); + + // if we match our pattern, append `@` else ignore + if (iconDefinitionValid(builder.subSequence(start + 1, end))) { + builder.insert(end, '@'); + } + + // move to next + start = builder.indexOf(TO_FIND, end); + } + } + + @Override + public char getOpeningCharacter() { + return IconNode.DELIMITER; + } + + @Override + public char getClosingCharacter() { + return IconNode.DELIMITER; + } + + @Override + public int getMinLength() { + return 1; + } + + @Override + public int getDelimiterUse(DelimiterRun opener, DelimiterRun closer) { + return opener.length() >= 1 && closer.length() >= 1 ? 1 : 0; + } + + @Override + public void process(Text opener, Text closer, int delimiterUse) { + + final IconGroupNode iconGroupNode = new IconGroupNode(); + + final Node next = opener.getNext(); + + boolean handled = false; + + // process only if we have exactly one Text node + if (next instanceof Text && next.getNext() == closer) { + + final String text = ((Text) next).getLiteral(); + + if (!TextUtils.isEmpty(text)) { + + // attempt to match + final Matcher matcher = PATTERN.matcher(text); + if (matcher.matches()) { + final IconNode iconNode = new IconNode( + matcher.group(1), + matcher.group(2), + matcher.group(3) + ); + iconGroupNode.appendChild(iconNode); + next.unlink(); + handled = true; + } + } + } + + if (!handled) { + + // restore delimiters if we didn't match + + iconGroupNode.appendChild(new Text(IconNode.DELIMITER_STRING)); + + Node node; + for (Node tmp = opener.getNext(); tmp != null && tmp != closer; tmp = node) { + node = tmp.getNext(); + // append a child anyway + iconGroupNode.appendChild(tmp); + } + + iconGroupNode.appendChild(new Text(IconNode.DELIMITER_STRING)); + } + + opener.insertBefore(iconGroupNode); + } + + private static int iconDefinitionEnd(int index, @NonNull StringBuilder builder) { + + // all spaces, new lines, non-words or digits, + + char c; + + int end = -1; + for (int i = index; i < builder.length(); i++) { + c = builder.charAt(i); + if (Character.isWhitespace(c) + || !(Character.isLetterOrDigit(c) || c == '-' || c == '_')) { + end = i; + break; + } + } + + if (end == -1) { + end = builder.length(); + } + + return end; + } + + private static boolean iconDefinitionValid(@NonNull CharSequence cs) { + final Matcher matcher = PATTERN.matcher(cs); + return matcher.matches(); + } +} + +class IconNode extends CustomNode implements Delimited { + + public static final char DELIMITER = '@'; + + public static final String DELIMITER_STRING = "" + DELIMITER; + + + private final String name; + + private final String color; + + private final String size; + + public IconNode(@NonNull String name, @NonNull String color, @NonNull String size) { + this.name = name; + this.color = color; + this.size = size; + } + + @NonNull + public String name() { + return name; + } + + @NonNull + public String color() { + return color; + } + + @NonNull + public String size() { + return size; + } + + @Override + public String getOpeningDelimiter() { + return DELIMITER_STRING; + } + + @Override + public String getClosingDelimiter() { + return DELIMITER_STRING; + } + + @Override + @NonNull + public String toString() { + return "IconNode{" + + "name='" + name + '\'' + + ", color='" + color + '\'' + + ", size='" + size + '\'' + + '}'; + } +} + +class IconGroupNode extends CustomNode { + +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/CustomizeThemeSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/CustomizeThemeSample.java new file mode 100644 index 00000000..8e624b30 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/CustomizeThemeSample.java @@ -0,0 +1,41 @@ +package io.noties.markwon.app.samples; + +import android.graphics.Color; + +import androidx.annotation.NonNull; + +import io.noties.markwon.AbstractMarkwonPlugin; +import io.noties.markwon.Markwon; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.core.MarkwonTheme; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200629123617", + title = "Customize theme", + description = "Customize `MarkwonTheme` styling", + artifacts = MarkwonArtifact.CORE, + tags = {Tags.style, Tags.theme, Tags.plugin} +) +public class CustomizeThemeSample extends MarkwonTextViewSample { + @Override + public void render() { + + final String md = "`A code` that is rendered differently\n\n```\nHello!\n```"; + + final Markwon markwon = Markwon.builder(context) + .usePlugin(new AbstractMarkwonPlugin() { + @Override + public void configureTheme(@NonNull MarkwonTheme.Builder builder) { + builder + .codeBackgroundColor(Color.BLACK) + .codeTextColor(Color.RED); + } + }) + .build(); + + markwon.setMarkdown(textView, md); + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/DelimiterProcessorSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/DelimiterProcessorSample.java new file mode 100644 index 00000000..71d3c249 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/DelimiterProcessorSample.java @@ -0,0 +1,83 @@ +package io.noties.markwon.app.samples; + +import androidx.annotation.NonNull; + +import org.commonmark.node.Emphasis; +import org.commonmark.node.Node; +import org.commonmark.node.Text; +import org.commonmark.parser.Parser; +import org.commonmark.parser.delimiter.DelimiterProcessor; +import org.commonmark.parser.delimiter.DelimiterRun; + +import io.noties.markwon.AbstractMarkwonPlugin; +import io.noties.markwon.Markwon; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200630194017", + title = "Custom delimiter processor", + description = "Custom parsing delimiter processor with `?` character", + artifacts = MarkwonArtifact.CORE, + tags = Tags.parsing +) +public class DelimiterProcessorSample extends MarkwonTextViewSample { + @Override + public void render() { + final String md = "" + + "?hello? there!"; + + final Markwon markwon = Markwon.builder(context) + .usePlugin(new AbstractMarkwonPlugin() { + @Override + public void configureParser(@NonNull Parser.Builder builder) { + builder.customDelimiterProcessor(new QuestionDelimiterProcessor()); + } + }) + .build(); + + markwon.setMarkdown(textView, md); + } +} + +class QuestionDelimiterProcessor implements DelimiterProcessor { + + @Override + public char getOpeningCharacter() { + return '?'; + } + + @Override + public char getClosingCharacter() { + return '?'; + } + + @Override + public int getMinLength() { + return 1; + } + + @Override + public int getDelimiterUse(DelimiterRun opener, DelimiterRun closer) { + if (opener.length() >= 1 && closer.length() >= 1) { + return 1; + } + return 0; + } + + @Override + public void process(Text opener, Text closer, int delimiterUse) { + final Node node = new Emphasis(); + + Node tmp = opener.getNext(); + while (tmp != null && tmp != closer) { + Node next = tmp.getNext(); + node.appendChild(tmp); + tmp = next; + } + + opener.insertAfter(node); + } +} \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/DisableNodeSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/DisableNodeSample.java new file mode 100644 index 00000000..dccbfa12 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/DisableNodeSample.java @@ -0,0 +1,44 @@ +package io.noties.markwon.app.samples; + +import androidx.annotation.NonNull; + +import org.commonmark.node.Heading; + +import io.noties.markwon.AbstractMarkwonPlugin; +import io.noties.markwon.Markwon; +import io.noties.markwon.MarkwonVisitor; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200629123308", + title = "Disable node from rendering", + description = "Disable _parsed_ node from being rendered (markdown syntax is still consumed)", + artifacts = {MarkwonArtifact.CORE}, + tags = {Tags.parsing, Tags.rendering} +) +public class DisableNodeSample extends MarkwonTextViewSample { + @Override + public void render() { + final String md = "# Heading 1\n\n## Heading 2\n\n**other** content [here](#)"; + + final Markwon markwon = Markwon.builder(context) + .usePlugin(new AbstractMarkwonPlugin() { + @Override + public void configureVisitor(@NonNull MarkwonVisitor.Builder builder) { + + // for example to disable rendering of heading: + // try commenting this out to see that otherwise headings will be rendered + builder.on(Heading.class, null); + + // same method can be used to override existing visitor by specifying + // a new NodeVisitor instance + } + }) + .build(); + + markwon.setMarkdown(textView, md); + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/EnabledBlockTypesSample.kt b/app-sample/src/main/java/io/noties/markwon/app/samples/EnabledBlockTypesSample.kt new file mode 100644 index 00000000..c7d1e98e --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/EnabledBlockTypesSample.kt @@ -0,0 +1,45 @@ +package io.noties.markwon.app.samples + +import io.noties.markwon.AbstractMarkwonPlugin +import io.noties.markwon.Markwon +import io.noties.markwon.app.sample.Tags +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample +import io.noties.markwon.core.CorePlugin +import io.noties.markwon.sample.annotations.MarkwonArtifact +import io.noties.markwon.sample.annotations.MarkwonSampleInfo +import org.commonmark.node.BlockQuote +import org.commonmark.parser.Parser + +@MarkwonSampleInfo( + id = "20200627075012", + title = "Enabled markdown blocks", + description = "Modify/inspect enabled by `CorePlugin` block types. " + + "Disable quotes or other blocks from being parsed", + artifacts = [MarkwonArtifact.CORE], + tags = [Tags.parsing, Tags.block, Tags.plugin] +) +class EnabledBlockTypesSample : MarkwonTextViewSample() { + override fun render() { + val md = """ + # Heading + ## Second level + > Quote is not handled + """.trimIndent() + + val markwon = Markwon.builder(context) + .usePlugin(object : AbstractMarkwonPlugin() { + override fun configureParser(builder: Parser.Builder) { + // obtain all enabled block types + val enabledBlockTypes = CorePlugin.enabledBlockTypes() + // it is safe to modify returned collection + // remove quotes + enabledBlockTypes.remove(BlockQuote::class.java) + + builder.enabledBlockTypes(enabledBlockTypes) + } + }) + .build() + + markwon.setMarkdown(textView, md) + } +} \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/GithubUserIssueInlineParsingSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/GithubUserIssueInlineParsingSample.java new file mode 100644 index 00000000..d2b89a77 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/GithubUserIssueInlineParsingSample.java @@ -0,0 +1,110 @@ +package io.noties.markwon.app.samples; + +import androidx.annotation.NonNull; + +import org.commonmark.node.Link; +import org.commonmark.node.Node; +import org.commonmark.parser.InlineParserFactory; +import org.commonmark.parser.Parser; + +import java.util.regex.Pattern; + +import io.noties.markwon.AbstractMarkwonPlugin; +import io.noties.markwon.Markwon; +import io.noties.markwon.app.BuildConfig; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.inlineparser.InlineProcessor; +import io.noties.markwon.inlineparser.MarkwonInlineParser; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200629162024", + title = "User mention and issue (via text)", + description = "Github-like user mention and issue " + + "rendering via `CorePlugin.OnTextAddedListener`", + artifacts = {MarkwonArtifact.CORE, MarkwonArtifact.INLINE_PARSER}, + tags = {Tags.parsing, Tags.textAddedListener, Tags.rendering} +) +public class GithubUserIssueInlineParsingSample extends MarkwonTextViewSample { + @Override + public void render() { + final String md = "" + + "# Custom Extension 2\n" + + "\n" + + "This is an issue #1\n" + + "Done by @noties"; + + final InlineParserFactory inlineParserFactory = MarkwonInlineParser.factoryBuilder() + // include all current defaults (otherwise will be empty - contain only our inline-processors) + // included by default, to create factory-builder without defaults call `factoryBuilderNoDefaults` +// .includeDefaults() + .addInlineProcessor(new IssueInlineProcessor()) + .addInlineProcessor(new UserInlineProcessor()) + .build(); + + final Markwon markwon = Markwon.builder(context) + .usePlugin(new AbstractMarkwonPlugin() { + @Override + public void configureParser(@NonNull Parser.Builder builder) { + builder.inlineParserFactory(inlineParserFactory); + } + }) + .build(); + + markwon.setMarkdown(textView, md); + } +} + +class IssueInlineProcessor extends InlineProcessor { + + private static final Pattern RE = Pattern.compile("\\d+"); + + @Override + public char specialCharacter() { + return '#'; + } + + @Override + protected Node parse() { + final String id = match(RE); + if (id != null) { + final Link link = new Link(createIssueOrPullRequestLinkDestination(id), null); + link.appendChild(text("#" + id)); + return link; + } + return null; + } + + @NonNull + private static String createIssueOrPullRequestLinkDestination(@NonNull String id) { + return BuildConfig.GIT_REPOSITORY + "/issues/" + id; + } +} + +class UserInlineProcessor extends InlineProcessor { + + private static final Pattern RE = Pattern.compile("\\w+"); + + @Override + public char specialCharacter() { + return '@'; + } + + @Override + protected Node parse() { + final String user = match(RE); + if (user != null) { + final Link link = new Link(createUserLinkDestination(user), null); + link.appendChild(text("@" + user)); + return link; + } + return null; + } + + @NonNull + private static String createUserLinkDestination(@NonNull String user) { + return "https://github.com/" + user; + } +} \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/GithubUserIssueOnTextAddedSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/GithubUserIssueOnTextAddedSample.java new file mode 100644 index 00000000..f7db669b --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/GithubUserIssueOnTextAddedSample.java @@ -0,0 +1,121 @@ +package io.noties.markwon.app.samples; + +import androidx.annotation.NonNull; + +import org.commonmark.node.Link; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import io.noties.markwon.AbstractMarkwonPlugin; +import io.noties.markwon.Markwon; +import io.noties.markwon.MarkwonConfiguration; +import io.noties.markwon.MarkwonVisitor; +import io.noties.markwon.RenderProps; +import io.noties.markwon.SpannableBuilder; +import io.noties.markwon.app.BuildConfig; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.core.CorePlugin; +import io.noties.markwon.core.CoreProps; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200629162024", + title = "User mention and issue (via text)", + description = "Github-like user mention and issue " + + "rendering via `CorePlugin.OnTextAddedListener`", + artifacts = MarkwonArtifact.CORE, + tags = {Tags.parsing, Tags.textAddedListener, Tags.rendering} +) +public class GithubUserIssueOnTextAddedSample extends MarkwonTextViewSample { + @Override + public void render() { + final String md = "" + + "# Custom Extension 2\n" + + "\n" + + "This is an issue #1\n" + + "Done by @noties"; + + final Markwon markwon = Markwon.builder(context) + .usePlugin(new AbstractMarkwonPlugin() { + @Override + public void configure(@NonNull Registry registry) { + registry.require(CorePlugin.class, corePlugin -> + corePlugin.addOnTextAddedListener(new GithubLinkifyRegexTextAddedListener())); + } + }) + .build(); + + markwon.setMarkdown(textView, md); + } +} + +class GithubLinkifyRegexTextAddedListener implements CorePlugin.OnTextAddedListener { + + private static final Pattern PATTERN = Pattern.compile("((#\\d+)|(@\\w+))", Pattern.MULTILINE); + + @Override + public void onTextAdded(@NonNull MarkwonVisitor visitor, @NonNull String text, int start) { + + final Matcher matcher = PATTERN.matcher(text); + + String value; + String url; + int index; + + while (matcher.find()) { + + value = matcher.group(1); + + // detect which one it is + if ('#' == value.charAt(0)) { + url = createIssueOrPullRequestLink(value.substring(1)); + } else { + url = createUserLink(value.substring(1)); + } + + // it's important to use `start` value (represents start-index of `text` in the visitor) + index = start + matcher.start(); + + setLink(visitor, url, index, index + value.length()); + } + } + + @NonNull + private String createIssueOrPullRequestLink(@NonNull String number) { + // issues and pull-requests on github follow the same pattern and we + // cannot know for sure which one it is, but if we use issues for all types, + // github will automatically redirect to pull-request if it's the one which is opened + return BuildConfig.GIT_REPOSITORY + "/issues/" + number; + } + + @NonNull + private String createUserLink(@NonNull String user) { + return "https://github.com/" + user; + } + + private void setLink(@NonNull MarkwonVisitor visitor, @NonNull String destination, int start, int end) { + + // might a simpler one, but it doesn't respect possible changes to links +// visitor.builder().setSpan( +// new LinkSpan(visitor.configuration().theme(), destination, visitor.configuration().linkResolver()), +// start, +// end +// ); + + // use default handlers for links + final MarkwonConfiguration configuration = visitor.configuration(); + final RenderProps renderProps = visitor.renderProps(); + + CoreProps.LINK_DESTINATION.set(renderProps, destination); + + SpannableBuilder.setSpans( + visitor.builder(), + configuration.spansFactory().require(Link.class).getSpans(configuration, renderProps), + start, + end + ); + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/HeadingNoSpaceBlockHandlerSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/HeadingNoSpaceBlockHandlerSample.java new file mode 100644 index 00000000..a6258e6e --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/HeadingNoSpaceBlockHandlerSample.java @@ -0,0 +1,56 @@ +package io.noties.markwon.app.samples; + +import androidx.annotation.NonNull; + +import org.commonmark.node.Heading; +import org.commonmark.node.Node; + +import io.noties.markwon.AbstractMarkwonPlugin; +import io.noties.markwon.BlockHandlerDef; +import io.noties.markwon.Markwon; +import io.noties.markwon.MarkwonVisitor; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200629125924", + title = "Heading no padding (block handler)", + description = "Process padding (spacing) after heading with a " + + "`BlockHandler`", + artifacts = MarkwonArtifact.CORE, + tags = {Tags.block, Tags.spacing, Tags.padding, Tags.heading, Tags.rendering} +) +public class HeadingNoSpaceBlockHandlerSample extends MarkwonTextViewSample { + @Override + public void render() { + final String md = "" + + "# Title title title title title title title title title title\n\n" + + "text text text text" + + ""; + + final Markwon markwon = Markwon.builder(context) + .usePlugin(new AbstractMarkwonPlugin() { + @Override + public void configureVisitor(@NonNull MarkwonVisitor.Builder builder) { + builder.blockHandler(new BlockHandlerDef() { + @Override + public void blockEnd(@NonNull MarkwonVisitor visitor, @NonNull Node node) { + if (node instanceof Heading) { + if (visitor.hasNext(node)) { + visitor.ensureNewLine(); + // ensure new line but do not force insert one + } + } else { + super.blockEnd(visitor, node); + } + } + }); + } + }) + .build(); + + markwon.setMarkdown(textView, md); + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/HeadingNoSpaceSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/HeadingNoSpaceSample.java new file mode 100644 index 00000000..4f2a79e1 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/HeadingNoSpaceSample.java @@ -0,0 +1,64 @@ +package io.noties.markwon.app.samples; + +import androidx.annotation.NonNull; + +import org.commonmark.node.Heading; + +import io.noties.markwon.AbstractMarkwonPlugin; +import io.noties.markwon.Markwon; +import io.noties.markwon.MarkwonVisitor; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.core.CoreProps; +import io.noties.markwon.core.MarkwonTheme; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200629125622", + title = "Heading no padding", + description = "Do not add a new line after heading node", + artifacts = MarkwonArtifact.CORE, + tags = {Tags.spacing, Tags.padding, Tags.spacing, Tags.rendering} +) +public class HeadingNoSpaceSample extends MarkwonTextViewSample { + @Override + public void render() { + final String md = "" + + "# Title title title title title title title title title title" + + "\n\ntext text text text" + + ""; + + final Markwon markwon = Markwon.builder(context) + .usePlugin(new AbstractMarkwonPlugin() { + @Override + public void configureTheme(@NonNull MarkwonTheme.Builder builder) { + builder.headingBreakHeight(0); + } + + @Override + public void configureVisitor(@NonNull MarkwonVisitor.Builder builder) { + builder.on(Heading.class, (visitor, heading) -> { + + visitor.ensureNewLine(); + + final int length = visitor.length(); + visitor.visitChildren(heading); + + CoreProps.HEADING_LEVEL.set(visitor.renderProps(), heading.getLevel()); + + visitor.setSpansForNodeOptional(heading, length); + + if (visitor.hasNext(heading)) { + visitor.ensureNewLine(); + // by default Markwon adds a new line here +// visitor.forceNewLine(); + } + }); + } + }) + .build(); + + markwon.setMarkdown(textView, md); + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/InlinePluginNoDefaultsSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/InlinePluginNoDefaultsSample.java new file mode 100644 index 00000000..93d7b9e1 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/InlinePluginNoDefaultsSample.java @@ -0,0 +1,35 @@ +package io.noties.markwon.app.samples; + +import io.noties.markwon.Markwon; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.inlineparser.MarkwonInlineParser; +import io.noties.markwon.inlineparser.MarkwonInlineParserPlugin; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200629170857", + title = "Inline parsing without defaults", + description = "Configure inline parser plugin to **not** have any **inline** parsing", + artifacts = {MarkwonArtifact.INLINE_PARSER}, + tags = {Tags.parsing} +) +public class InlinePluginNoDefaultsSample extends MarkwonTextViewSample { + @Override + public void render() { + final String md = "" + + "# Heading\n" + + "`code` inlined and **bold** here"; + + final Markwon markwon = Markwon.builder(context) + .usePlugin(MarkwonInlineParserPlugin.create(MarkwonInlineParser.factoryBuilderNoDefaults())) +// .usePlugin(MarkwonInlineParserPlugin.create(MarkwonInlineParser.factoryBuilderNoDefaults(), factoryBuilder -> { +// // if anything, they can be included here +//// factoryBuilder.includeDefaults() +// })) + .build(); + + markwon.setMarkdown(textView, md); + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/LetterOrderedListSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/LetterOrderedListSample.java new file mode 100644 index 00000000..64c68bd6 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/LetterOrderedListSample.java @@ -0,0 +1,188 @@ +package io.noties.markwon.app.samples; + +import android.text.TextUtils; +import android.util.SparseIntArray; + +import androidx.annotation.NonNull; + +import org.commonmark.node.BulletList; +import org.commonmark.node.ListItem; +import org.commonmark.node.Node; +import org.commonmark.node.OrderedList; + +import io.noties.markwon.AbstractMarkwonPlugin; +import io.noties.markwon.Markwon; +import io.noties.markwon.MarkwonSpansFactory; +import io.noties.markwon.MarkwonVisitor; +import io.noties.markwon.Prop; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.core.CoreProps; +import io.noties.markwon.core.spans.BulletListItemSpan; +import io.noties.markwon.core.spans.OrderedListItemSpan; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200629130954", + title = "Letter ordered list", + description = "Render bullet list inside an ordered list with letters instead of bullets", + artifacts = MarkwonArtifact.CORE, + tags = {Tags.rendering, Tags.plugin, Tags.lists} +) +public class LetterOrderedListSample extends MarkwonTextViewSample { + @Override + public void render() { + // bullet list nested in ordered list renders letters instead of bullets + final String md = "" + + "1. Hello there!\n" + + "1. And here is how:\n" + + " - First\n" + + " - Second\n" + + " - Third\n" + + " 1. And first here\n\n"; + + final Markwon markwon = Markwon.builder(context) + .usePlugin(new BulletListIsOrderedWithLettersWhenNestedPlugin()) + .build(); + + markwon.setMarkdown(textView, md); + } +} + +class BulletListIsOrderedWithLettersWhenNestedPlugin extends AbstractMarkwonPlugin { + + private static final Prop BULLET_LETTER = Prop.of("my-bullet-letter"); + + // or introduce some kind of synchronization if planning to use from multiple threads, + // for example via ThreadLocal + private final SparseIntArray bulletCounter = new SparseIntArray(); + + @Override + public void afterRender(@NonNull Node node, @NonNull MarkwonVisitor visitor) { + // clear counter after render + bulletCounter.clear(); + } + + @Override + public void configureVisitor(@NonNull MarkwonVisitor.Builder builder) { + // NB that both ordered and bullet lists are represented + // by ListItem (must inspect parent to detect the type) + builder.on(ListItem.class, (visitor, listItem) -> { + // mimic original behaviour (copy-pasta from CorePlugin) + + final int length = visitor.length(); + + visitor.visitChildren(listItem); + + final Node parent = listItem.getParent(); + if (parent instanceof OrderedList) { + + final int start = ((OrderedList) parent).getStartNumber(); + + CoreProps.LIST_ITEM_TYPE.set(visitor.renderProps(), CoreProps.ListItemType.ORDERED); + CoreProps.ORDERED_LIST_ITEM_NUMBER.set(visitor.renderProps(), start); + + // after we have visited the children increment start number + final OrderedList orderedList = (OrderedList) parent; + orderedList.setStartNumber(orderedList.getStartNumber() + 1); + + } else { + CoreProps.LIST_ITEM_TYPE.set(visitor.renderProps(), CoreProps.ListItemType.BULLET); + + if (isBulletOrdered(parent)) { + // obtain current count value + final int count = currentBulletCountIn(parent); + BULLET_LETTER.set(visitor.renderProps(), createBulletLetter(count)); + // update current count value + setCurrentBulletCountIn(parent, count + 1); + } else { + CoreProps.BULLET_LIST_ITEM_LEVEL.set(visitor.renderProps(), listLevel(listItem)); + // clear letter info when regular bullet list is used + BULLET_LETTER.clear(visitor.renderProps()); + } + } + + visitor.setSpansForNodeOptional(listItem, length); + + if (visitor.hasNext(listItem)) { + visitor.ensureNewLine(); + } + }); + } + + @Override + public void configureSpansFactory(@NonNull MarkwonSpansFactory.Builder builder) { + builder.setFactory(ListItem.class, (configuration, props) -> { + final Object spans; + + if (CoreProps.ListItemType.BULLET == CoreProps.LIST_ITEM_TYPE.require(props)) { + final String letter = BULLET_LETTER.get(props); + if (!TextUtils.isEmpty(letter)) { + // NB, we are using OrderedListItemSpan here! + spans = new OrderedListItemSpan( + configuration.theme(), + letter + ); + } else { + spans = new BulletListItemSpan( + configuration.theme(), + CoreProps.BULLET_LIST_ITEM_LEVEL.require(props) + ); + } + } else { + + final String number = String.valueOf(CoreProps.ORDERED_LIST_ITEM_NUMBER.require(props)) + + "." + '\u00a0'; + + spans = new OrderedListItemSpan( + configuration.theme(), + number + ); + } + + return spans; + }); + } + + private int currentBulletCountIn(@NonNull Node parent) { + return bulletCounter.get(parent.hashCode(), 0); + } + + private void setCurrentBulletCountIn(@NonNull Node parent, int count) { + bulletCounter.put(parent.hashCode(), count); + } + + @NonNull + private static String createBulletLetter(int count) { + // or lower `a` + // `'u00a0` is non-breakable space char + return ((char) ('A' + count)) + ".\u00a0"; + } + + private static int listLevel(@NonNull Node node) { + int level = 0; + Node parent = node.getParent(); + while (parent != null) { + if (parent instanceof ListItem) { + level += 1; + } + parent = parent.getParent(); + } + return level; + } + + private static boolean isBulletOrdered(@NonNull Node node) { + node = node.getParent(); + while (node != null) { + if (node instanceof OrderedList) { + return true; + } + if (node instanceof BulletList) { + return false; + } + node = node.getParent(); + } + return false; + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/LinkRemoveUnderlineSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/LinkRemoveUnderlineSample.java new file mode 100644 index 00000000..4967b09b --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/LinkRemoveUnderlineSample.java @@ -0,0 +1,49 @@ +package io.noties.markwon.app.samples; + +import android.text.TextPaint; +import android.text.style.CharacterStyle; +import android.text.style.UpdateAppearance; + +import androidx.annotation.NonNull; + +import org.commonmark.node.Link; + +import io.noties.markwon.AbstractMarkwonPlugin; +import io.noties.markwon.Markwon; +import io.noties.markwon.MarkwonSpansFactory; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200702101224", + title = "Remove link underline", + artifacts = MarkwonArtifact.CORE, + tags = {Tags.links, Tags.rendering, Tags.span} +) +public class LinkRemoveUnderlineSample extends MarkwonTextViewSample { + @Override + public void render() { + final String md = "" + + "There are a lot of [links](#) [here](#)"; + + final Markwon markwon = Markwon.builder(context) + .usePlugin(new AbstractMarkwonPlugin() { + @Override + public void configureSpansFactory(@NonNull MarkwonSpansFactory.Builder builder) { + builder.appendFactory(Link.class, (configuration, props) -> new RemoveUnderlineSpan()); + } + }) + .build(); + + markwon.setMarkdown(textView, md); + } +} + +class RemoveUnderlineSpan extends CharacterStyle implements UpdateAppearance { + @Override + public void updateDrawState(TextPaint tp) { + tp.setUnderlineText(false); + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/LinkTitleSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/LinkTitleSample.java new file mode 100644 index 00000000..ff4133e5 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/LinkTitleSample.java @@ -0,0 +1,97 @@ +package io.noties.markwon.app.samples; + +import android.text.Spanned; +import android.view.View; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.commonmark.node.Link; + +import java.util.Locale; + +import io.noties.markwon.AbstractMarkwonPlugin; +import io.noties.markwon.LinkResolver; +import io.noties.markwon.Markwon; +import io.noties.markwon.MarkwonSpansFactory; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.core.CoreProps; +import io.noties.markwon.core.MarkwonTheme; +import io.noties.markwon.core.spans.LinkSpan; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200629122230", + title = "Obtain link title", + description = "Obtain title (text) of clicked link, `[title](#destination)`", + artifacts = {MarkwonArtifact.CORE}, + tags = {Tags.links, Tags.span} +) +public class LinkTitleSample extends MarkwonTextViewSample { + @Override + public void render() { + final String md = "" + + "# Links\n\n" + + "[link title](#)"; + + final Markwon markwon = Markwon.builder(context) + .usePlugin(new AbstractMarkwonPlugin() { + @Override + public void configureSpansFactory(@NonNull MarkwonSpansFactory.Builder builder) { + builder.setFactory(Link.class, (configuration, props) -> + // create a subclass of markwon LinkSpan + new ClickSelfSpan( + configuration.theme(), + CoreProps.LINK_DESTINATION.require(props), + configuration.linkResolver() + ) + ); + } + }) + .build(); + + markwon.setMarkdown(textView, md); + } +} + +class ClickSelfSpan extends LinkSpan { + + ClickSelfSpan( + @NonNull MarkwonTheme theme, + @NonNull String link, + @NonNull LinkResolver resolver) { + super(theme, link, resolver); + } + + @Override + public void onClick(View widget) { + Toast.makeText( + widget.getContext(), + String.format(Locale.ROOT, "clicked link title: '%s'", linkTitle(widget)), + Toast.LENGTH_LONG + ).show(); + super.onClick(widget); + } + + @Nullable + private CharSequence linkTitle(@NonNull View widget) { + + if (!(widget instanceof TextView)) { + return null; + } + + final Spanned spanned = (Spanned) ((TextView) widget).getText(); + final int start = spanned.getSpanStart(this); + final int end = spanned.getSpanEnd(this); + + if (start < 0 || end < 0) { + return null; + } + + return spanned.subSequence(start, end); + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/LinkWithoutSchemeSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/LinkWithoutSchemeSample.java new file mode 100644 index 00000000..28d218f0 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/LinkWithoutSchemeSample.java @@ -0,0 +1,29 @@ +package io.noties.markwon.app.samples; + +import io.noties.markwon.Markwon; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200629124005", + title = "Links without scheme", + description = "Links without scheme are considered to be `https`", + artifacts = {MarkwonArtifact.CORE}, + tags = {Tags.links, Tags.defaults} +) +public class LinkWithoutSchemeSample extends MarkwonTextViewSample { + @Override + public void render() { + final String md = "" + + "# Links without scheme\n" + + "[a link without scheme](github.com) is considered to be `https`.\n" + + "Override `LinkResolverDef` to change this functionality" + + ""; + + final Markwon markwon = Markwon.create(context); + + markwon.setMarkdown(textView, md); + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/NoParsingSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/NoParsingSample.java new file mode 100644 index 00000000..77ff47ea --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/NoParsingSample.java @@ -0,0 +1,49 @@ +package io.noties.markwon.app.samples; + +import androidx.annotation.NonNull; + +import org.commonmark.parser.Parser; + +import java.util.Collections; + +import io.noties.markwon.AbstractMarkwonPlugin; +import io.noties.markwon.Markwon; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.inlineparser.MarkwonInlineParser; +import io.noties.markwon.inlineparser.MarkwonInlineParserPlugin; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200629171212", + title = "No parsing", + description = "All commonmark parsing is disabled (both inlines and blocks)", + artifacts = MarkwonArtifact.CORE, + tags = {Tags.parsing, Tags.rendering} +) +public class NoParsingSample extends MarkwonTextViewSample { + @Override + public void render() { + final String md = "" + + "# Heading\n" + + "[link](#) was _here_ and `then` and it was:\n" + + "> a quote\n" + + "```java\n" + + "final int someJavaCode = 0;\n" + + "```\n"; + + final Markwon markwon = Markwon.builder(context) + // disable inline parsing + .usePlugin(MarkwonInlineParserPlugin.create(MarkwonInlineParser.factoryBuilderNoDefaults())) + .usePlugin(new AbstractMarkwonPlugin() { + @Override + public void configureParser(@NonNull Parser.Builder builder) { + builder.enabledBlockTypes(Collections.emptySet()); + } + }) + .build(); + + markwon.setMarkdown(textView, md); + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/ParagraphSpanStyle.java b/app-sample/src/main/java/io/noties/markwon/app/samples/ParagraphSpanStyle.java new file mode 100644 index 00000000..00ce4cd8 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/ParagraphSpanStyle.java @@ -0,0 +1,43 @@ +package io.noties.markwon.app.samples; + +import android.graphics.Color; +import android.text.style.ForegroundColorSpan; + +import androidx.annotation.NonNull; + +import org.commonmark.node.Paragraph; + +import io.noties.markwon.AbstractMarkwonPlugin; +import io.noties.markwon.Markwon; +import io.noties.markwon.MarkwonSpansFactory; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200629122647", + title = "Paragraph style", + description = "Apply a style (via span) to a paragraph", + artifacts = {MarkwonArtifact.CORE}, + tags = {Tags.paragraph, Tags.style, Tags.span} +) +public class ParagraphSpanStyle extends MarkwonTextViewSample { + @Override + public void render() { + final String md = "# Hello!\n\nA paragraph?\n\nIt should be!"; + + final Markwon markwon = Markwon.builder(context) + .usePlugin(new AbstractMarkwonPlugin() { + @Override + public void configureSpansFactory(@NonNull MarkwonSpansFactory.Builder builder) { + // apply a span to a Paragraph + builder.setFactory(Paragraph.class, (configuration, props) -> + new ForegroundColorSpan(Color.GREEN)); + } + }) + .build(); + + markwon.setMarkdown(textView, md); + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/PrecomputedFutureSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/PrecomputedFutureSample.java new file mode 100644 index 00000000..37ec4cb7 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/PrecomputedFutureSample.java @@ -0,0 +1,75 @@ +package io.noties.markwon.app.samples; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.LinearLayoutManager; + +import io.noties.markwon.AbstractMarkwonPlugin; +import io.noties.markwon.Markwon; +import io.noties.markwon.MarkwonConfiguration; +import io.noties.markwon.PrecomputedFutureTextSetterCompat; +import io.noties.markwon.app.R; +import io.noties.markwon.app.readme.GithubImageDestinationProcessor; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonRecyclerViewSample; +import io.noties.markwon.app.utils.SampleUtilsKtKt; +import io.noties.markwon.ext.strikethrough.StrikethroughPlugin; +import io.noties.markwon.ext.tables.TablePlugin; +import io.noties.markwon.ext.tasklist.TaskListPlugin; +import io.noties.markwon.image.ImagesPlugin; +import io.noties.markwon.recycler.MarkwonAdapter; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200702092446", + title = "PrecomputedFutureTextSetterCompat", + description = "Usage of `PrecomputedFutureTextSetterCompat` " + + "inside a `RecyclerView` with appcompat", + artifacts = {MarkwonArtifact.RECYCLER}, + tags = {Tags.recyclerView, Tags.precomputedText} +) +public class PrecomputedFutureSample extends MarkwonRecyclerViewSample { + @Override + public void render() { + if (!hasAppCompat()) { + /* + PLEASE COMPILE WITH `APPCOMPAT` dependency + */ + return; + } + + final String md = SampleUtilsKtKt.loadReadMe(context); + + final Markwon markwon = Markwon.builder(context) + .textSetter(PrecomputedFutureTextSetterCompat.create()) + .usePlugin(ImagesPlugin.create()) + .usePlugin(TablePlugin.create(context)) + .usePlugin(TaskListPlugin.create(context)) + .usePlugin(StrikethroughPlugin.create()) + .usePlugin(new AbstractMarkwonPlugin() { + @Override + public void configureConfiguration(@NonNull MarkwonConfiguration.Builder builder) { + builder.imageDestinationProcessor(new GithubImageDestinationProcessor()); + } + }) + .build(); + + final MarkwonAdapter adapter = MarkwonAdapter + .createTextViewIsRoot(R.layout.adapter_appcompat_default_entry); + + recyclerView.setLayoutManager(new LinearLayoutManager(context)); + recyclerView.setAdapter(adapter); + + adapter.setMarkdown(markwon, md); + adapter.notifyDataSetChanged(); + } + + private static boolean hasAppCompat() { + try { + Class.forName("androidx.appcompat.widget.AppCompatTextView"); + return true; + } catch (Throwable t) { + return false; + } + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/PrecomputedSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/PrecomputedSample.java new file mode 100644 index 00000000..ae1c260c --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/PrecomputedSample.java @@ -0,0 +1,32 @@ +package io.noties.markwon.app.samples; + +import java.util.concurrent.Executors; + +import io.noties.markwon.Markwon; +import io.noties.markwon.PrecomputedTextSetterCompat; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200702091654", + title = "PrecomputedTextSetterCompat", + description = "`TextSetter` to use `PrecomputedTextSetterCompat`", + artifacts = MarkwonArtifact.CORE, + tags = Tags.precomputedText +) +public class PrecomputedSample extends MarkwonTextViewSample { + @Override + public void render() { + final String md = "" + + "# Heading\n" + + "**bold** some precomputed spans via `PrecomputedTextSetterCompat`"; + + final Markwon markwon = Markwon.builder(context) + .textSetter(PrecomputedTextSetterCompat.create(Executors.newCachedThreadPool())) + .build(); + + markwon.setMarkdown(textView, md); + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/ReadMorePluginSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/ReadMorePluginSample.java new file mode 100644 index 00000000..81e3989f --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/ReadMorePluginSample.java @@ -0,0 +1,209 @@ +package io.noties.markwon.app.samples; + +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.style.ClickableSpan; +import android.text.style.ReplacementSpan; +import android.view.View; +import android.widget.TextView; + +import androidx.annotation.NonNull; + +import io.noties.markwon.AbstractMarkwonPlugin; +import io.noties.markwon.Markwon; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.ext.tables.TablePlugin; +import io.noties.markwon.image.ImagesPlugin; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200629161505", + title = "Read more plugin", + description = "Plugin that adds expand/collapse (\"show all\"/\"show less\")", + artifacts = MarkwonArtifact.CORE, + tags = {Tags.plugin} +) +public class ReadMorePluginSample extends MarkwonTextViewSample { + @Override + public void render() { + final String md = "" + + "Lorem **ipsum** ![dolor](https://avatars2.githubusercontent.com/u/30618885?s=460&v=4) sit amet, consectetur adipiscing elit. Morbi vitae enim ut sem aliquet ultrices. Nunc a accumsan orci. Suspendisse tortor ante, lacinia ac scelerisque sed, dictum eget metus. Morbi ante augue, tristique eget quam in, vestibulum rutrum lacus. Nulla aliquam auctor cursus. Nulla at lacus condimentum, viverra lacus eget, sollicitudin ex. Cras efficitur leo dui, sit amet rutrum tellus venenatis et. Sed in facilisis libero. Etiam ultricies, nulla ut venenatis tincidunt, tortor erat tristique ante, non aliquet massa arcu eget nisl. Etiam gravida erat ante, sit amet lobortis mauris commodo nec. Praesent vitae sodales quam. Vivamus condimentum porta suscipit. Donec posuere id felis ac scelerisque. Vestibulum lacinia et leo id lobortis. Sed vitae dolor nec ligula dapibus finibus vel eu libero. Nam tincidunt maximus elit, sit amet tincidunt lacus laoreet malesuada.\n\n" + + "here we ![are](https://avatars2.githubusercontent.com/u/30618885?s=460&v=4)"; + + final Markwon markwon = Markwon.builder(context) + .usePlugin(ImagesPlugin.create()) + .usePlugin(new ReadMorePlugin()) + .build(); + + markwon.setMarkdown(textView, md); + } +} + +/** + * Read more plugin based on text length. It is easier to implement than lines (need to adjust + * last line to include expand/collapse text). + */ +class ReadMorePlugin extends AbstractMarkwonPlugin { + + @SuppressWarnings("FieldCanBeLocal") + private final int maxLength = 150; + + @SuppressWarnings("FieldCanBeLocal") + private final String labelMore = "Show more..."; + + @SuppressWarnings("FieldCanBeLocal") + private final String labelLess = "...Show less"; + + @Override + public void configure(@NonNull Registry registry) { + // establish connections with all _dynamic_ content that your markdown supports, + // like images, tables, latex, etc + registry.require(ImagesPlugin.class); +// registry.require(TablePlugin.class); + } + + @Override + public void afterSetText(@NonNull TextView textView) { + final CharSequence text = textView.getText(); + if (text.length() < maxLength) { + // everything is OK, no need to ellipsize) + return; + } + + final int breakAt = breakTextAt(text, 0, maxLength); + final CharSequence cs = createCollapsedString(text, 0, breakAt); + textView.setText(cs); + } + + @SuppressWarnings("SameParameterValue") + @NonNull + private CharSequence createCollapsedString(@NonNull CharSequence text, int start, int end) { + final SpannableStringBuilder builder = new SpannableStringBuilder(text, start, end); + + // NB! each table row is represented as a space character and new-line (so length=2) no + // matter how many characters are inside table cells + + // we can _clean_ this builder, for example remove all dynamic content (like images and tables, + // but keep them in full/expanded version) + //noinspection ConstantConditions + if (true) { + // it is an implementation detail but _mostly_ dynamic content is implemented as + // ReplacementSpans + final ReplacementSpan[] spans = builder.getSpans(0, builder.length(), ReplacementSpan.class); + if (spans != null) { + for (ReplacementSpan span : spans) { + builder.removeSpan(span); + } + } + + // NB! if there will be a table in _preview_ (collapsed) then each row will be represented as a + // space and new-line + trim(builder); + } + + final CharSequence fullText = createFullText(text, builder); + + builder.append(' '); + + final int length = builder.length(); + builder.append(labelMore); + builder.setSpan(new ClickableSpan() { + @Override + public void onClick(@NonNull View widget) { + ((TextView) widget).setText(fullText); + } + }, length, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + return builder; + } + + @NonNull + private CharSequence createFullText(@NonNull CharSequence text, @NonNull CharSequence collapsedText) { + // full/expanded text can also be different, + // for example it can be kept as-is and have no `collapse` functionality (once expanded cannot collapse) + // or can contain collapse feature + final CharSequence fullText; + //noinspection ConstantConditions + if (true) { + // for example let's allow collapsing + final SpannableStringBuilder builder = new SpannableStringBuilder(text); + builder.append(' '); + + final int length = builder.length(); + builder.append(labelLess); + builder.setSpan(new ClickableSpan() { + @Override + public void onClick(@NonNull View widget) { + ((TextView) widget).setText(collapsedText); + } + }, length, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + fullText = builder; + } else { + fullText = text; + } + + return fullText; + } + + private static void trim(@NonNull SpannableStringBuilder builder) { + + // NB! tables use `\u00a0` (non breaking space) which is not reported as white-space + + char c; + + for (int i = 0, length = builder.length(); i < length; i++) { + c = builder.charAt(i); + if (!Character.isWhitespace(c) && c != '\u00a0') { + if (i > 0) { + builder.replace(0, i, ""); + } + break; + } + } + + for (int i = builder.length() - 1; i >= 0; i--) { + c = builder.charAt(i); + if (!Character.isWhitespace(c) && c != '\u00a0') { + if (i < builder.length() - 1) { + builder.replace(i, builder.length(), ""); + } + break; + } + } + } + + // depending on your locale these can be different + // There is a BreakIterator in Android, but it is not reliable, still theoretically + // it should work better than hand-written and hardcoded rules + @SuppressWarnings("SameParameterValue") + private static int breakTextAt(@NonNull CharSequence text, int start, int max) { + + int last = start; + + // no need to check for _start_ (anyway will be ignored) + for (int i = start + max - 1; i > start; i--) { + final char c = text.charAt(i); + if (Character.isWhitespace(c) + || c == '.' + || c == ',' + || c == '!' + || c == '?') { + // include this special character + last = i - 1; + break; + } + } + + if (last <= start) { + // when used in subSequence last index is exclusive, + // so given max=150 would result in 0-149 subSequence + return start + max; + } + + return last; + } +} + diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/RecyclerSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/RecyclerSample.java new file mode 100644 index 00000000..02a9dc41 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/RecyclerSample.java @@ -0,0 +1,84 @@ +package io.noties.markwon.app.samples; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.LinearLayoutManager; + +import org.commonmark.ext.gfm.tables.TableBlock; +import org.commonmark.node.FencedCodeBlock; + +import io.noties.markwon.AbstractMarkwonPlugin; +import io.noties.markwon.Markwon; +import io.noties.markwon.MarkwonConfiguration; +import io.noties.markwon.MarkwonVisitor; +import io.noties.markwon.app.R; +import io.noties.markwon.app.readme.GithubImageDestinationProcessor; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonRecyclerViewSample; +import io.noties.markwon.app.utils.SampleUtilsKtKt; +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.recycler.MarkwonAdapter; +import io.noties.markwon.recycler.SimpleEntry; +import io.noties.markwon.recycler.table.TableEntry; +import io.noties.markwon.recycler.table.TableEntryPlugin; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200702101750", + title = "RecyclerView", + description = "Usage with `RecyclerView`", + artifacts = {MarkwonArtifact.RECYCLER, MarkwonArtifact.RECYCLER_TABLE}, + tags = Tags.recyclerView +) +public class RecyclerSample extends MarkwonRecyclerViewSample { + @Override + public void render() { + final String md = SampleUtilsKtKt.loadReadMe(context); + + final Markwon markwon = Markwon.builder(context) + .usePlugin(ImagesPlugin.create()) + .usePlugin(TableEntryPlugin.create(context)) + .usePlugin(HtmlPlugin.create()) + .usePlugin(StrikethroughPlugin.create()) + .usePlugin(TaskListPlugin.create(context)) + .usePlugin(new AbstractMarkwonPlugin() { + @Override + public void configureConfiguration(@NonNull MarkwonConfiguration.Builder builder) { + builder.imageDestinationProcessor(new GithubImageDestinationProcessor()); + } + + @Override + public void configureVisitor(@NonNull MarkwonVisitor.Builder builder) { + builder.on(FencedCodeBlock.class, (visitor, fencedCodeBlock) -> { + // we actually won't be applying code spans here, as our custom view will + // draw background and apply mono typeface + // + // NB the `trim` operation on literal (as code will have a new line at the end) + final CharSequence code = visitor.configuration() + .syntaxHighlight() + .highlight(fencedCodeBlock.getInfo(), fencedCodeBlock.getLiteral().trim()); + visitor.builder().append(code); + }); + } + }) + .build(); + + final MarkwonAdapter adapter = MarkwonAdapter.builderTextViewIsRoot(R.layout.adapter_node) + .include(FencedCodeBlock.class, SimpleEntry.create(R.layout.adapter_node_code_block, R.id.text_view)) + .include(TableBlock.class, TableEntry.create(builder -> { + builder + .tableLayout(R.layout.adapter_node_table_block, R.id.table_layout) + .textLayoutIsRoot(R.layout.view_table_entry_cell); + })) + .build(); + + recyclerView.setLayoutManager(new LinearLayoutManager(context)); + recyclerView.setAdapter(adapter); + + adapter.setMarkdown(markwon, md); + adapter.notifyDataSetChanged(); + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/SimpleExtensionSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/SimpleExtensionSample.java new file mode 100644 index 00000000..db9ab3be --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/SimpleExtensionSample.java @@ -0,0 +1,49 @@ +package io.noties.markwon.app.samples; + +import android.graphics.Color; +import android.text.style.ForegroundColorSpan; + +import io.noties.markwon.Markwon; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.core.spans.EmphasisSpan; +import io.noties.markwon.core.spans.StrongEmphasisSpan; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; +import io.noties.markwon.simple.ext.SimpleExtPlugin; + +@MarkwonSampleInfo( + id = "20200630194335", + title = "Delimiter processor simple-ext", + description = "Custom delimiter processor implemented with a `SimpleExtPlugin`", + artifacts = MarkwonArtifact.SIMPLE_EXT, + tags = Tags.parsing +) +public class SimpleExtensionSample extends MarkwonTextViewSample { + @Override + public void render() { + final String md = "" + + "# SimpleExt\n" + + "\n" + + "+let's start with `+`, ??then we can use this, and finally @@this$$??+"; + + // NB! we cannot have multiple delimiter processor with the same character + // (even if lengths are different) + + final Markwon markwon = Markwon.builder(context) + .usePlugin(SimpleExtPlugin.create(plugin -> { + plugin + .addExtension(1, '+', (configuration, props) -> new EmphasisSpan()) + .addExtension(2, '?', (configuration, props) -> new StrongEmphasisSpan()) + .addExtension( + 2, + '@', + '$', + (configuration, props) -> new ForegroundColorSpan(Color.RED) + ); + })) + .build(); + + markwon.setMarkdown(textView, md); + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/SoftBreakAddsNewLineSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/SoftBreakAddsNewLineSample.java new file mode 100644 index 00000000..94fe0485 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/SoftBreakAddsNewLineSample.java @@ -0,0 +1,29 @@ +package io.noties.markwon.app.samples; + +import io.noties.markwon.Markwon; +import io.noties.markwon.SoftBreakAddsNewLinePlugin; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200629125040", + title = "Soft break new line", + description = "Add a new line for a markdown soft-break node", + artifacts = MarkwonArtifact.CORE, + tags = {Tags.newLine, Tags.softBreak} +) +public class SoftBreakAddsNewLineSample extends MarkwonTextViewSample { + @Override + public void render() { + final String md = "" + + "Hello there ->(line)\n(break)<- going on and on"; + + final Markwon markwon = Markwon.builder(context) + .usePlugin(SoftBreakAddsNewLinePlugin.create()) + .build(); + + markwon.setMarkdown(textView, md); + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/SoftBreakAddsSpace.java b/app-sample/src/main/java/io/noties/markwon/app/samples/SoftBreakAddsSpace.java new file mode 100644 index 00000000..44bfedee --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/SoftBreakAddsSpace.java @@ -0,0 +1,28 @@ +package io.noties.markwon.app.samples; + +import io.noties.markwon.Markwon; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200629124706", + title = "Soft break adds space", + description = "By default a soft break (`\n`) will " + + "add a space character instead of new line", + artifacts = MarkwonArtifact.CORE, + tags = {Tags.newLine, Tags.softBreak, Tags.defaults} +) +public class SoftBreakAddsSpace extends MarkwonTextViewSample { + @Override + public void render() { + final String md = "" + + "Hello there ->(line)\n(break)<- going on and on"; + + // by default a soft break will add a space (instead of line break) + final Markwon markwon = Markwon.create(context); + + markwon.setMarkdown(textView, md); + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/ToastDynamicContentSample.kt b/app-sample/src/main/java/io/noties/markwon/app/samples/ToastDynamicContentSample.kt new file mode 100644 index 00000000..55e939dd --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/ToastDynamicContentSample.kt @@ -0,0 +1,74 @@ +package io.noties.markwon.app.samples + +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import android.widget.Toast +import io.noties.markwon.Markwon +import io.noties.markwon.app.BuildConfig +import io.noties.markwon.app.sample.Tags +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample +import io.noties.markwon.image.ImagesPlugin +import io.noties.markwon.sample.annotations.MarkwonArtifact +import io.noties.markwon.sample.annotations.MarkwonSampleInfo + +@MarkwonSampleInfo( + id = "20200627074017", + title = "Markdown in Toast (with dynamic content)", + description = "Display markdown in a `android.widget.Toast` with dynamic content (image)", + artifacts = [MarkwonArtifact.CORE, MarkwonArtifact.IMAGE], + tags = [Tags.toast, Tags.hack] +) +class ToastDynamicContentSample : MarkwonTextViewSample() { + override fun render() { + val md = """ + # Head! + + ![alt](${BuildConfig.GIT_REPOSITORY}/raw/master/art/markwon_logo.png) + + Do you see an image? ☝️ + """.trimIndent() + + val markwon = Markwon.builder(context) + .usePlugin(ImagesPlugin.create()) + .build() + + val markdown = markwon.toMarkdown(md) + + val toast = Toast.makeText(context, markdown, Toast.LENGTH_LONG) + + // try to obtain textView + val textView = toast.textView + if (textView != null) { + markwon.setParsedMarkdown(textView, markdown) + } + + // finally show toast (at this point, if we didn't find TextView it will still + // present markdown, just without dynamic content (image)) + toast.show() + } +} + +private val Toast.textView: TextView? + get() { + + fun find(view: View?): TextView? { + + if (view is TextView) { + return view + } + + if (view is ViewGroup) { + for (i in 0 until view.childCount) { + val textView = find(view.getChildAt(i)) + if (textView != null) { + return textView + } + } + } + + return null + } + + return find(view) + } \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/ToastSample.kt b/app-sample/src/main/java/io/noties/markwon/app/samples/ToastSample.kt new file mode 100644 index 00000000..0ac813f8 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/ToastSample.kt @@ -0,0 +1,38 @@ +package io.noties.markwon.app.samples + +import android.widget.Toast +import io.noties.markwon.Markwon +import io.noties.markwon.app.sample.Tags +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample +import io.noties.markwon.sample.annotations.MarkwonArtifact +import io.noties.markwon.sample.annotations.MarkwonSampleInfo + +@MarkwonSampleInfo( + id = "20200627072642", + title = "Markdown in Toast", + description = "Display _static_ markdown content in a `android.widget.Toast`", + artifacts = [MarkwonArtifact.CORE], + tags = [Tags.toast] +) +class ToastSample : MarkwonTextViewSample() { + override fun render() { + // NB! only _static_ content is going to be displayed, + // so, no images, tables or latex in a Toast + val md = """ + # Heading is fine + > Even quote if **fine** + ``` + finally code works; + ``` + _italic_ to put an end to it + """.trimIndent() + + val markwon = Markwon.create(context) + + // render raw input to styled markdown + val markdown = markwon.toMarkdown(md) + + // Toast accepts CharSequence and allows styling via spans + Toast.makeText(context, markdown, Toast.LENGTH_LONG).show() + } +} \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/basics/Simple.kt b/app-sample/src/main/java/io/noties/markwon/app/samples/basics/Simple.kt new file mode 100644 index 00000000..6bacdce7 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/basics/Simple.kt @@ -0,0 +1,33 @@ +package io.noties.markwon.app.samples.basics + +import io.noties.markwon.Markwon +import io.noties.markwon.app.sample.Tags +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample +import io.noties.markwon.sample.annotations.MarkwonArtifact +import io.noties.markwon.sample.annotations.MarkwonSampleInfo + +@MarkwonSampleInfo( + id = "20200626152255", + title = "Simple", + description = "The most primitive and simple way to apply markdown to a `TextView`", + artifacts = [MarkwonArtifact.CORE], + tags = [Tags.basics] +) +class Simple : MarkwonTextViewSample() { + override fun render() { + // markdown input + val md = """ + # Heading + + > A quote + + **bold _italic_ bold** + """.trimIndent() + + // markwon instance + val markwon = Markwon.create(context) + + // apply raw markdown (internally parsed and rendered) + markwon.setMarkdown(textView, md) + } +} \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/basics/SimpleWalkthrough.kt b/app-sample/src/main/java/io/noties/markwon/app/samples/basics/SimpleWalkthrough.kt new file mode 100644 index 00000000..b58c4af5 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/basics/SimpleWalkthrough.kt @@ -0,0 +1,47 @@ +package io.noties.markwon.app.samples.basics + +import android.text.Spanned +import io.noties.markwon.Markwon +import io.noties.markwon.app.sample.Tags +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample +import io.noties.markwon.core.CorePlugin +import io.noties.markwon.sample.annotations.MarkwonArtifact +import io.noties.markwon.sample.annotations.MarkwonSampleInfo +import org.commonmark.node.Node + +@MarkwonSampleInfo( + id = "20200626153426", + title = "Simple with walk-through", + description = "Walk-through for simple use case", + artifacts = [MarkwonArtifact.CORE], + tags = [Tags.basics] +) +class SimpleWalkthrough : MarkwonTextViewSample() { + override fun render() { + val md: String = """ + # Hello! + + > a quote + + ``` + code block + ``` + """.trimIndent() + + // create markwon instance via builder method + val markwon: Markwon = Markwon.builder(context) + // add required plugins + // NB, there is no need to add CorePlugin as it is added automatically + .usePlugin(CorePlugin.create()) + .build() + + // parse markdown into commonmark representation + val node: Node = markwon.parse(md) + + // render commonmark node + val markdown: Spanned = markwon.render(node) + + // apply it to a TextView + markwon.setParsedMarkdown(textView, markdown) + } +} \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/editor/EditorAdditionalEditSpan.java b/app-sample/src/main/java/io/noties/markwon/app/samples/editor/EditorAdditionalEditSpan.java new file mode 100644 index 00000000..038234d4 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/editor/EditorAdditionalEditSpan.java @@ -0,0 +1,106 @@ +package io.noties.markwon.app.samples.editor; + +import android.text.Editable; +import android.text.Spanned; +import android.text.TextPaint; +import android.text.style.MetricAffectingSpan; + +import androidx.annotation.NonNull; + +import io.noties.markwon.Markwon; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.samples.editor.shared.MarkwonEditTextSample; +import io.noties.markwon.core.spans.StrongEmphasisSpan; +import io.noties.markwon.editor.AbstractEditHandler; +import io.noties.markwon.editor.MarkwonEditor; +import io.noties.markwon.editor.MarkwonEditorTextWatcher; +import io.noties.markwon.editor.MarkwonEditorUtils; +import io.noties.markwon.editor.PersistedSpans; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200629165136", + title = "Additional edit span", + description = "Additional _edit_ span (span that is present in " + + "`EditText` along with punctuation", + artifacts = {MarkwonArtifact.EDITOR, MarkwonArtifact.INLINE_PARSER}, + tags = {Tags.editor, Tags.span} +) +public class EditorAdditionalEditSpan extends MarkwonEditTextSample { + @Override + public void render() { + // An additional span is used to highlight strong-emphasis + + final MarkwonEditor editor = MarkwonEditor.builder(Markwon.create(context)) + .useEditHandler(new BoldEditHandler()) + .build(); + + editText.addTextChangedListener(MarkwonEditorTextWatcher.withProcess(editor)); + } +} + +class BoldEditHandler extends AbstractEditHandler { + @Override + public void configurePersistedSpans(@NonNull PersistedSpans.Builder builder) { + // Here we define which span is _persisted_ in EditText, it is not removed + // from EditText between text changes, but instead - reused (by changing + // position). Consider it as a cache for spans. We could use `StrongEmphasisSpan` + // here also, but I chose Bold to indicate that this span is not the same + // as in off-screen rendered markdown + builder.persistSpan(Bold.class, Bold::new); + } + + @Override + public void handleMarkdownSpan( + @NonNull PersistedSpans persistedSpans, + @NonNull Editable editable, + @NonNull String input, + @NonNull StrongEmphasisSpan span, + int spanStart, + int spanTextLength) { + // Unfortunately we cannot hardcode delimiters length here (aka spanTextLength + 4) + // because multiple inline markdown nodes can refer to the same text. + // For example, `**_~~hey~~_**` - we will receive `**_~~` in this method, + // and thus will have to manually find actual position in raw user input + final MarkwonEditorUtils.Match match = + MarkwonEditorUtils.findDelimited(input, spanStart, "**", "__"); + if (match != null) { + editable.setSpan( + // we handle StrongEmphasisSpan and represent it with Bold in EditText + // we still could use StrongEmphasisSpan, but it must be accessed + // via persistedSpans + persistedSpans.get(Bold.class), + match.start(), + match.end(), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ); + } + } + + @NonNull + @Override + public Class markdownSpanType() { + return StrongEmphasisSpan.class; + } +} + +class Bold extends MetricAffectingSpan { + public Bold() { + super(); + } + + @Override + public void updateDrawState(TextPaint tp) { + update(tp); + } + + @Override + public void updateMeasureState(@NonNull TextPaint textPaint) { + update(textPaint); + } + + private void update(@NonNull TextPaint paint) { + paint.setFakeBoldText(true); + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/editor/EditorAdditionalPluginSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/editor/EditorAdditionalPluginSample.java new file mode 100644 index 00000000..d435e358 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/editor/EditorAdditionalPluginSample.java @@ -0,0 +1,34 @@ +package io.noties.markwon.app.samples.editor; + +import io.noties.markwon.Markwon; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.samples.editor.shared.MarkwonEditTextSample; +import io.noties.markwon.editor.MarkwonEditor; +import io.noties.markwon.editor.MarkwonEditorTextWatcher; +import io.noties.markwon.ext.strikethrough.StrikethroughPlugin; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200629165347", + title = "Additional plugin", + description = "Additional plugin for editor", + artifacts = {MarkwonArtifact.EDITOR, MarkwonArtifact.INLINE_PARSER, MarkwonArtifact.EXT_STRIKETHROUGH}, + tags = {Tags.editor} +) +public class EditorAdditionalPluginSample extends MarkwonEditTextSample { + @Override + public void render() { + // As highlight works based on text-diff, everything that is present in input, + // but missing in resulting markdown is considered to be punctuation, this is why + // additional plugins do not need special handling + + final Markwon markwon = Markwon.builder(context) + .usePlugin(StrikethroughPlugin.create()) + .build(); + + final MarkwonEditor editor = MarkwonEditor.create(markwon); + + editText.addTextChangedListener(MarkwonEditorTextWatcher.withProcess(editor)); + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/editor/EditorCustomPunctuationSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/editor/EditorCustomPunctuationSample.java new file mode 100644 index 00000000..7ce708aa --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/editor/EditorCustomPunctuationSample.java @@ -0,0 +1,37 @@ +package io.noties.markwon.app.samples.editor; + +import android.text.style.ForegroundColorSpan; + +import io.noties.markwon.Markwon; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.samples.editor.shared.MarkwonEditTextSample; +import io.noties.markwon.editor.MarkwonEditor; +import io.noties.markwon.editor.MarkwonEditorTextWatcher; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200629164627", + title = "Custom punctuation span", + description = "Custom span for punctuation in editor", + artifacts = {MarkwonArtifact.EDITOR, MarkwonArtifact.INLINE_PARSER}, + tags = {Tags.editor, Tags.span} +) +public class EditorCustomPunctuationSample extends MarkwonEditTextSample { + @Override + public void render() { + // Use own punctuation span + + final MarkwonEditor editor = MarkwonEditor.builder(Markwon.create(context)) + .punctuationSpan(CustomPunctuationSpan.class, CustomPunctuationSpan::new) + .build(); + + editText.addTextChangedListener(MarkwonEditorTextWatcher.withProcess(editor)); + } +} + +class CustomPunctuationSpan extends ForegroundColorSpan { + CustomPunctuationSpan() { + super(0xFFFF0000); // RED + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/editor/EditorHeadingSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/editor/EditorHeadingSample.java new file mode 100644 index 00000000..915343f5 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/editor/EditorHeadingSample.java @@ -0,0 +1,32 @@ +package io.noties.markwon.app.samples.editor; + +import java.util.concurrent.Executors; + +import io.noties.markwon.Markwon; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.samples.editor.shared.MarkwonEditTextSample; +import io.noties.markwon.app.samples.editor.shared.HeadingEditHandler; +import io.noties.markwon.editor.MarkwonEditor; +import io.noties.markwon.editor.MarkwonEditorTextWatcher; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200630113954", + title = "Heading edit handler", + description = "Handling of heading node in editor", + artifacts = {MarkwonArtifact.EDITOR, MarkwonArtifact.INLINE_PARSER}, + tags = {Tags.editor} +) +public class EditorHeadingSample extends MarkwonEditTextSample { + @Override + public void render() { + final Markwon markwon = Markwon.create(context); + final MarkwonEditor editor = MarkwonEditor.builder(markwon) + .useEditHandler(new HeadingEditHandler()) + .build(); + + editText.addTextChangedListener(MarkwonEditorTextWatcher.withPreRender( + editor, Executors.newSingleThreadExecutor(), editText)); + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/editor/EditorMultipleEditSpansSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/editor/EditorMultipleEditSpansSample.java new file mode 100644 index 00000000..91e36ad1 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/editor/EditorMultipleEditSpansSample.java @@ -0,0 +1,87 @@ +package io.noties.markwon.app.samples.editor; + +import android.text.method.LinkMovementMethod; + +import androidx.annotation.NonNull; + +import org.commonmark.parser.InlineParserFactory; +import org.commonmark.parser.Parser; + +import java.util.concurrent.Executors; + +import io.noties.markwon.AbstractMarkwonPlugin; +import io.noties.markwon.Markwon; +import io.noties.markwon.SoftBreakAddsNewLinePlugin; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.samples.editor.shared.MarkwonEditTextSample; +import io.noties.markwon.app.samples.editor.shared.BlockQuoteEditHandler; +import io.noties.markwon.app.samples.editor.shared.CodeEditHandler; +import io.noties.markwon.app.samples.editor.shared.LinkEditHandler; +import io.noties.markwon.app.samples.editor.shared.StrikethroughEditHandler; +import io.noties.markwon.editor.MarkwonEditor; +import io.noties.markwon.editor.MarkwonEditorTextWatcher; +import io.noties.markwon.editor.handler.EmphasisEditHandler; +import io.noties.markwon.editor.handler.StrongEmphasisEditHandler; +import io.noties.markwon.ext.strikethrough.StrikethroughPlugin; +import io.noties.markwon.inlineparser.BangInlineProcessor; +import io.noties.markwon.inlineparser.EntityInlineProcessor; +import io.noties.markwon.inlineparser.HtmlInlineProcessor; +import io.noties.markwon.inlineparser.MarkwonInlineParser; +import io.noties.markwon.linkify.LinkifyPlugin; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200629165920", + title = "Multiple edit spans", + description = "Additional multiple edit spans for editor", + artifacts = {MarkwonArtifact.EDITOR, MarkwonArtifact.INLINE_PARSER}, + tags = {Tags.editor} +) +public class EditorMultipleEditSpansSample extends MarkwonEditTextSample { + @Override + public void render() { + + // for links to be clickable + editText.setMovementMethod(LinkMovementMethod.getInstance()); + + final InlineParserFactory inlineParserFactory = MarkwonInlineParser.factoryBuilder() + // no inline images will be parsed + .excludeInlineProcessor(BangInlineProcessor.class) + // no html tags will be parsed + .excludeInlineProcessor(HtmlInlineProcessor.class) + // no entities will be parsed (aka `&` etc) + .excludeInlineProcessor(EntityInlineProcessor.class) + .build(); + + final Markwon markwon = Markwon.builder(context) + .usePlugin(StrikethroughPlugin.create()) + .usePlugin(LinkifyPlugin.create()) + .usePlugin(new AbstractMarkwonPlugin() { + @Override + public void configureParser(@NonNull Parser.Builder builder) { + + // disable all commonmark-java blocks, only inlines will be parsed +// builder.enabledBlockTypes(Collections.emptySet()); + + builder.inlineParserFactory(inlineParserFactory); + } + }) + .usePlugin(SoftBreakAddsNewLinePlugin.create()) + .build(); + + final LinkEditHandler.OnClick onClick = (widget, link) -> markwon.configuration().linkResolver().resolve(widget, link); + + final MarkwonEditor editor = MarkwonEditor.builder(markwon) + .useEditHandler(new EmphasisEditHandler()) + .useEditHandler(new StrongEmphasisEditHandler()) + .useEditHandler(new StrikethroughEditHandler()) + .useEditHandler(new CodeEditHandler()) + .useEditHandler(new BlockQuoteEditHandler()) + .useEditHandler(new LinkEditHandler(onClick)) + .build(); + + editText.addTextChangedListener(MarkwonEditorTextWatcher.withPreRender( + editor, Executors.newSingleThreadExecutor(), editText)); + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/editor/EditorNewLineContinuationSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/editor/EditorNewLineContinuationSample.java new file mode 100644 index 00000000..f339ac1a --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/editor/EditorNewLineContinuationSample.java @@ -0,0 +1,158 @@ +package io.noties.markwon.app.samples.editor; + +import android.text.Editable; +import android.text.TextUtils; +import android.text.TextWatcher; + +import androidx.annotation.NonNull; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import io.noties.debug.Debug; +import io.noties.markwon.Markwon; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.samples.editor.shared.MarkwonEditTextSample; +import io.noties.markwon.editor.MarkwonEditor; +import io.noties.markwon.editor.MarkwonEditorTextWatcher; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200629170348", + title = "Editor new line continuation", + description = "Sample of how new line character can be handled " + + "in order to add a _continuation_, for example adding a new " + + "bullet list item if current line starts with one", + artifacts = {MarkwonArtifact.EDITOR, MarkwonArtifact.INLINE_PARSER}, + tags = {Tags.editor} +) +public class EditorNewLineContinuationSample extends MarkwonEditTextSample { + @Override + public void render() { + final Markwon markwon = Markwon.create(context); + final MarkwonEditor editor = MarkwonEditor.create(markwon); + + final TextWatcher textWatcher = MarkdownNewLine + .wrap(MarkwonEditorTextWatcher.withProcess(editor)); + + editText.addTextChangedListener(textWatcher); + } +} + +class MarkdownNewLine { + + @NonNull + static TextWatcher wrap(@NonNull TextWatcher textWatcher) { + return new NewLineTextWatcher(textWatcher); + } + + private MarkdownNewLine() { + } + + private static class NewLineTextWatcher implements TextWatcher { + + // NB! matches only bullet lists + private final Pattern RE = Pattern.compile("^( {0,3}[\\-+* ]+)(.+)*$"); + + private final TextWatcher wrapped; + + private boolean selfChange; + + // this content is pending to be inserted at the beginning + private String pendingNewLineContent; + private int pendingNewLineIndex; + + // mark current edited line for removal (range start/end) + private int clearLineStart; + private int clearLineEnd; + + NewLineTextWatcher(@NonNull TextWatcher wrapped) { + this.wrapped = wrapped; + } + + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + // no op + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + if (selfChange) { + return; + } + + // just one new character added + if (before == 0 + && count == 1 + && '\n' == s.charAt(start)) { + int end = -1; + for (int i = start - 1; i >= 0; i--) { + if ('\n' == s.charAt(i)) { + end = i + 1; + break; + } + } + + // start at the very beginning + if (end < 0) { + end = 0; + } + + final String pendingNewLineContent; + + final int clearLineStart; + final int clearLineEnd; + + final Matcher matcher = RE.matcher(s.subSequence(end, start)); + if (matcher.matches()) { + // if second group is empty -> remove new line + final String content = matcher.group(2); + Debug.e("new line, content: '%s'", content); + if (TextUtils.isEmpty(content)) { + // another empty new line, remove this start + clearLineStart = end; + clearLineEnd = start; + pendingNewLineContent = null; + } else { + pendingNewLineContent = matcher.group(1); + clearLineStart = clearLineEnd = 0; + } + } else { + pendingNewLineContent = null; + clearLineStart = clearLineEnd = 0; + } + this.pendingNewLineContent = pendingNewLineContent; + this.pendingNewLineIndex = start + 1; + this.clearLineStart = clearLineStart; + this.clearLineEnd = clearLineEnd; + } + } + + @Override + public void afterTextChanged(Editable s) { + if (selfChange) { + return; + } + + if (pendingNewLineContent != null || clearLineStart < clearLineEnd) { + selfChange = true; + try { + if (pendingNewLineContent != null) { + s.insert(pendingNewLineIndex, pendingNewLineContent); + pendingNewLineContent = null; + } else { + s.replace(clearLineStart, clearLineEnd, ""); + clearLineStart = clearLineEnd = 0; + } + } finally { + selfChange = false; + } + } + + // NB, we assume MarkdownEditor text watcher that only listens for this event, + // other text-watchers must be interested in other events also + wrapped.afterTextChanged(s); + } + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/editor/EditorPreRenderSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/editor/EditorPreRenderSample.java new file mode 100644 index 00000000..e81ab3e7 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/editor/EditorPreRenderSample.java @@ -0,0 +1,34 @@ +package io.noties.markwon.app.samples.editor; + +import java.util.concurrent.Executors; + +import io.noties.markwon.Markwon; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.samples.editor.shared.MarkwonEditTextSample; +import io.noties.markwon.editor.MarkwonEditor; +import io.noties.markwon.editor.MarkwonEditorTextWatcher; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200629164422", + title = "Editor with pre-render (async)", + description = "Editor functionality with highlight " + + "taking place in another thread", + artifacts = {MarkwonArtifact.EDITOR, MarkwonArtifact.INLINE_PARSER}, + tags = {Tags.editor} +) +public class EditorPreRenderSample extends MarkwonEditTextSample { + @Override + public void render() { + // Process highlight in background thread + + final Markwon markwon = Markwon.create(context); + final MarkwonEditor editor = MarkwonEditor.create(markwon); + + editText.addTextChangedListener(MarkwonEditorTextWatcher.withPreRender( + editor, + Executors.newCachedThreadPool(), + editText)); + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/editor/EditorSimpleSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/editor/EditorSimpleSample.java new file mode 100644 index 00000000..93f7445e --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/editor/EditorSimpleSample.java @@ -0,0 +1,32 @@ +package io.noties.markwon.app.samples.editor; + +import io.noties.markwon.Markwon; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.samples.editor.shared.MarkwonEditTextSample; +import io.noties.markwon.editor.MarkwonEditor; +import io.noties.markwon.editor.MarkwonEditorTextWatcher; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200629164227", + title = "Simple editor", + description = "Simple usage of editor with markdown highlight", + artifacts = {MarkwonArtifact.EDITOR, MarkwonArtifact.INLINE_PARSER}, + tags = {Tags.editor} +) +public class EditorSimpleSample extends MarkwonEditTextSample { + @Override + public void render() { + // Process highlight in-place (right after text has changed) + + // obtain Markwon instance + final Markwon markwon = Markwon.create(context); + + // create editor + final MarkwonEditor editor = MarkwonEditor.create(markwon); + + // set edit listener + editText.addTextChangedListener(MarkwonEditorTextWatcher.withProcess(editor)); + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/editor/shared/BlockQuoteEditHandler.java b/app-sample/src/main/java/io/noties/markwon/app/samples/editor/shared/BlockQuoteEditHandler.java new file mode 100644 index 00000000..69b31e44 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/editor/shared/BlockQuoteEditHandler.java @@ -0,0 +1,50 @@ +package io.noties.markwon.app.samples.editor.shared; + +import android.text.Editable; +import android.text.Spanned; + +import androidx.annotation.NonNull; + +import io.noties.markwon.Markwon; +import io.noties.markwon.core.MarkwonTheme; +import io.noties.markwon.core.spans.BlockQuoteSpan; +import io.noties.markwon.editor.EditHandler; +import io.noties.markwon.editor.PersistedSpans; + +public class BlockQuoteEditHandler implements EditHandler { + + private MarkwonTheme theme; + + @Override + public void init(@NonNull Markwon markwon) { + this.theme = markwon.configuration().theme(); + } + + @Override + public void configurePersistedSpans(@NonNull PersistedSpans.Builder builder) { + builder.persistSpan(BlockQuoteSpan.class, () -> new BlockQuoteSpan(theme)); + } + + @Override + public void handleMarkdownSpan( + @NonNull PersistedSpans persistedSpans, + @NonNull Editable editable, + @NonNull String input, + @NonNull BlockQuoteSpan span, + int spanStart, + int spanTextLength) { + // todo: here we should actually find a proper ending of a block quote... + editable.setSpan( + persistedSpans.get(BlockQuoteSpan.class), + spanStart, + spanStart + spanTextLength, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ); + } + + @NonNull + @Override + public Class markdownSpanType() { + return BlockQuoteSpan.class; + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/editor/shared/CodeEditHandler.java b/app-sample/src/main/java/io/noties/markwon/app/samples/editor/shared/CodeEditHandler.java new file mode 100644 index 00000000..c14d5a5e --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/editor/shared/CodeEditHandler.java @@ -0,0 +1,54 @@ +package io.noties.markwon.app.samples.editor.shared; + +import android.text.Editable; +import android.text.Spanned; + +import androidx.annotation.NonNull; + +import io.noties.markwon.Markwon; +import io.noties.markwon.core.MarkwonTheme; +import io.noties.markwon.core.spans.CodeSpan; +import io.noties.markwon.editor.EditHandler; +import io.noties.markwon.editor.MarkwonEditorUtils; +import io.noties.markwon.editor.PersistedSpans; + +public class CodeEditHandler implements EditHandler { + + private MarkwonTheme theme; + + @Override + public void init(@NonNull Markwon markwon) { + this.theme = markwon.configuration().theme(); + } + + @Override + public void configurePersistedSpans(@NonNull PersistedSpans.Builder builder) { + builder.persistSpan(CodeSpan.class, () -> new CodeSpan(theme)); + } + + @Override + public void handleMarkdownSpan( + @NonNull PersistedSpans persistedSpans, + @NonNull Editable editable, + @NonNull String input, + @NonNull CodeSpan span, + int spanStart, + int spanTextLength) { + final MarkwonEditorUtils.Match match = + MarkwonEditorUtils.findDelimited(input, spanStart, "`"); + if (match != null) { + editable.setSpan( + persistedSpans.get(CodeSpan.class), + match.start(), + match.end(), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ); + } + } + + @NonNull + @Override + public Class markdownSpanType() { + return CodeSpan.class; + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/editor/shared/HeadingEditHandler.java b/app-sample/src/main/java/io/noties/markwon/app/samples/editor/shared/HeadingEditHandler.java new file mode 100644 index 00000000..990d3995 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/editor/shared/HeadingEditHandler.java @@ -0,0 +1,82 @@ +package io.noties.markwon.app.samples.editor.shared; + +import android.text.Editable; +import android.text.Spanned; + +import androidx.annotation.NonNull; + +import io.noties.markwon.Markwon; +import io.noties.markwon.core.MarkwonTheme; +import io.noties.markwon.core.spans.HeadingSpan; +import io.noties.markwon.editor.EditHandler; +import io.noties.markwon.editor.PersistedSpans; + +public class HeadingEditHandler implements EditHandler { + + private MarkwonTheme theme; + + @Override + public void init(@NonNull Markwon markwon) { + this.theme = markwon.configuration().theme(); + } + + @Override + public void configurePersistedSpans(@NonNull PersistedSpans.Builder builder) { + builder + .persistSpan(Head1.class, () -> new Head1(theme)) + .persistSpan(Head2.class, () -> new Head2(theme)); + } + + @Override + public void handleMarkdownSpan( + @NonNull PersistedSpans persistedSpans, + @NonNull Editable editable, + @NonNull String input, + @NonNull HeadingSpan span, + int spanStart, + int spanTextLength + ) { + final Class type; + switch (span.getLevel()) { + case 1: + type = Head1.class; + break; + case 2: + type = Head2.class; + break; + default: + type = null; + } + + if (type != null) { + final int index = input.indexOf('\n', spanStart + spanTextLength); + final int end = index < 0 + ? input.length() + : index; + editable.setSpan( + persistedSpans.get(type), + spanStart, + end, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ); + } + } + + @NonNull + @Override + public Class markdownSpanType() { + return HeadingSpan.class; + } + + private static class Head1 extends HeadingSpan { + Head1(@NonNull MarkwonTheme theme) { + super(theme, 1); + } + } + + private static class Head2 extends HeadingSpan { + Head2(@NonNull MarkwonTheme theme) { + super(theme, 2); + } + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/editor/shared/LinkEditHandler.java b/app-sample/src/main/java/io/noties/markwon/app/samples/editor/shared/LinkEditHandler.java new file mode 100644 index 00000000..8b01ae3a --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/editor/shared/LinkEditHandler.java @@ -0,0 +1,90 @@ +package io.noties.markwon.app.samples.editor.shared; + +import android.text.Editable; +import android.text.Spanned; +import android.text.style.ClickableSpan; +import android.view.View; + +import androidx.annotation.NonNull; + +import io.noties.markwon.core.spans.LinkSpan; +import io.noties.markwon.editor.AbstractEditHandler; +import io.noties.markwon.editor.PersistedSpans; + +public class LinkEditHandler extends AbstractEditHandler { + + public interface OnClick { + void onClick(@NonNull View widget, @NonNull String link); + } + + private final OnClick onClick; + + public LinkEditHandler(@NonNull OnClick onClick) { + this.onClick = onClick; + } + + @Override + public void configurePersistedSpans(@NonNull PersistedSpans.Builder builder) { + builder.persistSpan(EditLinkSpan.class, () -> new EditLinkSpan(onClick)); + } + + @Override + public void handleMarkdownSpan( + @NonNull PersistedSpans persistedSpans, + @NonNull Editable editable, + @NonNull String input, + @NonNull LinkSpan span, + int spanStart, + int spanTextLength) { + + final EditLinkSpan editLinkSpan = persistedSpans.get(EditLinkSpan.class); + editLinkSpan.link = span.getLink(); + + // First first __letter__ to find link content (scheme start in URL, receiver in email address) + // NB! do not use phone number auto-link (via LinkifyPlugin) as we cannot guarantee proper link + // display. For example, we _could_ also look for a digit, but: + // * if phone number start with special symbol, we won't have it (`+`, `(`) + // * it might interfere with an ordered-list + int start = -1; + + for (int i = spanStart, length = input.length(); i < length; i++) { + if (Character.isLetter(input.charAt(i))) { + start = i; + break; + } + } + + if (start > -1) { + editable.setSpan( + editLinkSpan, + start, + start + spanTextLength, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ); + } + } + + @NonNull + @Override + public Class markdownSpanType() { + return LinkSpan.class; + } + + static class EditLinkSpan extends ClickableSpan { + + private final OnClick onClick; + + String link; + + EditLinkSpan(@NonNull OnClick onClick) { + this.onClick = onClick; + } + + @Override + public void onClick(@NonNull View widget) { + if (link != null) { + onClick.onClick(widget, link); + } + } + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/editor/shared/MarkwonEditTextSample.kt b/app-sample/src/main/java/io/noties/markwon/app/samples/editor/shared/MarkwonEditTextSample.kt new file mode 100644 index 00000000..fb90e293 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/editor/shared/MarkwonEditTextSample.kt @@ -0,0 +1,104 @@ +package io.noties.markwon.app.samples.editor.shared + +import android.content.Context +import android.text.SpannableStringBuilder +import android.text.Spanned +import android.text.style.StrikethroughSpan +import android.view.View +import android.widget.Button +import android.widget.EditText +import android.widget.TextView +import io.noties.markwon.app.R +import io.noties.markwon.app.sample.ui.MarkwonSample +import io.noties.markwon.core.spans.EmphasisSpan +import io.noties.markwon.core.spans.StrongEmphasisSpan +import java.util.ArrayList + +abstract class MarkwonEditTextSample : MarkwonSample() { + + protected lateinit var context: Context + protected lateinit var editText: EditText + + override val layoutResId: Int + get() = R.layout.sample_edit_text + + override fun onViewCreated(view: View) { + context = view.context + editText = view.findViewById(R.id.edit_text) + initBottomBar(view) + render() + } + + abstract fun render() + + private fun initBottomBar(view: View) { + // all except block-quote wraps if have selection, or inserts at current cursor position + val bold: Button = view.findViewById(R.id.bold) + val italic: Button = view.findViewById(R.id.italic) + val strike: Button = view.findViewById(R.id.strike) + val quote: Button = view.findViewById(R.id.quote) + val code: Button = view.findViewById(R.id.code) + + addSpan(bold, StrongEmphasisSpan()) + addSpan(italic, EmphasisSpan()) + addSpan(strike, StrikethroughSpan()) + + bold.setOnClickListener(InsertOrWrapClickListener(editText, "**")) + italic.setOnClickListener(InsertOrWrapClickListener(editText, "_")) + strike.setOnClickListener(InsertOrWrapClickListener(editText, "~~")) + code.setOnClickListener(InsertOrWrapClickListener(editText, "`")) + quote.setOnClickListener { + val start = editText.selectionStart + val end = editText.selectionEnd + if (start < 0) { + return@setOnClickListener + } + if (start == end) { + editText.text.insert(start, "> ") + } else { + // wrap the whole selected area in a quote + val newLines: MutableList = ArrayList(3) + newLines.add(start) + val text = editText.text.subSequence(start, end).toString() + var index = text.indexOf('\n') + while (index != -1) { + newLines.add(start + index + 1) + index = text.indexOf('\n', index + 1) + } + for (i in newLines.indices.reversed()) { + editText.text.insert(newLines[i], "> ") + } + } + } + } + + private fun addSpan(textView: TextView, vararg spans: Any) { + val builder = SpannableStringBuilder(textView.text) + val end = builder.length + for (span in spans) { + builder.setSpan(span, 0, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + textView.text = builder + } + + private class InsertOrWrapClickListener( + private val editText: EditText, + private val text: String + ) : View.OnClickListener { + override fun onClick(v: View) { + val start = editText.selectionStart + val end = editText.selectionEnd + if (start < 0) { + return + } + if (start == end) { + // insert at current position + editText.text.insert(start, text) + } else { + editText.text.insert(end, text) + editText.text.insert(start, text) + } + } + + } +} \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/editor/shared/StrikethroughEditHandler.java b/app-sample/src/main/java/io/noties/markwon/app/samples/editor/shared/StrikethroughEditHandler.java new file mode 100644 index 00000000..7893d970 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/editor/shared/StrikethroughEditHandler.java @@ -0,0 +1,45 @@ +package io.noties.markwon.app.samples.editor.shared; + +import android.text.Editable; +import android.text.Spanned; +import android.text.style.StrikethroughSpan; + +import androidx.annotation.NonNull; + +import io.noties.markwon.editor.AbstractEditHandler; +import io.noties.markwon.editor.MarkwonEditorUtils; +import io.noties.markwon.editor.PersistedSpans; + +public class StrikethroughEditHandler extends AbstractEditHandler { + + @Override + public void configurePersistedSpans(@NonNull PersistedSpans.Builder builder) { + builder.persistSpan(StrikethroughSpan.class, StrikethroughSpan::new); + } + + @Override + public void handleMarkdownSpan( + @NonNull PersistedSpans persistedSpans, + @NonNull Editable editable, + @NonNull String input, + @NonNull StrikethroughSpan span, + int spanStart, + int spanTextLength) { + final MarkwonEditorUtils.Match match = + MarkwonEditorUtils.findDelimited(input, spanStart, "~~"); + if (match != null) { + editable.setSpan( + persistedSpans.get(StrikethroughSpan.class), + match.start(), + match.end(), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ); + } + } + + @NonNull + @Override + public Class markdownSpanType() { + return StrikethroughSpan.class; + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/html/HtmlAlignSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/html/HtmlAlignSample.java new file mode 100644 index 00000000..c9e84e3e --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/html/HtmlAlignSample.java @@ -0,0 +1,87 @@ +package io.noties.markwon.app.samples.html; + +import android.text.Layout; +import android.text.style.AlignmentSpan; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.Collection; +import java.util.Collections; + +import io.noties.markwon.AbstractMarkwonPlugin; +import io.noties.markwon.Markwon; +import io.noties.markwon.MarkwonConfiguration; +import io.noties.markwon.RenderProps; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.html.HtmlPlugin; +import io.noties.markwon.html.HtmlTag; +import io.noties.markwon.html.tag.SimpleTagHandler; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200630114630", + title = "Align HTML tag", + description = "Implement custom HTML tag handling", + artifacts = MarkwonArtifact.HTML, + tags = {Tags.rendering, Tags.span, Tags.html} +) +public class HtmlAlignSample extends MarkwonTextViewSample { + @Override + public void render() { + final String md = "" + + "We are centered\n" + + "\n" + + "We are at the end\n" + + "\n" + + "We should be at the start\n" + + "\n"; + + + final Markwon markwon = Markwon.builder(context) + .usePlugin(HtmlPlugin.create()) + .usePlugin(new AbstractMarkwonPlugin() { + @Override + public void configure(@NonNull Registry registry) { + registry.require(HtmlPlugin.class, htmlPlugin -> htmlPlugin + .addHandler(new AlignTagHandler())); + } + }) + .build(); + + markwon.setMarkdown(textView, md); + } +} + +class AlignTagHandler extends SimpleTagHandler { + + @Nullable + @Override + public Object getSpans( + @NonNull MarkwonConfiguration configuration, + @NonNull RenderProps renderProps, + @NonNull HtmlTag tag) { + + final Layout.Alignment alignment; + + // html attribute without value, + if (tag.attributes().containsKey("center")) { + alignment = Layout.Alignment.ALIGN_CENTER; + } else if (tag.attributes().containsKey("end")) { + alignment = Layout.Alignment.ALIGN_OPPOSITE; + } else { + // empty value or any other will make regular alignment + alignment = Layout.Alignment.ALIGN_NORMAL; + } + + return new AlignmentSpan.Standard(alignment); + } + + @NonNull + @Override + public Collection supportedTags() { + return Collections.singleton("align"); + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/html/HtmlCenterTagSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/html/HtmlCenterTagSample.java new file mode 100644 index 00000000..bde623b9 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/html/HtmlCenterTagSample.java @@ -0,0 +1,115 @@ +package io.noties.markwon.app.samples.html; + +import android.text.Layout; +import android.text.style.AlignmentSpan; + +import androidx.annotation.NonNull; + +import java.util.Collection; +import java.util.Collections; + +import io.noties.debug.Debug; +import io.noties.markwon.Markwon; +import io.noties.markwon.MarkwonVisitor; +import io.noties.markwon.SpannableBuilder; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.app.samples.html.shared.IFrameHtmlPlugin; +import io.noties.markwon.html.HtmlPlugin; +import io.noties.markwon.html.HtmlTag; +import io.noties.markwon.html.MarkwonHtmlRenderer; +import io.noties.markwon.html.TagHandler; +import io.noties.markwon.image.ImagesPlugin; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200630120101", + title = "Center HTML tag", + description = "Handling of `center` HTML tag", + artifacts = {MarkwonArtifact.HTML, MarkwonArtifact.IMAGE}, + tags = {Tags.rendering, Tags.html} +) +public class HtmlCenterTagSample extends MarkwonTextViewSample { + @Override + public void render() { + final String html = "\n" + + "\n" + + "\n" + + "\n" + + "\n" + + "

\n" + + "

LiSA's Sword Art Online: Alicization OP Song \"ADAMAS\" Certified Platinum with 250,000 Downloads

\n" + + "

\n" + + "
The upper tune was already certified Gold one month after its digital release
\n" + + "

According to The Recording Industry Association of Japan (RIAJ)'s monthly report for April 2020, one of the LiSA's 14th single songs,\n" + + " \"ADAMAS\" (the first OP theme for the TV anime Sword Art Online:\n" + + " Alicization) has been certified Platinum for\n" + + " surpassing 250,000 downloads.

\n" + + "

 

\n" + + "

As a double A-side single with \"Akai Wana (who loves it?),\" \"ADAMAS\" was\n" + + " released from SACRA Music in Japan on December 12, 2018. Its CD single ranked second in Oricon's weekly single\n" + + " chart by selling 35,000 copies in its first week. Meanwhile, the song was released digitally two months prior to\n" + + " its CD release, October 8, then reached Gold (100,000 downloads) in the following month.

\n" + + "

 

\n" + + "

 

\n" + + "
\n" + + "

\"ADAMAS\" MV YouTube EDIT ver.:

\n" + + "

\n" + + "

\n" + + "

 

\n" + + "

Standard edition CD jacket:

\n" + + "

\"\"

\n" + + "
\n" + + "

  

\n" + + "
\n" + + "

 

\n" + + "

Source: RIAJ press release

\n" + + "

 

\n" + + "

©SACRA MUSIC

\n" + + "

 

\n" + + "

\n" + + "\n" + + "\n" + + ""; + + final Markwon markwon = Markwon.builder(context) + .usePlugin(HtmlPlugin.create(plugin -> + plugin.addHandler(new CenterTagHandler()))) + .usePlugin(new IFrameHtmlPlugin()) + .usePlugin(ImagesPlugin.create()) + .build(); + + markwon.setMarkdown(textView, html); + } +} + +class CenterTagHandler extends TagHandler { + + @Override + public void handle(@NonNull MarkwonVisitor visitor, @NonNull MarkwonHtmlRenderer renderer, @NonNull HtmlTag tag) { + Debug.e("center, isBlock: %s", tag.isBlock()); + if (tag.isBlock()) { + visitChildren(visitor, renderer, tag.getAsBlock()); + } + SpannableBuilder.setSpans( + visitor.builder(), + new AlignmentSpan.Standard(Layout.Alignment.ALIGN_CENTER), + tag.start(), + tag.end() + ); + } + + @NonNull + @Override + public Collection supportedTags() { + return Collections.singleton("center"); + } +} + diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/html/HtmlDetailsSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/html/HtmlDetailsSample.java new file mode 100644 index 00000000..231a195c --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/html/HtmlDetailsSample.java @@ -0,0 +1,428 @@ +package io.noties.markwon.app.samples.html; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Rect; +import android.text.Layout; +import android.text.Spanned; +import android.text.style.ClickableSpan; +import android.text.style.LeadingMarginSpan; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; + +import io.noties.markwon.Markwon; +import io.noties.markwon.MarkwonVisitor; +import io.noties.markwon.SpannableBuilder; +import io.noties.markwon.app.BuildConfig; +import io.noties.markwon.app.R; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonSample; +import io.noties.markwon.core.MarkwonTheme; +import io.noties.markwon.html.HtmlPlugin; +import io.noties.markwon.html.HtmlTag; +import io.noties.markwon.html.MarkwonHtmlRenderer; +import io.noties.markwon.html.TagHandler; +import io.noties.markwon.image.ImagesPlugin; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; +import io.noties.markwon.utils.LeadingMarginUtils; +import io.noties.markwon.utils.NoCopySpannableFactory; + +@MarkwonSampleInfo( + id = "20200630120752", + title = "Details HTML tag", + description = "Handling of `details` HTML tag", + artifacts = {MarkwonArtifact.HTML, MarkwonArtifact.IMAGE}, + tags = {Tags.image, Tags.rendering, Tags.html} +) +public class HtmlDetailsSample extends MarkwonSample { + + private Context context; + private ViewGroup content; + + @Override + protected int getLayoutResId() { + return R.layout.sample_html_details; + } + + @Override + public void onViewCreated(@NotNull View view) { + context = view.getContext(); + content = view.findViewById(R.id.content); + render(); + } + + private void render() { + final String md = "# Hello\n\n
\n" + + " stuff with \n\n*mark* **down**\n\n\n" + + "

\n\n" + + "\n" + + "## *formatted* **heading** with [a](link)\n" + + "```java\n" + + "code block\n" + + "```\n" + + "\n" + + "

\n" + + " nested stuff

\n" + + "\n" + + "\n" + + "* list\n" + + "* with\n" + + "\n\n" + + "![img](" + BuildConfig.GIT_REPOSITORY + "/raw/master/art/markwon_logo.png)\n\n" + + "" + + " 1. nested\n" + + " 1. items\n" + + "\n" + + " ```java\n" + + " // including code\n" + + " ```\n" + + " 1. blocks\n" + + "\n" + + "

The 3rd!\n\n" + + "**bold** _em_\n
" + + "

\n" + + "

\n\n" + + "and **this** *is* how..."; + + final Markwon markwon = Markwon.builder(context) + .usePlugin(HtmlPlugin.create(plugin -> + plugin.addHandler(new DetailsTagHandler()))) + .usePlugin(ImagesPlugin.create()) + .build(); + + final Spanned spanned = markwon.toMarkdown(md); + final DetailsParsingSpan[] spans = spanned.getSpans(0, spanned.length(), DetailsParsingSpan.class); + + // if we have no details, proceed as usual (single text-view) + if (spans == null || spans.length == 0) { + // no details + final TextView textView = appendTextView(); + markwon.setParsedMarkdown(textView, spanned); + return; + } + + final List list = new ArrayList<>(); + + for (DetailsParsingSpan span : spans) { + final DetailsElement e = settle(new DetailsElement(spanned.getSpanStart(span), spanned.getSpanEnd(span), span.summary), list); + if (e != null) { + list.add(e); + } + } + + for (DetailsElement element : list) { + initDetails(element, spanned); + } + + sort(list); + + + TextView textView; + int start = 0; + + for (DetailsElement element : list) { + + if (element.start != start) { + // subSequence and add new TextView + textView = appendTextView(); + textView.setText(subSequenceTrimmed(spanned, start, element.start)); + } + + // now add details TextView + textView = appendTextView(); + initDetailsTextView(markwon, textView, element); + + start = element.end; + } + + if (start != spanned.length()) { + // another textView with rest content + textView = appendTextView(); + textView.setText(subSequenceTrimmed(spanned, start, spanned.length())); + } + } + + @NonNull + private TextView appendTextView() { + final View view = LayoutInflater.from(context) + .inflate(R.layout.view_html_details_text_view, content, false); + final TextView textView = view.findViewById(R.id.text_view); + content.addView(view); + return textView; + } + + private void initDetailsTextView( + @NonNull Markwon markwon, + @NonNull TextView textView, + @NonNull DetailsElement element) { + + // minor optimization + textView.setSpannableFactory(NoCopySpannableFactory.getInstance()); + + // so, each element with children is a details tag + // there is a reason why we needed the SpannableBuilder in the first place -> we must revert spans +// final SpannableStringBuilder builder = new SpannableStringBuilder(); + final SpannableBuilder builder = new SpannableBuilder(); + append(builder, markwon, textView, element, element); + markwon.setParsedMarkdown(textView, builder.spannableStringBuilder()); + } + + private void append( + @NonNull SpannableBuilder builder, + @NonNull Markwon markwon, + @NonNull TextView textView, + @NonNull DetailsElement root, + @NonNull DetailsElement element) { + if (!element.children.isEmpty()) { + + final int start = builder.length(); + +// builder.append(element.content); + builder.append(subSequenceTrimmed(element.content, 0, element.content.length())); + + builder.setSpan(new ClickableSpan() { + @Override + public void onClick(@NonNull View widget) { + element.expanded = !element.expanded; + + initDetailsTextView(markwon, textView, root); + } + }, start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + if (element.expanded) { + for (DetailsElement child : element.children) { + append(builder, markwon, textView, root, child); + } + } + + builder.setSpan(new DetailsSpan(markwon.configuration().theme(), element), start); + + } else { + builder.append(element.content); + } + } + + // if null -> remove from where it was processed, + // else replace from where it was processed with a new one (can become expandable) + @Nullable + private static DetailsElement settle( + @NonNull DetailsElement element, + @NonNull List elements) { + for (DetailsElement e : elements) { + if (element.start > e.start && element.end <= e.end) { + final DetailsElement settled = settle(element, e.children); + if (settled != null) { + + // the thing is we must balance children if done like this + // let's just create a tree actually, so we are easier to modify + final Iterator iterator = e.children.iterator(); + while (iterator.hasNext()) { + final DetailsElement balanced = settle(iterator.next(), Collections.singletonList(element)); + if (balanced == null) { + iterator.remove(); + } + } + + // add to our children + e.children.add(element); + } + return null; + } + } + return element; + } + + private static void initDetails(@NonNull DetailsElement element, @NonNull Spanned spanned) { + int end = element.end; + for (int i = element.children.size() - 1; i >= 0; i--) { + final DetailsElement child = element.children.get(i); + if (child.end < end) { + element.children.add(new DetailsElement(child.end, end, spanned.subSequence(child.end, end))); + } + initDetails(child, spanned); + end = child.start; + } + + final int start = (element.start + element.content.length()); + if (end != start) { + element.children.add(new DetailsElement(start, end, spanned.subSequence(start, end))); + } + } + + private static void sort(@NonNull List elements) { + Collections.sort(elements, (o1, o2) -> Integer.compare(o1.start, o2.start)); + for (DetailsElement element : elements) { + sort(element.children); + } + } + + @NonNull + private static CharSequence subSequenceTrimmed(@NonNull CharSequence cs, int start, int end) { + + while (start < end) { + + final boolean isStartEmpty = Character.isWhitespace(cs.charAt(start)); + final boolean isEndEmpty = Character.isWhitespace(cs.charAt(end - 1)); + + if (!isStartEmpty && !isEndEmpty) { + break; + } + + if (isStartEmpty) { + start += 1; + } + if (isEndEmpty) { + end -= 1; + } + } + + return cs.subSequence(start, end); + } + + private static class DetailsElement { + + final int start; + final int end; + final CharSequence content; + final List children = new ArrayList<>(0); + + boolean expanded; + + DetailsElement(int start, int end, @NonNull CharSequence content) { + this.start = start; + this.end = end; + this.content = content; + } + + @Override + @NonNull + public String toString() { + return "DetailsElement{" + + "start=" + start + + ", end=" + end + + ", content=" + toStringContent(content) + + ", children=" + children + + ", expanded=" + expanded + + '}'; + } + + @NonNull + private static String toStringContent(@NonNull CharSequence cs) { + return cs.toString().replaceAll("\n", "\\n"); + } + } + + private static class DetailsTagHandler extends TagHandler { + + @Override + public void handle( + @NonNull MarkwonVisitor visitor, + @NonNull MarkwonHtmlRenderer renderer, + @NonNull HtmlTag tag) { + + int summaryEnd = -1; + + for (HtmlTag child : tag.getAsBlock().children()) { + + if (!child.isClosed()) { + continue; + } + + if ("summary".equals(child.name())) { + summaryEnd = child.end(); + } + + final TagHandler tagHandler = renderer.tagHandler(child.name()); + if (tagHandler != null) { + tagHandler.handle(visitor, renderer, child); + } else if (child.isBlock()) { + visitChildren(visitor, renderer, child.getAsBlock()); + } + } + + if (summaryEnd > -1) { + visitor.builder().setSpan(new DetailsParsingSpan( + subSequenceTrimmed(visitor.builder(), tag.start(), summaryEnd) + ), tag.start(), tag.end()); + } + } + + @NonNull + @Override + public Collection supportedTags() { + return Collections.singleton("details"); + } + } + + private static class DetailsParsingSpan { + + final CharSequence summary; + + DetailsParsingSpan(@NonNull CharSequence summary) { + this.summary = summary; + } + } + + private static class DetailsSpan implements LeadingMarginSpan { + + private final DetailsElement element; + private final int blockMargin; + private final int blockQuoteWidth; + + private final Rect rect = new Rect(); + private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); + + DetailsSpan(@NonNull MarkwonTheme theme, @NonNull DetailsElement element) { + this.element = element; + this.blockMargin = theme.getBlockMargin(); + this.blockQuoteWidth = theme.getBlockQuoteWidth(); + this.paint.setStyle(Paint.Style.FILL); + } + + @Override + public int getLeadingMargin(boolean first) { + return blockMargin; + } + + @Override + public void drawLeadingMargin(Canvas c, Paint p, int x, int dir, int top, int baseline, int bottom, CharSequence text, int start, int end, boolean first, Layout layout) { + + if (LeadingMarginUtils.selfStart(start, text, this)) { + rect.set(x, top, x + blockMargin, bottom); + if (element.expanded) { + paint.setColor(Color.GREEN); + } else { + paint.setColor(Color.RED); + } + paint.setStyle(Paint.Style.FILL); + c.drawRect(rect, paint); + + } else { + + if (element.expanded) { + final int l = (blockMargin - blockQuoteWidth) / 2; + rect.set(x + l, top, x + l + blockQuoteWidth, bottom); + paint.setStyle(Paint.Style.FILL); + paint.setColor(Color.GRAY); + c.drawRect(rect, paint); + } + } + } + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/html/HtmlDisableSanitizeSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/html/HtmlDisableSanitizeSample.java new file mode 100644 index 00000000..72fee0aa --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/html/HtmlDisableSanitizeSample.java @@ -0,0 +1,42 @@ +package io.noties.markwon.app.samples.html; + +import androidx.annotation.NonNull; + +import io.noties.markwon.AbstractMarkwonPlugin; +import io.noties.markwon.Markwon; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200630171424", + title = "Disable HTML", + description = "Disable HTML via replacing special `<` and `>` symbols", + artifacts = MarkwonArtifact.CORE, + tags = {Tags.html, Tags.rendering, Tags.parsing, Tags.plugin} +) +public class HtmlDisableSanitizeSample extends MarkwonTextViewSample { + @Override + public void render() { + final String md = "# Html disabled\n\n" + + "emphasis strong\n\n" + + "

paragraph

\n\n" + + "\n\n" + + ""; + + final Markwon markwon = Markwon.builder(context) + .usePlugin(new AbstractMarkwonPlugin() { + @NonNull + @Override + public String processMarkdown(@NonNull String markdown) { + return markdown + .replaceAll("<", "<") + .replaceAll(">", ">"); + } + }) + .build(); + + markwon.setMarkdown(textView, md); + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/html/HtmlEmptyTagReplacementSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/html/HtmlEmptyTagReplacementSample.java new file mode 100644 index 00000000..8617ef33 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/html/HtmlEmptyTagReplacementSample.java @@ -0,0 +1,47 @@ +package io.noties.markwon.app.samples.html; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import io.noties.markwon.Markwon; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.html.HtmlEmptyTagReplacement; +import io.noties.markwon.html.HtmlPlugin; +import io.noties.markwon.html.HtmlTag; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200630115725", + title = "HTML empty tag replacement", + description = "Render custom content when HTML tag contents is empty, " + + "in case of self-closed HTML tags or tags without content (closed " + + "right after opened)", + artifacts = MarkwonArtifact.HTML, + tags = {Tags.rendering, Tags.html} +) +public class HtmlEmptyTagReplacementSample extends MarkwonTextViewSample { + @Override + public void render() { + final String md = "" + + " the `` is replaced?"; + + final Markwon markwon = Markwon.builder(context) + .usePlugin(HtmlPlugin.create(plugin -> { + plugin.emptyTagReplacement(new HtmlEmptyTagReplacement() { + @Nullable + @Override + public String replace(@NonNull HtmlTag tag) { + if ("empty".equals(tag.name())) { + return "REPLACED_EMPTY_WITH_IT"; + } + return super.replace(tag); + } + }); + })) + .build(); + + markwon.setMarkdown(textView, md); + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/html/HtmlEnhanceSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/html/HtmlEnhanceSample.java new file mode 100644 index 00000000..8851bb60 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/html/HtmlEnhanceSample.java @@ -0,0 +1,102 @@ +package io.noties.markwon.app.samples.html; + +import android.text.TextUtils; +import android.text.style.AbsoluteSizeSpan; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.Px; + +import java.util.Collection; +import java.util.Collections; + +import io.noties.markwon.AbstractMarkwonPlugin; +import io.noties.markwon.Markwon; +import io.noties.markwon.MarkwonVisitor; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.html.HtmlPlugin; +import io.noties.markwon.html.HtmlTag; +import io.noties.markwon.html.MarkwonHtmlRenderer; +import io.noties.markwon.html.TagHandler; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200630115103", + title = "Enhance custom HTML tag", + description = "Custom HTML tag implementation " + + "that _enhances_ a part of text given start and end indices", + artifacts = MarkwonArtifact.HTML, + tags = {Tags.rendering, Tags.span, Tags.html} +) +public class HtmlEnhanceSample extends MarkwonTextViewSample { + @Override + public void render() { + final String md = "" + + "This is text that must be enhanced, at least a part of it"; + + + final Markwon markwon = Markwon.builder(context) + .usePlugin(HtmlPlugin.create()) + .usePlugin(new AbstractMarkwonPlugin() { + @Override + public void configure(@NonNull Registry registry) { + registry.require(HtmlPlugin.class, htmlPlugin -> htmlPlugin + .addHandler(new EnhanceTagHandler((int) (textView.getTextSize() * 2 + .05F)))); + } + }) + .build(); + + markwon.setMarkdown(textView, md); + } +} + +class EnhanceTagHandler extends TagHandler { + + private final int enhanceTextSize; + + EnhanceTagHandler(@Px int enhanceTextSize) { + this.enhanceTextSize = enhanceTextSize; + } + + @Override + public void handle( + @NonNull MarkwonVisitor visitor, + @NonNull MarkwonHtmlRenderer renderer, + @NonNull HtmlTag tag) { + + // we require start and end to be present + final int start = parsePosition(tag.attributes().get("start")); + final int end = parsePosition(tag.attributes().get("end")); + + if (start > -1 && end > -1) { + visitor.builder().setSpan( + new AbsoluteSizeSpan(enhanceTextSize), + tag.start() + start, + tag.start() + end + ); + } + } + + @NonNull + @Override + public Collection supportedTags() { + return Collections.singleton("enhance"); + } + + private static int parsePosition(@Nullable String value) { + int position; + if (!TextUtils.isEmpty(value)) { + try { + position = Integer.parseInt(value); + } catch (NumberFormatException e) { + e.printStackTrace(); + position = -1; + } + } else { + position = -1; + } + return position; + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/html/HtmlIFrameSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/html/HtmlIFrameSample.java new file mode 100644 index 00000000..cadac76e --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/html/HtmlIFrameSample.java @@ -0,0 +1,50 @@ +package io.noties.markwon.app.samples.html; + +import io.noties.markwon.Markwon; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.app.samples.html.shared.IFrameHtmlPlugin; +import io.noties.markwon.html.HtmlPlugin; +import io.noties.markwon.image.ImagesPlugin; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200630115521", + title = "IFrame HTML tag", + description = "Handling of `iframe` HTML tag", + artifacts = {MarkwonArtifact.HTML, MarkwonArtifact.IMAGE}, + tags = {Tags.image, Tags.rendering, Tags.html} +) +public class HtmlIFrameSample extends MarkwonTextViewSample { + @Override + public void render() { + final String md = "" + + "# Hello iframe\n\n" + + "

\"JUMP

\n" + + "

 

\n" + + "

Switch owners will soon get to take part in the ultimate Shonen Jump rumble. Bandai Namco announced plans to bring Jump Force to Switch as Jump Force Deluxe Edition, with a release set for sometime this year. This version will include all of the original playable characters and those from Character Pass 1, and Character Pass 2 is also in the works for all versions, starting with Shoto Todoroki from My Hero Academia.

\n" + + "

 

\n" + + "

Other than Todoroki, Bandai Namco hinted that the four other Character Pass 2 characters will hail from Hunter x Hunter, Yu Yu Hakusho, Bleach, and JoJo's Bizarre Adventure. Character Pass 2 will be priced at $17.99, and Todoroki launches this spring. 

\n" + + "

 

\n" + + "

\n" + + "

 

\n" + + "

Character Pass 2 promo:

\n" + + "

 

\n" + + "

\n" + + "

 

\n" + + "

\"\"

\n" + + "

 

\n" + + "

-------

\n" + + "

Joseph Luster is the Games and Web editor at Otaku USA Magazine. You can read his webcomic, BIG DUMB FIGHTING IDIOTS at subhumanzoids. Follow him on Twitter @Moldilox. 

"; + + final Markwon markwon = Markwon.builder(context) + .usePlugin(ImagesPlugin.create()) + .usePlugin(HtmlPlugin.create()) + .usePlugin(new IFrameHtmlPlugin()) + .build(); + + markwon.setMarkdown(textView, md); + } +} + diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/html/HtmlImageSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/html/HtmlImageSample.java new file mode 100644 index 00000000..fbf17470 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/html/HtmlImageSample.java @@ -0,0 +1,44 @@ +package io.noties.markwon.app.samples.html; + +import io.noties.markwon.Markwon; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.html.HtmlPlugin; +import io.noties.markwon.image.ImagesPlugin; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200630115300", + title = "Html images", + description = "Usage of HTML images", + artifacts = {MarkwonArtifact.HTML, MarkwonArtifact.IMAGE}, + tags = {Tags.image, Tags.rendering, Tags.html} +) +public class HtmlImageSample extends MarkwonTextViewSample { + @Override + public void render() { + // treat unclosed/void `img` tag as HTML inline + final String md = "" + + "## Try CommonMark\n" + + "\n" + + "Markwon IMG:\n" + + "\n" + + "![](https://upload.wikimedia.org/wikipedia/it/thumb/c/c5/GTA_2.JPG/220px-GTA_2.JPG)\n" + + "\n" + + "New lines...\n" + + "\n" + + "HTML IMG:\n" + + "\n" + + "\n" + + "\n" + + "New lines\n\n"; + + final Markwon markwon = Markwon.builder(context) + .usePlugin(ImagesPlugin.create()) + .usePlugin(HtmlPlugin.create()) + .build(); + + markwon.setMarkdown(textView, md); + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/html/HtmlRandomCharSize.java b/app-sample/src/main/java/io/noties/markwon/app/samples/html/HtmlRandomCharSize.java new file mode 100644 index 00000000..0e06ddab --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/html/HtmlRandomCharSize.java @@ -0,0 +1,86 @@ +package io.noties.markwon.app.samples.html; + +import android.text.style.AbsoluteSizeSpan; + +import androidx.annotation.NonNull; + +import java.util.Collection; +import java.util.Collections; +import java.util.Random; + +import io.noties.markwon.AbstractMarkwonPlugin; +import io.noties.markwon.Markwon; +import io.noties.markwon.MarkwonVisitor; +import io.noties.markwon.SpannableBuilder; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.html.HtmlPlugin; +import io.noties.markwon.html.HtmlTag; +import io.noties.markwon.html.MarkwonHtmlRenderer; +import io.noties.markwon.html.TagHandler; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200630114923", + title = "Random char size HTML tag", + description = "Implementation of a custom HTML tag handler " + + "that assigns each character a random size", + artifacts = MarkwonArtifact.HTML, + tags = {Tags.rendering, Tags.span, Tags.html} +) +public class HtmlRandomCharSize extends MarkwonTextViewSample { + @Override + public void render() { + final String md = "" + + "\n" + + "This message should have a jumpy feeling because of different sizes of characters\n" + + "\n\n"; + + final Markwon markwon = Markwon.builder(context) + .usePlugin(HtmlPlugin.create()) + .usePlugin(new AbstractMarkwonPlugin() { + @Override + public void configure(@NonNull Registry registry) { + registry.require(HtmlPlugin.class, htmlPlugin -> htmlPlugin + .addHandler(new RandomCharSize(new Random(42L), textView.getTextSize()))); + } + }) + .build(); + + markwon.setMarkdown(textView, md); + } +} + +class RandomCharSize extends TagHandler { + + private final Random random; + private final float base; + + RandomCharSize(@NonNull Random random, float base) { + this.random = random; + this.base = base; + } + + @Override + public void handle( + @NonNull MarkwonVisitor visitor, + @NonNull MarkwonHtmlRenderer renderer, + @NonNull HtmlTag tag) { + + final SpannableBuilder builder = visitor.builder(); + + // text content is already added, we should only apply spans + + for (int i = tag.start(), end = tag.end(); i < end; i++) { + final int size = (int) (base * (random.nextFloat() + 0.5F) + 0.5F); + builder.setSpan(new AbsoluteSizeSpan(size, false), i, i + 1); + } + } + + @NonNull + @Override + public Collection supportedTags() { + return Collections.singleton("random-char-size"); + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/html/shared/IFrameHtmlPlugin.java b/app-sample/src/main/java/io/noties/markwon/app/samples/html/shared/IFrameHtmlPlugin.java new file mode 100644 index 00000000..146287f0 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/html/shared/IFrameHtmlPlugin.java @@ -0,0 +1,52 @@ +package io.noties.markwon.app.samples.html.shared; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.commonmark.node.Image; + +import java.util.Collection; +import java.util.Collections; + +import io.noties.markwon.AbstractMarkwonPlugin; +import io.noties.markwon.MarkwonConfiguration; +import io.noties.markwon.RenderProps; +import io.noties.markwon.html.HtmlPlugin; +import io.noties.markwon.html.HtmlTag; +import io.noties.markwon.html.tag.SimpleTagHandler; +import io.noties.markwon.image.ImageProps; +import io.noties.markwon.image.ImageSize; + +public class IFrameHtmlPlugin extends AbstractMarkwonPlugin { + @Override + public void configure(@NonNull Registry registry) { + registry.require(HtmlPlugin.class, htmlPlugin -> + htmlPlugin.addHandler(new IFrameHtmlPlugin.EmbedTagHandler())); + } + + private static class EmbedTagHandler extends SimpleTagHandler { + + @Nullable + @Override + public Object getSpans(@NonNull MarkwonConfiguration configuration, @NonNull RenderProps renderProps, @NonNull HtmlTag tag) { + final ImageSize imageSize = new ImageSize( + new ImageSize.Dimension(640, "px"), + new ImageSize.Dimension(480, "px") + ); + ImageProps.IMAGE_SIZE.set(renderProps, imageSize); + + ImageProps.DESTINATION.set( + renderProps, + "https://img1.ak.crunchyroll.com/i/spire2/d7b1d6bc7563224388ef5ffc04a967581589950464_full.jpg"); + + return configuration.spansFactory().require(Image.class) + .getSpans(configuration, renderProps); + } + + @NonNull + @Override + public Collection supportedTags() { + return Collections.singleton("iframe"); + } + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/image/ErrorImageSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/image/ErrorImageSample.java new file mode 100644 index 00000000..a7eb0325 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/image/ErrorImageSample.java @@ -0,0 +1,44 @@ +package io.noties.markwon.app.samples.image; + +import android.graphics.drawable.Drawable; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; + +import io.noties.markwon.Markwon; +import io.noties.markwon.app.R; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.image.ImagesPlugin; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200630165828", + title = "Image error handler", + artifacts = MarkwonArtifact.IMAGE, + tags = Tags.image +) +public class ErrorImageSample extends MarkwonTextViewSample { + @Override + public void render() { + final String md = "" + + "![error](https://github.com/dcurtis/markdown-mark/raw/master/png/______1664x1024-solid.png)"; + + final Markwon markwon = Markwon.builder(context) + // error handler additionally allows to log/inspect errors during image loading + .usePlugin(ImagesPlugin.create(plugin -> { + plugin.errorHandler(new ImagesPlugin.ErrorHandler() { + @Nullable + @Override + public Drawable handleError(@NonNull String url, @NonNull Throwable throwable) { + return ContextCompat.getDrawable(context, R.drawable.ic_home_black_36dp); + } + }); + })) + .build(); + + markwon.setMarkdown(textView, md); + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/image/GifImageSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/image/GifImageSample.java new file mode 100644 index 00000000..8a1dc71c --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/image/GifImageSample.java @@ -0,0 +1,33 @@ +package io.noties.markwon.app.samples.image; + +import io.noties.markwon.Markwon; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.image.ImagesPlugin; +import io.noties.markwon.image.gif.GifMediaDecoder; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200630162214", + title = "GIF image", + artifacts = MarkwonArtifact.IMAGE, + tags = {Tags.image, Tags.gif} +) +public class GifImageSample extends MarkwonTextViewSample { + @Override + public void render() { + final String md = "" + + "![gif-image](https://upload.wikimedia.org/wikipedia/commons/2/2c/Rotating_earth_%28large%29.gif)"; + + final Markwon markwon = Markwon.builder(context) + // GIF is handled by default if library is used in the app +// .usePlugin(ImagesPlugin.create()) + .usePlugin(ImagesPlugin.create(plugin -> { + plugin.addMediaDecoder(GifMediaDecoder.create()); + })) + .build(); + + markwon.setMarkdown(textView, md); + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/image/GlideImageSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/image/GlideImageSample.java new file mode 100644 index 00000000..e16b449a --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/image/GlideImageSample.java @@ -0,0 +1,27 @@ +package io.noties.markwon.app.samples.image; + +import io.noties.markwon.Markwon; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.image.glide.GlideImagesPlugin; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200630170112", + title = "Glide image", + artifacts = MarkwonArtifact.IMAGE_GLIDE, + tags = Tags.image +) +public class GlideImageSample extends MarkwonTextViewSample { + @Override + public void render() { + final String md = "[![undefined](https://img.youtube.com/vi/gs1I8_m4AOM/0.jpg)](https://www.youtube.com/watch?v=gs1I8_m4AOM)"; + + final Markwon markwon = Markwon.builder(context) + .usePlugin(GlideImagesPlugin.create(context)) + .build(); + + markwon.setMarkdown(textView, md); + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/image/GlidePlaceholderImageSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/image/GlidePlaceholderImageSample.java new file mode 100644 index 00000000..3b753e15 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/image/GlidePlaceholderImageSample.java @@ -0,0 +1,58 @@ +package io.noties.markwon.app.samples.image; + +import android.content.Context; +import android.graphics.drawable.Drawable; + +import androidx.annotation.NonNull; + +import com.bumptech.glide.Glide; +import com.bumptech.glide.RequestBuilder; +import com.bumptech.glide.request.target.Target; + +import io.noties.markwon.Markwon; +import io.noties.markwon.app.R; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.image.AsyncDrawable; +import io.noties.markwon.image.glide.GlideImagesPlugin; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200630170241", + title = "Glide image with placeholder", + artifacts = MarkwonArtifact.IMAGE_GLIDE, + tags = Tags.image +) +public class GlidePlaceholderImageSample extends MarkwonTextViewSample { + @Override + public void render() { + final String md = "[![undefined](https://img.youtube.com/vi/gs1I8_m4AOM/0.jpg)](https://www.youtube.com/watch?v=gs1I8_m4AOM)"; + + final Context context = this.context; + + final Markwon markwon = Markwon.builder(context) + .usePlugin(GlideImagesPlugin.create(new GlideImagesPlugin.GlideStore() { + @NonNull + @Override + public RequestBuilder load(@NonNull AsyncDrawable drawable) { +// final Drawable placeholder = ContextCompat.getDrawable(context, R.drawable.ic_home_black_36dp); +// placeholder.setBounds(0, 0, 100, 100); + return Glide.with(context) + .load(drawable.getDestination()) +// .placeholder(ContextCompat.getDrawable(context, R.drawable.ic_home_black_36dp)); +// .placeholder(placeholder); + .placeholder(R.drawable.ic_home_black_36dp); + } + + @Override + public void cancel(@NonNull Target target) { + Glide.with(context) + .clear(target); + } + })) + .build(); + + markwon.setMarkdown(textView, md); + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/image/ImageSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/image/ImageSample.java new file mode 100644 index 00000000..34c42593 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/image/ImageSample.java @@ -0,0 +1,28 @@ +package io.noties.markwon.app.samples.image; + +import io.noties.markwon.Markwon; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.image.ImagesPlugin; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200630144659", + title = "Markdown image", + artifacts = MarkwonArtifact.IMAGE, + tags = Tags.image +) +public class ImageSample extends MarkwonTextViewSample { + @Override + public void render() { + final String md = "" + + "![image](https://github.com/dcurtis/markdown-mark/raw/master/png/208x128-solid.png)"; + + final Markwon markwon = Markwon.builder(context) + .usePlugin(ImagesPlugin.create()) + .build(); + + markwon.setMarkdown(textView, md); + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/image/ImagesCustomSchemeSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/image/ImagesCustomSchemeSample.java new file mode 100644 index 00000000..e7eaf3c8 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/image/ImagesCustomSchemeSample.java @@ -0,0 +1,73 @@ +package io.noties.markwon.app.samples.image; + +import android.net.Uri; + +import androidx.annotation.NonNull; + +import java.util.Collection; +import java.util.Collections; + +import io.noties.markwon.AbstractMarkwonPlugin; +import io.noties.markwon.Markwon; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.image.ImageItem; +import io.noties.markwon.image.ImagesPlugin; +import io.noties.markwon.image.SchemeHandler; +import io.noties.markwon.image.network.NetworkSchemeHandler; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200629124201", + title = "Image destination custom scheme", + description = "Example of handling custom scheme " + + "(`https`, `ftp`, `whatever`, etc.) for images destination URLs " + + "with `ImagesPlugin`", + artifacts = {MarkwonArtifact.IMAGE}, + tags = {Tags.image} +) +public class ImagesCustomSchemeSample extends MarkwonTextViewSample { + @Override + public void render() { + final String md = "![image](myownscheme://en.wikipedia.org/static/images/project-logos/enwiki-2x.png)"; + + final Markwon markwon = Markwon.builder(context) + .usePlugin(ImagesPlugin.create()) + .usePlugin(new AbstractMarkwonPlugin() { + @Override + public void configure(@NonNull Registry registry) { + + // use registry.require to obtain a plugin, does also + // a runtime validation if this plugin is registered + registry.require(ImagesPlugin.class, plugin -> plugin.addSchemeHandler(new SchemeHandler() { + + // it's a sample only, most likely you won't need to + // use existing scheme-handler, this for demonstration purposes only + final NetworkSchemeHandler handler = NetworkSchemeHandler.create(); + + @NonNull + @Override + public ImageItem handle(@NonNull String raw, @NonNull Uri uri) { + // just replace it with https for the sack of sample + final String url = raw.replace("myownscheme", "https"); + return handler.handle(url, Uri.parse(url)); + } + + @NonNull + @Override + public Collection supportedSchemes() { + return Collections.singleton("myownscheme"); + } + })); + } + }) + // or we can init plugin with this factory method +// .usePlugin(ImagesPlugin.create(plugin -> { +// plugin.addSchemeHandler(/**/) +// })) + .build(); + + markwon.setMarkdown(textView, md); + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/image/PlaceholderImageSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/image/PlaceholderImageSample.java new file mode 100644 index 00000000..30d0ab28 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/image/PlaceholderImageSample.java @@ -0,0 +1,46 @@ +package io.noties.markwon.app.samples.image; + +import android.graphics.drawable.Drawable; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; + +import io.noties.markwon.Markwon; +import io.noties.markwon.app.R; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.image.AsyncDrawable; +import io.noties.markwon.image.ImagesPlugin; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200630165504", + title = "Image with placeholder", + artifacts = MarkwonArtifact.IMAGE, + tags = Tags.image +) +public class PlaceholderImageSample extends MarkwonTextViewSample { + @Override + public void render() { + final String md = "" + + "![image](https://github.com/dcurtis/markdown-mark/raw/master/png/1664x1024-solid.png)"; + + final Markwon markwon = Markwon.builder(context) + .usePlugin(ImagesPlugin.create(plugin -> { + plugin.placeholderProvider(new ImagesPlugin.PlaceholderProvider() { + @Nullable + @Override + public Drawable providePlaceholder(@NonNull AsyncDrawable drawable) { + // by default drawable intrinsic size will be used + // otherwise bounds can be applied explicitly + return ContextCompat.getDrawable(context, R.drawable.ic_android_black_24dp); + } + }); + })) + .build(); + + markwon.setMarkdown(textView, md); + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/image/SvgImageSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/image/SvgImageSample.java new file mode 100644 index 00000000..01b39205 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/image/SvgImageSample.java @@ -0,0 +1,38 @@ +package io.noties.markwon.app.samples.image; + +import io.noties.markwon.Markwon; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.image.ImagesPlugin; +import io.noties.markwon.image.svg.SvgPictureMediaDecoder; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200630161952", + title = "SVG image", + artifacts = MarkwonArtifact.IMAGE, + tags = {Tags.image, Tags.svg} +) +public class SvgImageSample extends MarkwonTextViewSample { + @Override + public void render() { + final String md = "" + + "![svg-image](https://github.com/dcurtis/markdown-mark/raw/master/svg/markdown-mark-solid.svg)"; + + final Markwon markwon = Markwon.builder(context) + // SVG and GIF are automatically handled if required + // libraries are in path (specified in dependencies block) +// .usePlugin(ImagesPlugin.create()) + // let's make it implicit + .usePlugin(ImagesPlugin.create(plugin -> { + // there 2 svg media decoders: + // - regular `SvgMediaDecoder` + // - special one when SVG doesn't have width and height specified - `SvgPictureMediaDecoder` + plugin.addMediaDecoder(SvgPictureMediaDecoder.create()); + })) + .build(); + + markwon.setMarkdown(textView, md); + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/inlineparsing/InlineParsingDisableCodeSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/inlineparsing/InlineParsingDisableCodeSample.java new file mode 100644 index 00000000..b5496e77 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/inlineparsing/InlineParsingDisableCodeSample.java @@ -0,0 +1,70 @@ +package io.noties.markwon.app.samples.inlineparsing; + +import androidx.annotation.NonNull; + +import org.commonmark.node.Block; +import org.commonmark.node.FencedCodeBlock; +import org.commonmark.node.IndentedCodeBlock; +import org.commonmark.parser.InlineParserFactory; +import org.commonmark.parser.Parser; + +import java.util.HashSet; +import java.util.Set; + +import io.noties.markwon.AbstractMarkwonPlugin; +import io.noties.markwon.Markwon; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.core.CorePlugin; +import io.noties.markwon.inlineparser.BackticksInlineProcessor; +import io.noties.markwon.inlineparser.MarkwonInlineParser; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200630170607", + title = "Disable code inline parsing", + artifacts = MarkwonArtifact.INLINE_PARSER, + tags = {Tags.inline, Tags.parsing} +) +public class InlineParsingDisableCodeSample extends MarkwonTextViewSample { + @Override + public void render() { + // parses all as usual, but ignores code (inline and block) + + final String md = "# Head!\n\n" + + "* one\n" + + "+ two\n\n" + + "and **bold** to `you`!\n\n" + + "> a quote _em_\n\n" + + "```java\n" + + "final int i = 0;\n" + + "```\n\n" + + "**Good day!**"; + + final InlineParserFactory inlineParserFactory = MarkwonInlineParser.factoryBuilder() + .excludeInlineProcessor(BackticksInlineProcessor.class) + .build(); + + final Set> enabledBlocks = new HashSet>() {{ + // IndentedCodeBlock.class and FencedCodeBlock.class are missing + addAll(CorePlugin.enabledBlockTypes()); + + remove(FencedCodeBlock.class); + remove(IndentedCodeBlock.class); + }}; + + final Markwon markwon = Markwon.builder(context) + .usePlugin(new AbstractMarkwonPlugin() { + @Override + public void configureParser(@NonNull Parser.Builder builder) { + builder + .inlineParserFactory(inlineParserFactory) + .enabledBlockTypes(enabledBlocks); + } + }) + .build(); + + markwon.setMarkdown(textView, md); + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/inlineparsing/InlineParsingLinksOnlySample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/inlineparsing/InlineParsingLinksOnlySample.java new file mode 100644 index 00000000..81bf7774 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/inlineparsing/InlineParsingLinksOnlySample.java @@ -0,0 +1,55 @@ +package io.noties.markwon.app.samples.inlineparsing; + +import androidx.annotation.NonNull; + +import org.commonmark.parser.InlineParserFactory; +import org.commonmark.parser.Parser; + +import io.noties.markwon.AbstractMarkwonPlugin; +import io.noties.markwon.Markwon; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.inlineparser.CloseBracketInlineProcessor; +import io.noties.markwon.inlineparser.MarkwonInlineParser; +import io.noties.markwon.inlineparser.OpenBracketInlineProcessor; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200630170412", + title = "Links only inline parsing", + artifacts = MarkwonArtifact.INLINE_PARSER, + tags = {Tags.parsing, Tags.inline} +) +public class InlineParsingLinksOnlySample extends MarkwonTextViewSample { + @Override + public void render() { + // note that image is considered a link now + final String md = "**bold_bold-italic_** html-u, [link](#) ![alt](#image) `code`"; + + // create an inline-parser-factory that will _ONLY_ parse links + // this would mean: + // * no emphasises (strong and regular aka bold and italics), + // * no images, + // * no code, + // * no HTML entities (&) + // * no HTML tags + // markdown blocks are still parsed + final InlineParserFactory inlineParserFactory = MarkwonInlineParser.factoryBuilderNoDefaults() + .referencesEnabled(true) + .addInlineProcessor(new OpenBracketInlineProcessor()) + .addInlineProcessor(new CloseBracketInlineProcessor()) + .build(); + + final Markwon markwon = Markwon.builder(context) + .usePlugin(new AbstractMarkwonPlugin() { + @Override + public void configureParser(@NonNull Parser.Builder builder) { + builder.inlineParserFactory(inlineParserFactory); + } + }) + .build(); + + markwon.setMarkdown(textView, md); + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/inlineparsing/InlineParsingNoDefaultsSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/inlineparsing/InlineParsingNoDefaultsSample.java new file mode 100644 index 00000000..ddd2fd01 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/inlineparsing/InlineParsingNoDefaultsSample.java @@ -0,0 +1,45 @@ +package io.noties.markwon.app.samples.inlineparsing; + +import androidx.annotation.NonNull; + +import io.noties.markwon.AbstractMarkwonPlugin; +import io.noties.markwon.Markwon; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.inlineparser.BackticksInlineProcessor; +import io.noties.markwon.inlineparser.MarkwonInlineParser; +import io.noties.markwon.inlineparser.MarkwonInlineParserPlugin; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200630170823", + title = "Inline parsing no defaults", + description = "Parsing only inline code and disable all the rest", + artifacts = MarkwonArtifact.INLINE_PARSER, + tags = {Tags.inline, Tags.parsing} +) +public class InlineParsingNoDefaultsSample extends MarkwonTextViewSample { + @Override + public void render() { + // a plugin with NO defaults registered + + final String md = "no [links](#) for **you** `code`!"; + + final Markwon markwon = Markwon.builder(context) + // pass `MarkwonInlineParser.factoryBuilderNoDefaults()` no disable all + .usePlugin(MarkwonInlineParserPlugin.create(MarkwonInlineParser.factoryBuilderNoDefaults())) + .usePlugin(new AbstractMarkwonPlugin() { + @Override + public void configure(@NonNull Registry registry) { + registry.require(MarkwonInlineParserPlugin.class, plugin -> { + plugin.factoryBuilder() + .addInlineProcessor(new BackticksInlineProcessor()); + }); + } + }) + .build(); + + markwon.setMarkdown(textView, md); + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/inlineparsing/InlineParsingNoHtmlSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/inlineparsing/InlineParsingNoHtmlSample.java new file mode 100644 index 00000000..80a9ef02 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/inlineparsing/InlineParsingNoHtmlSample.java @@ -0,0 +1,59 @@ +package io.noties.markwon.app.samples.inlineparsing; + +import androidx.annotation.NonNull; + +import org.commonmark.node.Block; +import org.commonmark.node.HtmlBlock; +import org.commonmark.parser.Parser; + +import java.util.Set; + +import io.noties.markwon.AbstractMarkwonPlugin; +import io.noties.markwon.Markwon; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.core.CorePlugin; +import io.noties.markwon.inlineparser.HtmlInlineProcessor; +import io.noties.markwon.inlineparser.MarkwonInlineParserPlugin; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200630171239", + title = "Inline parsing exclude HTML", + artifacts = MarkwonArtifact.INLINE_PARSER, + tags = {Tags.parsing, Tags.inline, Tags.block} +) +public class InlineParsingNoHtmlSample extends MarkwonTextViewSample { + @Override + public void render() { + final String md = "# Html disabled\n\n" + + "emphasis strong\n\n" + + "

paragraph

\n\n" + + "\n\n" + + ""; + + final Markwon markwon = Markwon.builder(context) + .usePlugin(MarkwonInlineParserPlugin.create()) + .usePlugin(new AbstractMarkwonPlugin() { + @Override + public void configure(@NonNull Registry registry) { + registry.require(MarkwonInlineParserPlugin.class, plugin -> { + plugin.factoryBuilder() + .excludeInlineProcessor(HtmlInlineProcessor.class); + }); + } + + @Override + public void configureParser(@NonNull Parser.Builder builder) { + final Set> blocks = CorePlugin.enabledBlockTypes(); + blocks.remove(HtmlBlock.class); + + builder.enabledBlockTypes(blocks); + } + }) + .build(); + + markwon.setMarkdown(textView, md); + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/inlineparsing/InlineParsingTooltipSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/inlineparsing/InlineParsingTooltipSample.java new file mode 100644 index 00000000..24d9f159 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/inlineparsing/InlineParsingTooltipSample.java @@ -0,0 +1,202 @@ +package io.noties.markwon.app.samples.inlineparsing; + +import android.app.Activity; +import android.graphics.Point; +import android.text.Layout; +import android.text.Spannable; +import android.text.TextPaint; +import android.text.style.ClickableSpan; +import android.view.Gravity; +import android.view.View; +import android.view.Window; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.commonmark.node.CustomNode; +import org.commonmark.node.Node; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import io.noties.markwon.AbstractMarkwonPlugin; +import io.noties.markwon.Markwon; +import io.noties.markwon.MarkwonVisitor; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.inlineparser.InlineProcessor; +import io.noties.markwon.inlineparser.MarkwonInlineParserPlugin; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200630195409", + title = "Tooltip with inline parser", + artifacts = MarkwonArtifact.INLINE_PARSER, + tags = {Tags.parsing, Tags.rendering} +) +public class InlineParsingTooltipSample extends MarkwonTextViewSample { + @Override + public void render() { + // NB! tooltip contents cannot have new lines + final String md = "" + + "\n" + + "\n" + + "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi vitae enim ut sem aliquet ultrices. Nunc a accumsan orci. Suspendisse tortor ante, lacinia ac scelerisque sed, dictum eget metus. Morbi ante augue, tristique eget quam in, vestibulum rutrum lacus. Nulla aliquam auctor cursus. Nulla at lacus condimentum, viverra lacus eget, sollicitudin ex. Cras efficitur leo dui, sit amet rutrum tellus venenatis et. Sed in facilisis libero. Etiam ultricies, nulla ut venenatis tincidunt, tortor erat tristique ante, non aliquet massa arcu eget nisl. Etiam gravida erat ante, sit amet lobortis mauris commodo nec. Praesent vitae sodales quam. Vivamus condimentum porta suscipit. Donec posuere id felis ac scelerisque. Vestibulum lacinia et leo id lobortis. Sed vitae dolor nec ligula dapibus finibus vel eu libero. Nam tincidunt maximus elit, sit amet tincidunt lacus laoreet malesuada.\n" + + "\n" + + "Aenean at urna leo. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Nulla facilisi. Integer lectus elit, congue a orci sed, dignissim sagittis sem. Aenean et pretium magna, nec ornare justo. Sed quis nunc blandit, luctus justo eget, pellentesque arcu. Pellentesque porta semper tristique. Donec et odio arcu. Nullam ultrices gravida congue. Praesent vel leo sed orci tempor luctus. Vivamus eget tortor arcu. Nullam sapien nulla, iaculis sit amet semper in, mattis nec metus. In porttitor augue id elit euismod mattis. Ut est justo, dapibus suscipit erat eu, pellentesque porttitor magna.\n" + + "\n" + + "Nunc porta orci eget dictum malesuada. Donec vehicula felis sit amet leo tincidunt placerat. Cras quis elit faucibus, porta elit at, sodales tortor. Donec elit mi, eleifend et maximus vitae, pretium varius velit. Integer maximus egestas urna, at semper augue egestas vitae. Phasellus arcu tellus, tincidunt eget tellus nec, hendrerit mollis mauris. Pellentesque commodo urna quis nisi ultrices, quis vehicula felis ultricies. Vivamus eu feugiat leo.\n" + + "\n" + + "Etiam sit amet lorem et eros suscipit rhoncus a a tellus. Sed pharetra dui purus, quis molestie leo congue nec. Suspendisse sed scelerisque quam. Vestibulum non laoreet felis. Fusce interdum euismod purus at scelerisque. Vivamus tempus varius nibh, sed accumsan nisl interdum non. Pellentesque rutrum egestas eros sit amet sollicitudin. Vivamus ultrices est erat. Curabitur gravida justo non felis euismod mollis. Ut porta finibus nulla, sed pellentesque purus euismod ac.\n" + + "\n" + + "Aliquam erat volutpat. Nullam suscipit sit amet tortor vel fringilla. Nulla facilisi. Nullam lacinia ex lacus, sit amet scelerisque justo semper a. Nullam ullamcorper, erat ac malesuada porta, augue erat sagittis mi, in auctor turpis mauris nec orci. Nunc sit amet felis placerat, pharetra diam nec, dapibus metus. Proin nulla orci, iaculis vitae vulputate vel, placerat ac erat. Morbi sit amet blandit velit. Cras consectetur vehicula lacus vel sagittis. Nunc tincidunt lacus in blandit faucibus. Curabitur vestibulum auctor vehicula. Sed quis ligula sit amet quam venenatis venenatis eget id felis. Maecenas feugiat nisl elit, facilisis tempus risus malesuada quis. " + + "# Hello tooltip!\n\n" + + "This is the !{tooltip label}(and actual content comes here)\n\n" + + "what if it is !{here}(The contents can be blocks, limited though) instead?\n\n" + + "![image](#) anyway"; + + final Markwon markwon = Markwon.builder(context) + .usePlugin(MarkwonInlineParserPlugin.create(factoryBuilder -> + factoryBuilder.addInlineProcessor(new TooltipInlineProcessor()))) + .usePlugin(new AbstractMarkwonPlugin() { + @Override + public void configureVisitor(@NonNull MarkwonVisitor.Builder builder) { + builder.on(TooltipNode.class, (visitor, tooltipNode) -> { + final int start = visitor.length(); + visitor.builder().append(tooltipNode.label); + visitor.setSpans(start, new TooltipSpan(tooltipNode.contents)); + }); + } + }) + .build(); + + markwon.setMarkdown(textView, md); + } +} + +class TooltipInlineProcessor extends InlineProcessor { + + // NB! without bang + // `\\{` is required (although marked as redundant), without it - runtime crash + @SuppressWarnings("RegExpRedundantEscape") + private static final Pattern RE = Pattern.compile("\\{(.+?)\\}\\((.+?)\\)"); + + @Override + public char specialCharacter() { + return '!'; + } + + @Nullable + @Override + protected Node parse() { + final String match = match(RE); + if (match == null) { + return null; + } + + final Matcher matcher = RE.matcher(match); + if (matcher.matches()) { + final String label = matcher.group(1); + final String contents = matcher.group(2); + return new TooltipNode(label, contents); + } + + return null; + } +} + +class TooltipNode extends CustomNode { + final String label; + final String contents; + + TooltipNode(@NonNull String label, @NonNull String contents) { + this.label = label; + this.contents = contents; + } +} + +class TooltipSpan extends ClickableSpan { + final String contents; + + TooltipSpan(@NonNull String contents) { + this.contents = contents; + } + + @Override + public void onClick(@NonNull View widget) { + // just to be safe + if (!(widget instanceof TextView)) { + return; + } + + final TextView textView = (TextView) widget; + final Spannable spannable = (Spannable) textView.getText(); + + // find self ending position (can also obtain start) +// final int start = spannable.getSpanStart(this); + final int end = spannable.getSpanEnd(this); + + // weird, didn't find self + if (/*start < 0 ||*/ end < 0) { + return; + } + + final Layout layout = textView.getLayout(); + if (layout == null) { + // also weird + return; + } + + final int line = layout.getLineForOffset(end); + + // position inside TextView, these values must also be adjusted to parent widget + // also note that container can + final int y = layout.getLineBottom(line); + final int x = (int) (layout.getPrimaryHorizontal(end) + 0.5F); + + final Window window = ((Activity) widget.getContext()).getWindow(); + final View decor = window.getDecorView(); + final Point point = relativeTo(decor, widget); + +// new Tooltip.Builder(widget.getContext()) +// .anchor(x + point.x, y + point.y) +// .text(contents) +// .create() +// .show(widget, Tooltip.Gravity.TOP, false); + + // Toast is not reliable when tried to position on the screen + // but anyway, this is to showcase only + final Toast toast = Toast.makeText(widget.getContext(), contents, Toast.LENGTH_LONG); + toast.setGravity(Gravity.TOP | Gravity.START, x + point.x, y + point.y); + toast.show(); + } + + @Override + public void updateDrawState(@NonNull TextPaint ds) { + // can customize appearance here as spans will be rendered as links + super.updateDrawState(ds); + } + + @NonNull + private static Point relativeTo(@NonNull View parent, @NonNull View who) { + return relativeTo(parent, who, new Point()); + } + + @NonNull + private static Point relativeTo(@NonNull View parent, @NonNull View who, @NonNull Point point) { + // NB! the scroll adjustments (we are interested in screen position, + // not real position inside parent) + point.x += who.getLeft(); + point.y += who.getTop(); + point.x -= who.getScrollX(); + point.y -= who.getScrollY(); + if (who != parent + && who.getParent() instanceof View) { + relativeTo(parent, (View) who.getParent(), point); + } + return point; + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/inlineparsing/InlineParsingWithDefaultsSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/inlineparsing/InlineParsingWithDefaultsSample.java new file mode 100644 index 00000000..74d1cf19 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/inlineparsing/InlineParsingWithDefaultsSample.java @@ -0,0 +1,45 @@ +package io.noties.markwon.app.samples.inlineparsing; + +import androidx.annotation.NonNull; + +import io.noties.markwon.AbstractMarkwonPlugin; +import io.noties.markwon.Markwon; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.inlineparser.MarkwonInlineParserPlugin; +import io.noties.markwon.inlineparser.OpenBracketInlineProcessor; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200630170723", + title = "Inline parsing with defaults", + description = "Parsing with all defaults except links", + artifacts = MarkwonArtifact.INLINE_PARSER, + tags = {Tags.inline, Tags.parsing} +) +public class InlineParsingWithDefaultsSample extends MarkwonTextViewSample { + @Override + public void render() { + // a plugin with defaults registered + + final String md = "no [links](#) for **you** `code`!"; + + final Markwon markwon = Markwon.builder(context) + .usePlugin(MarkwonInlineParserPlugin.create()) + // the same as: +// .usePlugin(MarkwonInlineParserPlugin.create(MarkwonInlineParser.factoryBuilder())) + .usePlugin(new AbstractMarkwonPlugin() { + @Override + public void configure(@NonNull Registry registry) { + registry.require(MarkwonInlineParserPlugin.class, plugin -> { + plugin.factoryBuilder() + .excludeInlineProcessor(OpenBracketInlineProcessor.class); + }); + } + }) + .build(); + + markwon.setMarkdown(textView, md); + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/latex/LatexBlockSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/latex/LatexBlockSample.java new file mode 100644 index 00000000..79c23831 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/latex/LatexBlockSample.java @@ -0,0 +1,33 @@ +package io.noties.markwon.app.samples.latex; + +import io.noties.markwon.Markwon; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.app.samples.latex.shared.LatexHolder; +import io.noties.markwon.ext.latex.JLatexMathPlugin; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200630200257", + title = "LaTex block", + description = "Render LaTeX block", + artifacts = MarkwonArtifact.EXT_LATEX, + tags = {Tags.rendering} +) +public class LatexBlockSample extends MarkwonTextViewSample { + @Override + public void render() { + final String md = "" + + "# LaTeX\n" + + "$$\n" + + "" + LatexHolder.LATEX_ARRAY + "\n" + + "$$"; + + final Markwon markwon = Markwon.builder(context) + .usePlugin(JLatexMathPlugin.create(textView.getTextSize())) + .build(); + + markwon.setMarkdown(textView, md); + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/latex/LatexDarkSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/latex/LatexDarkSample.java new file mode 100644 index 00000000..06f2ca8d --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/latex/LatexDarkSample.java @@ -0,0 +1,37 @@ +package io.noties.markwon.app.samples.latex; + +import io.noties.markwon.Markwon; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.ext.latex.JLatexMathPlugin; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200701094225", + title = "LaTeX dark", + description = "LaTeX automatically uses `TextView` text color " + + "if not configured explicitly", + artifacts = MarkwonArtifact.EXT_LATEX, + tags = Tags.rendering +) +public class LatexDarkSample extends MarkwonTextViewSample { + @Override + public void render() { + scrollView.setBackgroundColor(0xFF000000); + textView.setTextColor(0xFFffffff); + + final String md = "" + + "# LaTeX\n" + + "$$\n" + + "\\int \\frac{1}{x} dx = \\ln \\left| x \\right| + C\n" + + "$$\n" + + "text color is taken from text"; + + final Markwon markwon = Markwon.builder(context) + .usePlugin(JLatexMathPlugin.create(textView.getTextSize())) + .build(); + + markwon.setMarkdown(textView, md); + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/latex/LatexDefaultTextColorSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/latex/LatexDefaultTextColorSample.java new file mode 100644 index 00000000..52bc384d --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/latex/LatexDefaultTextColorSample.java @@ -0,0 +1,40 @@ +package io.noties.markwon.app.samples.latex; + +import android.graphics.Color; + +import io.noties.markwon.Markwon; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.app.samples.latex.shared.LatexHolder; +import io.noties.markwon.ext.latex.JLatexMathPlugin; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200701120848", + title = "LaTeX default text color", + description = "LaTeX will use text color of `TextView` by default", + artifacts = MarkwonArtifact.EXT_LATEX, + tags = Tags.rendering +) +public class LatexDefaultTextColorSample extends MarkwonTextViewSample { + @Override + public void render() { + // @since 4.3.0 text color is automatically taken from textView + // (if it's not specified explicitly via configuration) + textView.setTextColor(Color.RED); + + final String md = "" + + "# LaTeX default text color\n" + + "$$\n" + + "" + LatexHolder.LATEX_LONG_DIVISION + "\n" + + "$$\n" + + ""; + + final Markwon markwon = Markwon.builder(context) + .usePlugin(JLatexMathPlugin.create(textView.getTextSize())) + .build(); + + markwon.setMarkdown(textView, md); + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/latex/LatexDifferentTextSizesSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/latex/LatexDifferentTextSizesSample.java new file mode 100644 index 00000000..94a9ad8b --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/latex/LatexDifferentTextSizesSample.java @@ -0,0 +1,42 @@ +package io.noties.markwon.app.samples.latex; + +import io.noties.markwon.Markwon; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.app.samples.latex.shared.LatexHolder; +import io.noties.markwon.ext.latex.JLatexMathPlugin; +import io.noties.markwon.inlineparser.MarkwonInlineParserPlugin; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200701093504", + title = "LaTeX inline/block different text size", + artifacts = {MarkwonArtifact.EXT_LATEX, MarkwonArtifact.INLINE_PARSER}, + tags = {Tags.rendering} +) +public class LatexDifferentTextSizesSample extends MarkwonTextViewSample { + @Override + public void render() { + final String md = "" + + "# LaTeX different text sizes\n" + + "inline: " + LatexHolder.LATEX_BANGLE + ", okay and block:\n" + + "$$\n" + + "" + LatexHolder.LATEX_BANGLE + "\n" + + "$$\n" + + "that's it"; + + final Markwon markwon = Markwon.builder(context) + .usePlugin(MarkwonInlineParserPlugin.create()) + .usePlugin(JLatexMathPlugin.create( + textView.getTextSize() * 0.75F, + textView.getTextSize() * 1.50F, + builder -> { + builder.inlinesEnabled(true); + } + )) + .build(); + + markwon.setMarkdown(textView, md); + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/latex/LatexErrorSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/latex/LatexErrorSample.java new file mode 100644 index 00000000..ee5204a7 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/latex/LatexErrorSample.java @@ -0,0 +1,54 @@ +package io.noties.markwon.app.samples.latex; + +import android.graphics.drawable.Drawable; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; + +import io.noties.debug.Debug; +import io.noties.markwon.Markwon; +import io.noties.markwon.app.R; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.ext.latex.JLatexMathPlugin; +import io.noties.markwon.inlineparser.MarkwonInlineParserPlugin; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200701122624", + title = "LaTeX error handling", + description = "Log error when parsing LaTeX and display error drawable", + artifacts = MarkwonArtifact.EXT_LATEX, + tags = Tags.rendering +) +public class LatexErrorSample extends MarkwonTextViewSample { + @Override + public void render() { + final String md = "" + + "# LaTeX with error\n" + + "$$\n" + + "\\sum_{i=0}^\\infty x \\cdot 0 \\rightarrow \\iMightNotExist{0}\n" + + "$$\n\n" + + "must **not** be rendered"; + + final Markwon markwon = Markwon.builder(context) + .usePlugin(MarkwonInlineParserPlugin.create()) + .usePlugin(JLatexMathPlugin.create(textView.getTextSize(), builder -> { + builder.inlinesEnabled(true); + //noinspection Convert2Lambda + builder.errorHandler(new JLatexMathPlugin.ErrorHandler() { + @Nullable + @Override + public Drawable handleError(@Nullable String latex, @NonNull Throwable error) { + Debug.e(error, latex); + return ContextCompat.getDrawable(context, R.drawable.ic_android_black_24dp); + } + }); + })) + .build(); + + markwon.setMarkdown(textView, md); + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/latex/LatexInlineSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/latex/LatexInlineSample.java new file mode 100644 index 00000000..4d1cfc21 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/latex/LatexInlineSample.java @@ -0,0 +1,37 @@ +package io.noties.markwon.app.samples.latex; + +import io.noties.markwon.Markwon; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.app.samples.latex.shared.LatexHolder; +import io.noties.markwon.ext.latex.JLatexMathPlugin; +import io.noties.markwon.inlineparser.MarkwonInlineParserPlugin; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200701085820", + title = "LaTeX inline", + description = "Display LaTeX inline", + artifacts = {MarkwonArtifact.EXT_LATEX, MarkwonArtifact.INLINE_PARSER}, + tags = Tags.rendering +) +public class LatexInlineSample extends MarkwonTextViewSample { + @Override + public void render() { + final String md = "" + + "# LaTeX inline\n" + + "hey = $$" + LatexHolder.LATEX_BANGLE + "$$,\n" + + "that's it!"; + + // inlines must be explicitly enabled and require `MarkwonInlineParserPlugin` + final Markwon markwon = Markwon.builder(context) + .usePlugin(MarkwonInlineParserPlugin.create()) + .usePlugin(JLatexMathPlugin.create(textView.getTextSize(), builder -> { + builder.inlinesEnabled(true); + })) + .build(); + + markwon.setMarkdown(textView, md); + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/latex/LatexLegacySample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/latex/LatexLegacySample.java new file mode 100644 index 00000000..19be415f --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/latex/LatexLegacySample.java @@ -0,0 +1,37 @@ +package io.noties.markwon.app.samples.latex; + +import io.noties.markwon.Markwon; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.app.samples.latex.shared.LatexHolder; +import io.noties.markwon.ext.latex.JLatexMathPlugin; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200701090335", + title = "LaTeX blocks in legacy mode", + description = "Sample using _legacy_ LaTeX block parsing (pre `4.3.0` Markwon version)", + artifacts = MarkwonArtifact.EXT_LATEX, + tags = Tags.rendering +) +public class LatexLegacySample extends MarkwonTextViewSample { + @Override + public void render() { + final String md = "" + + "# LaTeX legacy\n" + + "There are no inlines in previous versions, only blocks:\n" + + "$$\n" + + "" + LatexHolder.LATEX_BOXES + "\n" + + "$$\n" + + "yeah"; + + final Markwon markwon = Markwon.builder(context) + .usePlugin(JLatexMathPlugin.create(textView.getTextSize(), builder -> { + builder.blocksLegacy(true); + })) + .build(); + + markwon.setMarkdown(textView, md); + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/latex/LatexOmegaSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/latex/LatexOmegaSample.java new file mode 100644 index 00000000..ec96abb5 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/latex/LatexOmegaSample.java @@ -0,0 +1,38 @@ +package io.noties.markwon.app.samples.latex; + +import io.noties.markwon.Markwon; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.ext.latex.JLatexMathPlugin; +import io.noties.markwon.inlineparser.MarkwonInlineParserPlugin; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200701090618", + title = "LaTeX omega symbol", + description = "Bug rendering omega symbol in LaTeX", + artifacts = {MarkwonArtifact.EXT_LATEX, MarkwonArtifact.INLINE_PARSER}, + tags = {Tags.rendering, Tags.knownBug} +) +public class LatexOmegaSample extends MarkwonTextViewSample { + @Override + public void render() { + final String md = "" + + "# Block\n\n" + + "$$\n" + + "\\Omega\n" + + "$$\n\n" + + "# Inline\n\n" + + "$$\\Omega$$"; + + final Markwon markwon = Markwon.builder(context) + .usePlugin(MarkwonInlineParserPlugin.create()) + .usePlugin(JLatexMathPlugin.create(textView.getTextSize(), builder -> { + builder.inlinesEnabled(true); + })) + .build(); + + markwon.setMarkdown(textView, md); + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/latex/LatexThemeSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/latex/LatexThemeSample.java new file mode 100644 index 00000000..ff15b9ec --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/latex/LatexThemeSample.java @@ -0,0 +1,53 @@ +package io.noties.markwon.app.samples.latex; + +import android.graphics.Color; +import android.graphics.drawable.ColorDrawable; + +import io.noties.markwon.Markwon; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.app.samples.latex.shared.LatexHolder; +import io.noties.markwon.ext.latex.JLatexMathPlugin; +import io.noties.markwon.ext.latex.JLatexMathTheme; +import io.noties.markwon.inlineparser.MarkwonInlineParserPlugin; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200701121528", + title = "LaTeX theme", + description = "Sample of theme customization for LaTeX", + artifacts = {MarkwonArtifact.EXT_LATEX, MarkwonArtifact.INLINE_PARSER}, + tags = Tags.rendering +) +public class LatexThemeSample extends MarkwonTextViewSample { + @Override + public void render() { + final String md = "" + + "# LaTeX theme\n" + + "Hello there $$" + LatexHolder.LATEX_BANGLE + "$$, how was it?" + + "Now, what about a _different_ approach and block:\n\n" + + "$$\n" + + "" + LatexHolder.LATEX_LONG_DIVISION + "\n" + + "$$\n\n" + + "Seems **fine**"; + + final int blockPadding = (int) (16 * context.getResources().getDisplayMetrics().density + 0.5F); + + final Markwon markwon = Markwon.builder(context) + .usePlugin(MarkwonInlineParserPlugin.create()) + .usePlugin(JLatexMathPlugin.create(textView.getTextSize(), builder -> { + builder.inlinesEnabled(true); + builder.theme() + .inlineBackgroundProvider(() -> new ColorDrawable(0x200000ff)) + .inlineTextColor(Color.GREEN) + .blockBackgroundProvider(() -> new ColorDrawable(0x2000ff00)) + .blockPadding(JLatexMathTheme.Padding.all(blockPadding)) + .blockTextColor(Color.RED) + ; + })) + .build(); + + markwon.setMarkdown(textView, md); + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/latex/shared/LatexHolder.java b/app-sample/src/main/java/io/noties/markwon/app/samples/latex/shared/LatexHolder.java new file mode 100644 index 00000000..c6d98903 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/latex/shared/LatexHolder.java @@ -0,0 +1,35 @@ +package io.noties.markwon.app.samples.latex.shared; + +public abstract class LatexHolder { + + public static final String LATEX_ARRAY; + public static final String LATEX_LONG_DIVISION = "\\text{A long division \\longdiv{12345}{13}"; + public static final String LATEX_BANGLE = "{a \\bangle b} {c \\brace d} {e \\brack f} {g \\choose h}"; + public static final String LATEX_BOXES; + + static { + String latex = "\\begin{array}{cc}"; + latex += "\\fbox{\\text{A framed box with \\textdbend}}&\\shadowbox{\\text{A shadowed box}}\\cr"; + latex += "\\doublebox{\\text{A double framed box}}&\\ovalbox{\\text{An oval framed box}}\\cr"; + latex += "\\end{array}"; + LATEX_BOXES = latex; + } + + static { + String latex = "\\begin{array}{l}"; + latex += "\\forall\\varepsilon\\in\\mathbb{R}_+^*\\ \\exists\\eta>0\\ |x-x_0|\\leq\\eta\\Longrightarrow|f(x)-f(x_0)|\\leq\\varepsilon\\\\"; + latex += "\\det\\begin{bmatrix}a_{11}&a_{12}&\\cdots&a_{1n}\\\\a_{21}&\\ddots&&\\vdots\\\\\\vdots&&\\ddots&\\vdots\\\\a_{n1}&\\cdots&\\cdots&a_{nn}\\end{bmatrix}\\overset{\\mathrm{def}}{=}\\sum_{\\sigma\\in\\mathfrak{S}_n}\\varepsilon(\\sigma)\\prod_{k=1}^n a_{k\\sigma(k)}\\\\"; + latex += "\\sideset{_\\alpha^\\beta}{_\\gamma^\\delta}{\\begin{pmatrix}a&b\\\\c&d\\end{pmatrix}}\\\\"; + latex += "\\int_0^\\infty{x^{2n} e^{-a x^2}\\,dx} = \\frac{2n-1}{2a} \\int_0^\\infty{x^{2(n-1)} e^{-a x^2}\\,dx} = \\frac{(2n-1)!!}{2^{n+1}} \\sqrt{\\frac{\\pi}{a^{2n+1}}}\\\\"; + latex += "\\int_a^b{f(x)\\,dx} = (b - a) \\sum\\limits_{n = 1}^\\infty {\\sum\\limits_{m = 1}^{2^n - 1} {\\left( { - 1} \\right)^{m + 1} } } 2^{ - n} f(a + m\\left( {b - a} \\right)2^{-n} )\\\\"; + latex += "\\int_{-\\pi}^{\\pi} \\sin(\\alpha x) \\sin^n(\\beta x) dx = \\textstyle{\\left \\{ \\begin{array}{cc} (-1)^{(n+1)/2} (-1)^m \\frac{2 \\pi}{2^n} \\binom{n}{m} & n \\mbox{ odd},\\ \\alpha = \\beta (2m-n) \\\\ 0 & \\mbox{otherwise} \\\\ \\end{array} \\right .}\\\\"; + latex += "L = \\int_a^b \\sqrt{ \\left|\\sum_{i,j=1}^ng_{ij}(\\gamma(t))\\left(\\frac{d}{dt}x^i\\circ\\gamma(t)\\right)\\left(\\frac{d}{dt}x^j\\circ\\gamma(t)\\right)\\right|}\\,dt\\\\"; + latex += "\\begin{array}{rl} s &= \\int_a^b\\left\\|\\frac{d}{dt}\\vec{r}\\,(u(t),v(t))\\right\\|\\,dt \\\\ &= \\int_a^b \\sqrt{u'(t)^2\\,\\vec{r}_u\\cdot\\vec{r}_u + 2u'(t)v'(t)\\, \\vec{r}_u\\cdot\\vec{r}_v+ v'(t)^2\\,\\vec{r}_v\\cdot\\vec{r}_v}\\,\\,\\, dt. \\end{array}\\\\"; + latex += "\\end{array}"; + LATEX_ARRAY = latex; + } + + + private LatexHolder() { + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/movementmethod/DisableImplicitMovementMethodPluginSample.kt b/app-sample/src/main/java/io/noties/markwon/app/samples/movementmethod/DisableImplicitMovementMethodPluginSample.kt new file mode 100644 index 00000000..9538f0a8 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/movementmethod/DisableImplicitMovementMethodPluginSample.kt @@ -0,0 +1,31 @@ +package io.noties.markwon.app.samples.movementmethod + +import io.noties.markwon.Markwon +import io.noties.markwon.app.sample.Tags +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample +import io.noties.markwon.movement.MovementMethodPlugin +import io.noties.markwon.sample.annotations.MarkwonArtifact +import io.noties.markwon.sample.annotations.MarkwonSampleInfo + +@MarkwonSampleInfo( + id = "20200629121803", + title = "Disable implicit movement method via plugin", + description = "Disable implicit movement method via `MovementMethodPlugin`", + artifacts = [MarkwonArtifact.CORE], + tags = [Tags.links, Tags.movementMethod, Tags.recyclerView] +) +class DisableImplicitMovementMethodPluginSample : MarkwonTextViewSample() { + override fun render() { + val md = """ + # Disable implicit movement method via plugin + We can disable implicit movement method via `MovementMethodPlugin` — + [link-that-is-not-clickable](https://noties.io) + """.trimIndent() + + val markwon = Markwon.builder(context) + .usePlugin(MovementMethodPlugin.none()) + .build() + + markwon.setMarkdown(textView, md) + } +} \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/movementmethod/DisableImplicitMovementMethodSample.kt b/app-sample/src/main/java/io/noties/markwon/app/samples/movementmethod/DisableImplicitMovementMethodSample.kt new file mode 100644 index 00000000..26479588 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/movementmethod/DisableImplicitMovementMethodSample.kt @@ -0,0 +1,45 @@ +package io.noties.markwon.app.samples.movementmethod + +import io.noties.markwon.AbstractMarkwonPlugin +import io.noties.markwon.Markwon +import io.noties.markwon.MarkwonPlugin +import io.noties.markwon.app.sample.Tags +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample +import io.noties.markwon.core.CorePlugin +import io.noties.markwon.sample.annotations.MarkwonArtifact +import io.noties.markwon.sample.annotations.MarkwonSampleInfo + +@MarkwonSampleInfo( + id = "20200627081256", + title = "Disable implicit movement method", + description = "Configure `Markwon` to **not** apply implicit movement method, " + + "which consumes touch events when used in a `RecyclerView` even when " + + "markdown does not contain links", + artifacts = [MarkwonArtifact.CORE], + tags = [Tags.plugin, Tags.movementMethod, Tags.links, Tags.recyclerView] +) +class DisableImplicitMovementMethodSample : MarkwonTextViewSample() { + override fun render() { + val md = """ + # Disable implicit movement method + Sometimes it is required to stop `Markwon` from applying _implicit_ + movement method (for example when used inside in a `RecyclerView` + in order to make the whole itemView clickable). `Markwon` inspects + `TextView` and applies implicit movement method if `getMovementMethod()` + returns `null`. No [links](https://github.com) will be clickable in this + markdown + """.trimIndent() + + val markwon = Markwon.builder(context) + .usePlugin(object : AbstractMarkwonPlugin() { + override fun configure(registry: MarkwonPlugin.Registry) { + registry.require(CorePlugin::class.java) + // this flag will make sure that CorePlugin won't apply any movement method + .hasExplicitMovementMethod(true) + } + }) + .build() + + markwon.setMarkdown(textView, md) + } +} \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/movementmethod/ExplicitMovementMethodSample.kt b/app-sample/src/main/java/io/noties/markwon/app/samples/movementmethod/ExplicitMovementMethodSample.kt new file mode 100644 index 00000000..b2ebc754 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/movementmethod/ExplicitMovementMethodSample.kt @@ -0,0 +1,38 @@ +package io.noties.markwon.app.samples.movementmethod + +import android.text.method.ScrollingMovementMethod +import io.noties.markwon.Markwon +import io.noties.markwon.app.BuildConfig +import io.noties.markwon.app.sample.Tags +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample +import io.noties.markwon.sample.annotations.MarkwonArtifact +import io.noties.markwon.sample.annotations.MarkwonSampleInfo + +@MarkwonSampleInfo( + id = "20200627080007", + title = "Explicit movement method", + description = "When a movement method already applied to a `TextView`" + + "`Markwon` won't try to apply own (implicit) one", + artifacts = [MarkwonArtifact.CORE], + tags = [Tags.movementMethod, Tags.links] +) +class ExplicitMovementMethodSample : MarkwonTextViewSample() { + override fun render() { + val md = """ + # Explicit movement method + If `TextView` already has a movement method specified, then `Markwon` + won't be applying a default one. You can specify movement + method via call to `setMovementMethod`. If your movement method can + handle [links](${BuildConfig.GIT_REPOSITORY}) then link would be + _clickable_ + """.trimIndent() + + val markwon = Markwon.create(context) + + // own movement method that does not handle clicks would still be used + // (no default aka implicit method would be applied by Markwon) + textView.movementMethod = ScrollingMovementMethod.getInstance() + + markwon.setMarkdown(textView, md) + } +} \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/movementmethod/ImplicitMovementMethodSample.kt b/app-sample/src/main/java/io/noties/markwon/app/samples/movementmethod/ImplicitMovementMethodSample.kt new file mode 100644 index 00000000..7ad138ed --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/movementmethod/ImplicitMovementMethodSample.kt @@ -0,0 +1,34 @@ +package io.noties.markwon.app.samples.movementmethod + +import io.noties.markwon.Markwon +import io.noties.markwon.app.BuildConfig +import io.noties.markwon.app.sample.Tags +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample +import io.noties.markwon.sample.annotations.MarkwonArtifact +import io.noties.markwon.sample.annotations.MarkwonSampleInfo + +@MarkwonSampleInfo( + id = "20200627075524", + title = "Implicit movement method", + description = "By default movement method is applied for links to be clickable", + artifacts = [MarkwonArtifact.CORE], + tags = [Tags.movementMethod, Tags.links, Tags.defaults] +) +class ImplicitMovementMethodSample : MarkwonTextViewSample() { + override fun render() { + val md = """ + # Implicit movement method + By default `Markwon` applies `LinkMovementMethod` if it is missing, + so in order for [links](${BuildConfig.GIT_REPOSITORY}) to be clickable + nothing special should be done + """.trimIndent() + + // by default Markwon will apply a `LinkMovementMethod` if + // it is missing. So, in order for links to be clickable + // nothing should be done + + val markwon = Markwon.create(context) + + markwon.setMarkdown(textView, md) + } +} \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/movementmethod/MovementMethodPluginSample.kt b/app-sample/src/main/java/io/noties/markwon/app/samples/movementmethod/MovementMethodPluginSample.kt new file mode 100644 index 00000000..f158e8bb --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/movementmethod/MovementMethodPluginSample.kt @@ -0,0 +1,33 @@ +package io.noties.markwon.app.samples.movementmethod + +import io.noties.markwon.Markwon +import io.noties.markwon.app.sample.Tags +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample +import io.noties.markwon.movement.MovementMethodPlugin +import io.noties.markwon.sample.annotations.MarkwonArtifact +import io.noties.markwon.sample.annotations.MarkwonSampleInfo + +@MarkwonSampleInfo( + id = "20200627081631", + title = "MovementMethodPlugin", + description = "Plugin to control movement method", + artifacts = [MarkwonArtifact.CORE], + tags = [Tags.movementMethod, Tags.links, Tags.plugin] +) +class MovementMethodPluginSample : MarkwonTextViewSample() { + override fun render() { + val md = """ + # MovementMethodPlugin + `MovementMethodPlugin` can be used to apply movement method + explicitly. Including specific case to disable implicit movement + method which is applied when `TextView.getMovementMethod()` + returns `null`. A [link](https://github.com) + """.trimIndent() + + val markwon = Markwon.builder(context) + .usePlugin(MovementMethodPlugin.link()) + .build() + + markwon.setMarkdown(textView, md) + } +} \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/notification/NotificationSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/notification/NotificationSample.java new file mode 100644 index 00000000..940ba57d --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/notification/NotificationSample.java @@ -0,0 +1,84 @@ +package io.noties.markwon.app.samples.notification; + +import android.graphics.Color; +import android.graphics.Typeface; +import android.text.style.BackgroundColorSpan; +import android.text.style.BulletSpan; +import android.text.style.QuoteSpan; +import android.text.style.StrikethroughSpan; +import android.text.style.StyleSpan; +import android.text.style.TypefaceSpan; + +import androidx.annotation.NonNull; + +import org.commonmark.ext.gfm.strikethrough.Strikethrough; +import org.commonmark.node.BlockQuote; +import org.commonmark.node.Code; +import org.commonmark.node.Emphasis; +import org.commonmark.node.ListItem; +import org.commonmark.node.StrongEmphasis; + +import io.noties.markwon.AbstractMarkwonPlugin; +import io.noties.markwon.Markwon; +import io.noties.markwon.MarkwonSpansFactory; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.app.samples.notification.shared.NotificationUtils; +import io.noties.markwon.ext.strikethrough.StrikethroughPlugin; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200701130729", + title = "Markdown in Notification", + description = "Proof of concept of using `Markwon` with `android.app.Notification`", + artifacts = MarkwonArtifact.CORE, + tags = Tags.hack +) +public class NotificationSample extends MarkwonTextViewSample { + @Override + public void render() { + // supports: + // * bold -> StyleSpan(BOLD) + // * italic -> StyleSpan(ITALIC) + // * quote -> QuoteSpan() + // * strikethrough -> StrikethroughSpan() + // * bullet list -> BulletSpan() + + // * link -> is styled but not clickable + // * code -> typeface monospace works, background is not + + final String md = "" + + "**bold _bold-italic_ bold** ~~strike~~ `code` [link](#)\n\n" + + "* bullet-one\n" + + "* * bullet-two\n" + + " * bullet-three\n\n" + + "> a quote\n\n" + + ""; + + final Markwon markwon = Markwon.builder(context) + .usePlugin(StrikethroughPlugin.create()) + .usePlugin(new AbstractMarkwonPlugin() { + @Override + public void configureSpansFactory(@NonNull MarkwonSpansFactory.Builder builder) { + builder + .setFactory(Emphasis.class, (configuration, props) -> new StyleSpan(Typeface.ITALIC)) + .setFactory(StrongEmphasis.class, (configuration, props) -> new StyleSpan(Typeface.BOLD)) + .setFactory(BlockQuote.class, (configuration, props) -> new QuoteSpan()) + .setFactory(Strikethrough.class, (configuration, props) -> new StrikethroughSpan()) + // NB! notification does not handle background color + .setFactory(Code.class, (configuration, props) -> new Object[]{ + new BackgroundColorSpan(Color.GRAY), + new TypefaceSpan("monospace") + }) + // NB! both ordered and bullet list items + .setFactory(ListItem.class, (configuration, props) -> new BulletSpan()); + } + }) + .build(); + + markwon.setMarkdown(textView, md); + + NotificationUtils.display(context, markwon.toMarkdown(md)); + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/notification/RemoteViewsSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/notification/RemoteViewsSample.java new file mode 100644 index 00000000..ab0c6458 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/notification/RemoteViewsSample.java @@ -0,0 +1,93 @@ +package io.noties.markwon.app.samples.notification; + +import android.graphics.Color; +import android.graphics.Typeface; +import android.text.style.BackgroundColorSpan; +import android.text.style.BulletSpan; +import android.text.style.QuoteSpan; +import android.text.style.RelativeSizeSpan; +import android.text.style.StrikethroughSpan; +import android.text.style.StyleSpan; +import android.text.style.TypefaceSpan; +import android.widget.RemoteViews; + +import androidx.annotation.NonNull; + +import org.commonmark.ext.gfm.strikethrough.Strikethrough; +import org.commonmark.node.BlockQuote; +import org.commonmark.node.Code; +import org.commonmark.node.Emphasis; +import org.commonmark.node.Heading; +import org.commonmark.node.ListItem; +import org.commonmark.node.StrongEmphasis; + +import io.noties.markwon.AbstractMarkwonPlugin; +import io.noties.markwon.Markwon; +import io.noties.markwon.MarkwonSpansFactory; +import io.noties.markwon.app.R; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.app.samples.notification.shared.NotificationUtils; +import io.noties.markwon.core.CoreProps; +import io.noties.markwon.ext.strikethrough.StrikethroughPlugin; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200702090140", + title = "RemoteViews in notification", + description = "Display markdown with platform (system) spans in notification via `RemoteViews`", + artifacts = MarkwonArtifact.CORE, + tags = Tags.hack +) +public class RemoteViewsSample extends MarkwonTextViewSample { + @Override + public void render() { + final String md = "" + + "# Heading 1\n" + +// "## Heading 2\n" + +// "### Heading 3\n" + +// "#### Heading 4\n" + +// "##### Heading 5\n" + +// "###### Heading 6\n" + + "**bold _italic_ bold** `code` [link](#) ~~strike~~\n" + + "* Bullet 1\n" + + "* * Bullet 2\n" + + " * Bullet 3\n" + + "> A quote **here**"; + + final float[] headingSizes = { + 2.F, 1.5F, 1.17F, 1.F, .83F, .67F, + }; + + final int bulletGapWidth = (int) (8 * context.getResources().getDisplayMetrics().density + 0.5F); + + final Markwon markwon = Markwon.builder(context) + .usePlugin(StrikethroughPlugin.create()) + .usePlugin(new AbstractMarkwonPlugin() { + @Override + public void configureSpansFactory(@NonNull MarkwonSpansFactory.Builder builder) { + builder + .setFactory(Heading.class, (configuration, props) -> new Object[]{ + new StyleSpan(Typeface.BOLD), + new RelativeSizeSpan(headingSizes[CoreProps.HEADING_LEVEL.require(props) - 1]) + }) + .setFactory(StrongEmphasis.class, (configuration, props) -> new StyleSpan(Typeface.BOLD)) + .setFactory(Emphasis.class, (configuration, props) -> new StyleSpan(Typeface.ITALIC)) + .setFactory(Code.class, (configuration, props) -> new Object[]{ + new BackgroundColorSpan(Color.GRAY), + new TypefaceSpan("monospace") + }) + .setFactory(Strikethrough.class, (configuration, props) -> new StrikethroughSpan()) + .setFactory(ListItem.class, (configuration, props) -> new BulletSpan(bulletGapWidth)) + .setFactory(BlockQuote.class, (configuration, props) -> new QuoteSpan()); + } + }) + .build(); + + final RemoteViews remoteViews = new RemoteViews(context.getPackageName(), R.layout.sample_remote_view); + remoteViews.setTextViewText(R.id.text_view, markwon.toMarkdown(md)); + + NotificationUtils.display(context, remoteViews); + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/notification/shared/NotificationUtils.java b/app-sample/src/main/java/io/noties/markwon/app/samples/notification/shared/NotificationUtils.java new file mode 100644 index 00000000..97e7cffd --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/notification/shared/NotificationUtils.java @@ -0,0 +1,85 @@ +package io.noties.markwon.app.samples.notification.shared; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.content.Context; +import android.os.Build; +import android.widget.RemoteViews; + +import androidx.annotation.NonNull; + +import io.noties.markwon.app.R; + +public abstract class NotificationUtils { + + private static final int ID = 2; + private static final String CHANNEL_ID = "2"; + + public static void display(@NonNull Context context, @NonNull CharSequence cs) { + final NotificationManager manager = context.getSystemService(NotificationManager.class); + if (manager == null) { + return; + } + + ensureChannel(manager, CHANNEL_ID); + + final Notification.Builder builder = new Notification.Builder(context) + .setSmallIcon(R.drawable.ic_stat_name) + .setContentTitle(context.getString(R.string.app_name)) + .setContentText(cs) + .setStyle(new Notification.BigTextStyle().bigText(cs)); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + builder.setChannelId(CHANNEL_ID); + } + + manager.notify(ID, builder.build()); + } + + public static void display(@NonNull Context context, @NonNull RemoteViews remoteViews) { + final NotificationManager manager = context.getSystemService(NotificationManager.class); + if (manager == null) { + return; + } + + ensureChannel(manager, CHANNEL_ID); + + final Notification.Builder builder = new Notification.Builder(context) + .setSmallIcon(R.drawable.ic_stat_name) + .setContentTitle(context.getString(R.string.app_name)); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + builder + .setCustomContentView(remoteViews) + .setCustomBigContentView(remoteViews); + } else { + builder.setContent(remoteViews); + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + builder.setChannelId(CHANNEL_ID); + } + + manager.notify(ID, builder.build()); + } + + @SuppressWarnings("SameParameterValue") + private static void ensureChannel(@NonNull NotificationManager manager, @NonNull String channelId) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return; + } + + final NotificationChannel channel = manager.getNotificationChannel(channelId); + if (channel == null) { + manager.createNotificationChannel(new NotificationChannel( + channelId, + channelId, + NotificationManager.IMPORTANCE_DEFAULT + )); + } + } + + private NotificationUtils() { + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/plugins/AnchorSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/plugins/AnchorSample.java new file mode 100644 index 00000000..ea4f5a9b --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/plugins/AnchorSample.java @@ -0,0 +1,37 @@ +package io.noties.markwon.app.samples.plugins; + +import io.noties.markwon.Markwon; +import io.noties.markwon.app.R; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.app.samples.plugins.shared.AnchorHeadingPlugin; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200629130728", + title = "Anchor plugin", + description = "HTML-like anchor links plugin, which scrolls to clicked anchor", + artifacts = MarkwonArtifact.CORE, + tags = {Tags.links, Tags.anchor, Tags.plugin} +) +public class AnchorSample extends MarkwonTextViewSample { + + @Override + public void render() { + + final String lorem = context.getString(R.string.lorem); + final String md = "" + + "Hello [there](#there)!\n\n\n" + + lorem + "\n\n" + + "# There!\n\n" + + lorem; + + final Markwon markwon = Markwon.builder(context) + .usePlugin(new AnchorHeadingPlugin((view, top) -> scrollView.smoothScrollTo(0, top))) + .build(); + + markwon.setMarkdown(textView, md); + } +} + diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/plugins/TableOfContentsSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/plugins/TableOfContentsSample.java new file mode 100644 index 00000000..34929e88 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/plugins/TableOfContentsSample.java @@ -0,0 +1,156 @@ +package io.noties.markwon.app.samples.plugins; + +import androidx.annotation.NonNull; + +import org.commonmark.node.AbstractVisitor; +import org.commonmark.node.BulletList; +import org.commonmark.node.CustomBlock; +import org.commonmark.node.Heading; +import org.commonmark.node.Link; +import org.commonmark.node.ListItem; +import org.commonmark.node.Node; +import org.commonmark.node.Text; + +import io.noties.markwon.AbstractMarkwonPlugin; +import io.noties.markwon.Markwon; +import io.noties.markwon.MarkwonVisitor; +import io.noties.markwon.app.R; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.app.samples.plugins.shared.AnchorHeadingPlugin; +import io.noties.markwon.core.SimpleBlockNodeVisitor; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200629161226", + title = "Table of contents", + description = "Sample plugin that adds a table of contents header", + artifacts = MarkwonArtifact.CORE, + tags = {Tags.rendering, Tags.plugin} +) +public class TableOfContentsSample extends MarkwonTextViewSample { + + @Override + public void render() { + final String lorem = context.getString(R.string.lorem); + final String md = "" + + "# First\n" + + "" + lorem + "\n\n" + + "# Second\n" + + "" + lorem + "\n\n" + + "## Second level\n\n" + + "" + lorem + "\n\n" + + "### Level 3\n\n" + + "" + lorem + "\n\n" + + "# First again\n" + + "" + lorem + "\n\n"; + + final Markwon markwon = Markwon.builder(context) + .usePlugin(new TableOfContentsPlugin()) + // NB! plugin is defined in `AnchorSample` file + .usePlugin(new AnchorHeadingPlugin((view, top) -> scrollView.smoothScrollTo(0, top))) + .build(); + + markwon.setMarkdown(textView, md); + } +} + +class TableOfContentsPlugin extends AbstractMarkwonPlugin { + @Override + public void configure(@NonNull Registry registry) { + // just to make it explicit + registry.require(AnchorHeadingPlugin.class); + } + + @Override + public void configureVisitor(@NonNull MarkwonVisitor.Builder builder) { + builder.on(TableOfContentsBlock.class, new SimpleBlockNodeVisitor()); + } + + @Override + public void beforeRender(@NonNull Node node) { + + // custom block to hold TOC + final TableOfContentsBlock block = new TableOfContentsBlock(); + + // create TOC title + { + final Text text = new Text("Table of contents"); + final Heading heading = new Heading(); + // important one - set TOC heading level + heading.setLevel(1); + heading.appendChild(text); + block.appendChild(heading); + } + + final HeadingVisitor visitor = new HeadingVisitor(block); + node.accept(visitor); + + // make it the very first node in rendered markdown + node.prependChild(block); + } + + private static class HeadingVisitor extends AbstractVisitor { + + private final BulletList bulletList = new BulletList(); + private final StringBuilder builder = new StringBuilder(); + private boolean isInsideHeading; + + HeadingVisitor(@NonNull Node node) { + node.appendChild(bulletList); + } + + @Override + public void visit(Heading heading) { + this.isInsideHeading = true; + try { + // reset build from previous content + builder.setLength(0); + + // obtain level (can additionally filter by level, to skip lower ones) + final int level = heading.getLevel(); + + // build heading title + visitChildren(heading); + + // initial list item + final ListItem listItem = new ListItem(); + + Node parent = listItem; + Node node = listItem; + + for (int i = 1; i < level; i++) { + final ListItem li = new ListItem(); + final BulletList bulletList = new BulletList(); + bulletList.appendChild(li); + parent.appendChild(bulletList); + parent = li; + node = li; + } + + final String content = builder.toString(); + final Link link = new Link("#" + AnchorHeadingPlugin.createAnchor(content), null); + final Text text = new Text(content); + link.appendChild(text); + node.appendChild(link); + bulletList.appendChild(listItem); + + + } finally { + isInsideHeading = false; + } + } + + @Override + public void visit(Text text) { + // can additionally check if we are building heading (to skip all other texts) + if (isInsideHeading) { + builder.append(text.getLiteral()); + } + } + } + + private static class TableOfContentsBlock extends CustomBlock { + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/plugins/shared/AnchorHeadingPlugin.java b/app-sample/src/main/java/io/noties/markwon/app/samples/plugins/shared/AnchorHeadingPlugin.java new file mode 100644 index 00000000..4663b4cb --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/plugins/shared/AnchorHeadingPlugin.java @@ -0,0 +1,97 @@ +package io.noties.markwon.app.samples.plugins.shared; + +import android.text.Spannable; +import android.text.Spanned; +import android.view.View; +import android.widget.TextView; + +import androidx.annotation.NonNull; + +import io.noties.markwon.AbstractMarkwonPlugin; +import io.noties.markwon.LinkResolverDef; +import io.noties.markwon.MarkwonConfiguration; +import io.noties.markwon.core.spans.HeadingSpan; + +public class AnchorHeadingPlugin extends AbstractMarkwonPlugin { + + public interface ScrollTo { + void scrollTo(@NonNull TextView view, int top); + } + + private final AnchorHeadingPlugin.ScrollTo scrollTo; + + public AnchorHeadingPlugin(@NonNull AnchorHeadingPlugin.ScrollTo scrollTo) { + this.scrollTo = scrollTo; + } + + @Override + public void configureConfiguration(@NonNull MarkwonConfiguration.Builder builder) { + builder.linkResolver(new AnchorHeadingPlugin.AnchorLinkResolver(scrollTo)); + } + + @Override + public void afterSetText(@NonNull TextView textView) { + final Spannable spannable = (Spannable) textView.getText(); + // obtain heading spans + final HeadingSpan[] spans = spannable.getSpans(0, spannable.length(), HeadingSpan.class); + if (spans != null) { + for (HeadingSpan span : spans) { + final int start = spannable.getSpanStart(span); + final int end = spannable.getSpanEnd(span); + final int flags = spannable.getSpanFlags(span); + spannable.setSpan( + new AnchorHeadingPlugin.AnchorSpan(createAnchor(spannable.subSequence(start, end))), + start, + end, + flags + ); + } + } + } + + private static class AnchorLinkResolver extends LinkResolverDef { + + private final AnchorHeadingPlugin.ScrollTo scrollTo; + + AnchorLinkResolver(@NonNull AnchorHeadingPlugin.ScrollTo scrollTo) { + this.scrollTo = scrollTo; + } + + @Override + public void resolve(@NonNull View view, @NonNull String link) { + if (link.startsWith("#")) { + final TextView textView = (TextView) view; + final Spanned spanned = (Spannable) textView.getText(); + final AnchorHeadingPlugin.AnchorSpan[] spans = spanned.getSpans(0, spanned.length(), AnchorHeadingPlugin.AnchorSpan.class); + if (spans != null) { + final String anchor = link.substring(1); + for (AnchorHeadingPlugin.AnchorSpan span : spans) { + if (anchor.equals(span.anchor)) { + final int start = spanned.getSpanStart(span); + final int line = textView.getLayout().getLineForOffset(start); + final int top = textView.getLayout().getLineTop(line); + scrollTo.scrollTo(textView, top); + return; + } + } + } + } + super.resolve(view, link); + } + } + + private static class AnchorSpan { + final String anchor; + + AnchorSpan(@NonNull String anchor) { + this.anchor = anchor; + } + } + + @NonNull + public static String createAnchor(@NonNull CharSequence content) { + return String.valueOf(content) + .replaceAll("[^\\w]", "") + .toLowerCase(); + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/table/TableCustomizeSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/table/TableCustomizeSample.java new file mode 100644 index 00000000..d662e670 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/table/TableCustomizeSample.java @@ -0,0 +1,47 @@ +package io.noties.markwon.app.samples.table; + +import android.graphics.Color; + +import io.noties.markwon.Markwon; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.ext.tables.TablePlugin; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; +import io.noties.markwon.utils.ColorUtils; +import io.noties.markwon.utils.Dip; + +@MarkwonSampleInfo( + id = "20200702135621", + title = "Customize table theme", + artifacts = MarkwonArtifact.EXT_TABLES, + tags = {Tags.theme} +) +public class TableCustomizeSample extends MarkwonTextViewSample { + @Override + public void render() { + final String md = "" + + "| HEADER | HEADER | HEADER |\n" + + "|:----:|:----:|:----:|\n" + + "| 测试 | 测试 | 测试 |\n" + + "| 测试 | 测试 | 测测测12345试测试测试 |\n" + + "| 测试 | 测试 | 123445 |\n" + + "| 测试 | 测试 | (650) 555-1212 |\n" + + "| 测试 | 测试 | [link](#) |\n"; + + final Markwon markwon = Markwon.builder(context) + .usePlugin(TablePlugin.create(builder -> { + final Dip dip = Dip.create(context); + builder + .tableBorderWidth(dip.toPx(2)) + .tableBorderColor(Color.YELLOW) + .tableCellPadding(dip.toPx(4)) + .tableHeaderRowBackgroundColor(ColorUtils.applyAlpha(Color.RED, 80)) + .tableEvenRowBackgroundColor(ColorUtils.applyAlpha(Color.GREEN, 80)) + .tableOddRowBackgroundColor(ColorUtils.applyAlpha(Color.BLUE, 80)); + })) + .build(); + + markwon.setMarkdown(textView, md); + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/table/TableLatexSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/table/TableLatexSample.java new file mode 100644 index 00000000..f1c7fc8d --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/table/TableLatexSample.java @@ -0,0 +1,45 @@ +package io.noties.markwon.app.samples.table; + +import io.noties.markwon.Markwon; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.ext.latex.JLatexMathPlugin; +import io.noties.markwon.ext.tables.TablePlugin; +import io.noties.markwon.image.ImagesPlugin; +import io.noties.markwon.inlineparser.MarkwonInlineParserPlugin; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200702140041", + title = "LaTeX inside table", + description = "Usage of LaTeX formulas inside markdown tables", + artifacts = {MarkwonArtifact.EXT_LATEX, MarkwonArtifact.EXT_TABLES, MarkwonArtifact.IMAGE}, + tags = {Tags.image} +) +public class TableLatexSample extends MarkwonTextViewSample { + @Override + public void render() { + String latex = "\\begin{array}{cc}"; + latex += "\\fbox{\\text{A framed box with \\textdbend}}&\\shadowbox{\\text{A shadowed box}}\\cr"; + latex += "\\doublebox{\\text{A double framed box}}&\\ovalbox{\\text{An oval framed box}}\\cr"; + latex += "\\end{array}"; + + final String md = "" + + "| HEADER | HEADER |\n" + + "|:----:|:----:|\n" + + "| ![Build](https://github.com/noties/Markwon/workflows/Build/badge.svg) | Build |\n" + + "| Stable | ![stable](https://img.shields.io/maven-central/v/io.noties.markwon/core.svg?label=stable) |\n" + + "| BIG | $$" + latex + "$$ |\n" + + "\n"; + + final Markwon markwon = Markwon.builder(context) + .usePlugin(MarkwonInlineParserPlugin.create()) + .usePlugin(ImagesPlugin.create()) + .usePlugin(JLatexMathPlugin.create(textView.getTextSize(), builder -> builder.inlinesEnabled(true))) + .usePlugin(TablePlugin.create(context)) + .build(); + + markwon.setMarkdown(textView, md); + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/table/TableLinkifySample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/table/TableLinkifySample.java new file mode 100644 index 00000000..ae210a68 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/table/TableLinkifySample.java @@ -0,0 +1,42 @@ +package io.noties.markwon.app.samples.table; + +import io.noties.markwon.Markwon; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.ext.tables.TablePlugin; +import io.noties.markwon.linkify.LinkifyPlugin; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200702135739", + title = "Linkify table", + description = "Automatically linkify markdown content " + + "including content inside tables", + artifacts = {MarkwonArtifact.EXT_TABLES, MarkwonArtifact.LINKIFY}, + tags = {Tags.links} +) +public class TableLinkifySample extends MarkwonTextViewSample { + @Override + public void render() { + final String md = "" + + "| HEADER | HEADER | HEADER |\n" + + "|:----:|:----:|:----:|\n" + + "| 测试 | 测试 | 测试 |\n" + + "| 测试 | 测试 | 测测测12345试测试测试 |\n" + + "| 测试 | 测试 | 123445 |\n" + + "| 测试 | 测试 | (650) 555-1212 |\n" + + "| 测试 | 测试 | [link](#) |\n" + + "\n" + + "测试\n" + + "\n" + + "[link link](https://link.link)"; + + final Markwon markwon = Markwon.builder(context) + .usePlugin(LinkifyPlugin.create()) + .usePlugin(TablePlugin.create(context)) + .build(); + + markwon.setMarkdown(textView, md); + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/table/TableWithImagesSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/table/TableWithImagesSample.java new file mode 100644 index 00000000..9c55aeb1 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/table/TableWithImagesSample.java @@ -0,0 +1,36 @@ +package io.noties.markwon.app.samples.table; + +import io.noties.markwon.Markwon; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.ext.tables.TablePlugin; +import io.noties.markwon.image.ImagesPlugin; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20200702135932", + title = "Images inside table", + description = "Usage of images inside markdown tables", + artifacts = {MarkwonArtifact.EXT_TABLES, MarkwonArtifact.IMAGE}, + tags = Tags.image +) +public class TableWithImagesSample extends MarkwonTextViewSample { + @Override + public void render() { + final String md = "" + + "| HEADER | HEADER |\n" + + "|:----:|:----:|\n" + + "| ![Build](https://github.com/noties/Markwon/workflows/Build/badge.svg) | Build |\n" + + "| Stable | ![stable](https://img.shields.io/maven-central/v/io.noties.markwon/core.svg?label=stable) |\n" + + "| BIG | ![image](https://images.pexels.com/photos/41171/brussels-sprouts-sprouts-cabbage-grocery-41171.jpeg) |\n" + + "\n"; + + final Markwon markwon = Markwon.builder(context) + .usePlugin(ImagesPlugin.create()) + .usePlugin(TablePlugin.create(context)) + .build(); + + markwon.setMarkdown(textView, md); + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/tasklist/TaskListCustomColorsSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/tasklist/TaskListCustomColorsSample.java new file mode 100644 index 00000000..bf58a9d0 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/tasklist/TaskListCustomColorsSample.java @@ -0,0 +1,34 @@ +package io.noties.markwon.app.samples.tasklist; + +import android.graphics.Color; + +import io.noties.markwon.Markwon; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.ext.tasklist.TaskListPlugin; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +import static io.noties.markwon.app.samples.tasklist.shared.TaskListHolder.MD; + +@MarkwonSampleInfo( + id = "20200702140536", + title = "GFM task list custom colors", + description = "Custom colors for task list extension", + artifacts = MarkwonArtifact.EXT_TASKLIST, + tags = Tags.parsing +) +public class TaskListCustomColorsSample extends MarkwonTextViewSample { + @Override + public void render() { + final int checkedFillColor = Color.RED; + final int normalOutlineColor = Color.GREEN; + final int checkMarkColor = Color.BLUE; + + final Markwon markwon = Markwon.builder(context) + .usePlugin(TaskListPlugin.create(checkedFillColor, normalOutlineColor, checkMarkColor)) + .build(); + + markwon.setMarkdown(textView, MD); + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/tasklist/TaskListCustomDrawableSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/tasklist/TaskListCustomDrawableSample.java new file mode 100644 index 00000000..a755dfb6 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/tasklist/TaskListCustomDrawableSample.java @@ -0,0 +1,37 @@ +package io.noties.markwon.app.samples.tasklist; + +import android.graphics.drawable.Drawable; + +import androidx.core.content.ContextCompat; + +import java.util.Objects; + +import io.noties.markwon.Markwon; +import io.noties.markwon.app.R; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.ext.tasklist.TaskListPlugin; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +import static io.noties.markwon.app.samples.tasklist.shared.TaskListHolder.MD; + +@MarkwonSampleInfo( + id = "20200702140749", + title = "GFM task list custom drawable", + artifacts = MarkwonArtifact.EXT_TASKLIST, + tags = Tags.plugin +) +public class TaskListCustomDrawableSample extends MarkwonTextViewSample { + @Override + public void render() { + final Drawable drawable = Objects.requireNonNull( + ContextCompat.getDrawable(context, R.drawable.custom_task_list)); + + final Markwon markwon = Markwon.builder(context) + .usePlugin(TaskListPlugin.create(drawable)) + .build(); + + markwon.setMarkdown(textView, MD); + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/tasklist/TaskListMutateSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/tasklist/TaskListMutateSample.java new file mode 100644 index 00000000..d60a1244 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/tasklist/TaskListMutateSample.java @@ -0,0 +1,101 @@ +package io.noties.markwon.app.samples.tasklist; + +import android.text.Spanned; +import android.text.TextPaint; +import android.text.style.ClickableSpan; +import android.view.View; +import android.widget.TextView; + +import androidx.annotation.NonNull; + +import io.noties.debug.Debug; +import io.noties.markwon.AbstractMarkwonPlugin; +import io.noties.markwon.Markwon; +import io.noties.markwon.MarkwonSpansFactory; +import io.noties.markwon.SpanFactory; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.ext.tasklist.TaskListItem; +import io.noties.markwon.ext.tasklist.TaskListPlugin; +import io.noties.markwon.ext.tasklist.TaskListSpan; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +import static io.noties.markwon.app.samples.tasklist.shared.TaskListHolder.MD; + +@MarkwonSampleInfo( + id = "20200702140901", + title = "GFM task list mutate", + artifacts = MarkwonArtifact.EXT_TASKLIST, + tags = Tags.plugin +) +public class TaskListMutateSample extends MarkwonTextViewSample { + @Override + public void render() { + final Markwon markwon = Markwon.builder(context) + .usePlugin(TaskListPlugin.create(context)) + .usePlugin(new AbstractMarkwonPlugin() { + @Override + public void configureSpansFactory(@NonNull MarkwonSpansFactory.Builder builder) { + // obtain origin task-list-factory + final SpanFactory origin = builder.getFactory(TaskListItem.class); + if (origin == null) { + return; + } + + builder.setFactory(TaskListItem.class, (configuration, props) -> { + // maybe it's better to validate the actual type here also + // and not force cast to task-list-span + final TaskListSpan span = (TaskListSpan) origin.getSpans(configuration, props); + if (span == null) { + return null; + } + + // NB, toggle click will intercept possible links inside task-list-item + return new Object[]{ + span, + new TaskListToggleSpan(span) + }; + }); + } + }) + .build(); + + markwon.setMarkdown(textView, MD); + } +} + +class TaskListToggleSpan extends ClickableSpan { + + private final TaskListSpan span; + + TaskListToggleSpan(@NonNull TaskListSpan span) { + this.span = span; + } + + @Override + public void onClick(@NonNull View widget) { + // toggle span (this is a mere visual change) + span.setDone(!span.isDone()); + // request visual update + widget.invalidate(); + + // it must be a TextView + final TextView textView = (TextView) widget; + // it must be spanned + final Spanned spanned = (Spanned) textView.getText(); + + // actual text of the span (this can be used along with the `span`) + final CharSequence task = spanned.subSequence( + spanned.getSpanStart(this), + spanned.getSpanEnd(this) + ); + + Debug.i("task done: %s, '%s'", span.isDone(), task); + } + + @Override + public void updateDrawState(@NonNull TextPaint ds) { + // no op, so text is not rendered as a link + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/tasklist/TaskListSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/tasklist/TaskListSample.java new file mode 100644 index 00000000..541e74a7 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/tasklist/TaskListSample.java @@ -0,0 +1,28 @@ +package io.noties.markwon.app.samples.tasklist; + +import io.noties.markwon.Markwon; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.ext.tasklist.TaskListPlugin; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +import static io.noties.markwon.app.samples.tasklist.shared.TaskListHolder.MD; + +@MarkwonSampleInfo( + id = "20200702140352", + title = "GFM task list", + description = "Github Flavored Markdown (GFM) task list extension", + artifacts = MarkwonArtifact.EXT_TASKLIST, + tags = Tags.plugin +) +public class TaskListSample extends MarkwonTextViewSample { + @Override + public void render() { + final Markwon markwon = Markwon.builder(context) + .usePlugin(TaskListPlugin.create(context)) + .build(); + + markwon.setMarkdown(textView, MD); + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/tasklist/shared/TaskListHolder.java b/app-sample/src/main/java/io/noties/markwon/app/samples/tasklist/shared/TaskListHolder.java new file mode 100644 index 00000000..19f4b7a7 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/tasklist/shared/TaskListHolder.java @@ -0,0 +1,17 @@ +package io.noties.markwon.app.samples.tasklist.shared; + +public abstract class TaskListHolder { + + public static final String MD = "" + + "- [ ] Not done here!\n" + + "- [x] and done\n" + + "- [X] and again!\n" + + "* [ ] **and** syntax _included_ `code`\n" + + "- [ ] [link](#)\n" + + "- [ ] [a check box](https://examp.le)\n" + + "- [x] [test]()\n" + + "- [List](https://examp.le) 3"; + + private TaskListHolder() { + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/utils/AdaptUtils.kt b/app-sample/src/main/java/io/noties/markwon/app/utils/AdaptUtils.kt new file mode 100644 index 00000000..0c4637e8 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/utils/AdaptUtils.kt @@ -0,0 +1,16 @@ +package io.noties.markwon.app.utils + +import androidx.recyclerview.widget.RecyclerView +import io.noties.adapt.Adapt +import io.noties.debug.Debug + +val Adapt.recyclerView: RecyclerView? + get() { + // internally throws if recycler is not present (detached from recyclerView) + return try { + recyclerView() + } catch (t: Throwable) { + Debug.e(t) + null + } + } \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/utils/Cancellable.kt b/app-sample/src/main/java/io/noties/markwon/app/utils/Cancellable.kt new file mode 100644 index 00000000..4f519ad4 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/utils/Cancellable.kt @@ -0,0 +1,7 @@ +package io.noties.markwon.app.utils + +interface Cancellable { + val isCancelled: Boolean + + fun cancel() +} \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/utils/InputStreamUtils.kt b/app-sample/src/main/java/io/noties/markwon/app/utils/InputStreamUtils.kt new file mode 100644 index 00000000..d146a4c1 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/utils/InputStreamUtils.kt @@ -0,0 +1,21 @@ +package io.noties.markwon.app.utils + +import java.io.IOException +import java.io.InputStream +import java.util.Scanner + +fun InputStream.readStringAndClose(): String { + try { + val scanner = Scanner(this).useDelimiter("\\A") + if (scanner.hasNext()) { + return scanner.next() + } + return "" + } finally { + try { + close() + } catch (e: IOException) { + // ignored + } + } +} \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/utils/KeyEventUtils.kt b/app-sample/src/main/java/io/noties/markwon/app/utils/KeyEventUtils.kt new file mode 100644 index 00000000..ea3a3bd8 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/utils/KeyEventUtils.kt @@ -0,0 +1,9 @@ +package io.noties.markwon.app.utils + +import android.view.KeyEvent + +object KeyEventUtils { + fun isActionUp(event: KeyEvent?): Boolean { + return event == null || KeyEvent.ACTION_UP == event.action + } +} \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/utils/KeyboardUtils.kt b/app-sample/src/main/java/io/noties/markwon/app/utils/KeyboardUtils.kt new file mode 100644 index 00000000..1bcdfa73 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/utils/KeyboardUtils.kt @@ -0,0 +1,17 @@ +package io.noties.markwon.app.utils + +import android.view.View +import android.view.inputmethod.InputMethodManager + +object KeyboardUtils { + + fun show(view: View) { + view.context.getSystemService(InputMethodManager::class.java) + ?.showSoftInput(view, 0) + } + + fun hide(view: View) { + view.context.getSystemService(InputMethodManager::class.java) + ?.hideSoftInputFromWindow(view.windowToken, 0) + } +} \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/utils/ReadMeUtils.kt b/app-sample/src/main/java/io/noties/markwon/app/utils/ReadMeUtils.kt new file mode 100644 index 00000000..907f35e5 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/utils/ReadMeUtils.kt @@ -0,0 +1,73 @@ +package io.noties.markwon.app.utils + +import android.net.Uri +import android.text.TextUtils +import java.util.regex.Pattern + +object ReadMeUtils { + + // username, repo, branch, lastPathSegment + @Suppress("RegExpRedundantEscape") + private val RE_FILE = Pattern.compile("^https:\\/\\/github\\.com\\/([\\w-.]+?)\\/([\\w-.]+?)\\/(?:blob|raw)\\/([\\w-.]+?)\\/(.+)\$") + + @Suppress("RegExpRedundantEscape") + private val RE_REPOSITORY: Pattern = Pattern.compile("^https:\\/\\/github.com\\/([\\w-.]+?)\\/([\\w-.]+?)\\/*\$") + + data class GithubInfo( + val username: String, + val repository: String, + val branch: String, + val fileName: String + ) + + fun parseRepository(url: String): Pair? { + val matcher = RE_REPOSITORY.matcher(url) + val (user, repository) = if (matcher.matches()) { + Pair(matcher.group(1), matcher.group(2)) + } else { + Pair(null, null) + } + return if (TextUtils.isEmpty(user) || TextUtils.isEmpty(repository)) { + null + } else { + Pair(user!!, repository!!) + } + } + + fun parseInfo(data: Uri?): GithubInfo? { + + if (data == null) { + return null + } + + val matcher = RE_FILE.matcher(data.toString()) + if (!matcher.matches()) { + return null + } + + return GithubInfo( + username = matcher.group(1)!!, + repository = matcher.group(2)!!, + branch = matcher.group(3)!!, + fileName = matcher.group(4)!! + ) + } + + fun buildRawGithubUrl(data: Uri): String { + val info = parseInfo(data) + return if (info == null) { + data.toString() + } else { + buildRawGithubUrl(info) + } + } + + @Suppress("MemberVisibilityCanBePrivate") + fun buildRawGithubUrl(info: GithubInfo): String { + return "https://github.com/${info.username}/${info.repository}/raw/${info.branch}/${info.fileName}" + } + + fun buildRepositoryReadMeUrl(username: String, repository: String): String { + return buildRawGithubUrl(GithubInfo(username, repository, "master", "README.md")) + } +} \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/utils/SampleUtils.java b/app-sample/src/main/java/io/noties/markwon/app/utils/SampleUtils.java new file mode 100644 index 00000000..c25c7dbc --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/utils/SampleUtils.java @@ -0,0 +1,42 @@ +package io.noties.markwon.app.utils; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.List; + +import io.noties.markwon.app.sample.Sample; + +public abstract class SampleUtils { + + @NonNull + public static List readSamples(@NonNull Context context) { + + try (InputStream inputStream = context.getAssets().open("samples.json")) { + return readSamples(inputStream); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + // NB! stream is not closed by this method + @NonNull + public static List readSamples(@NonNull InputStream inputStream) { + final Gson gson = new Gson(); + return gson.fromJson( + new InputStreamReader(inputStream), + new TypeToken>() { + }.getType() + ); + } + + private SampleUtils() { + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/utils/SampleUtilsKt.kt b/app-sample/src/main/java/io/noties/markwon/app/utils/SampleUtilsKt.kt new file mode 100644 index 00000000..dfbd8d27 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/utils/SampleUtilsKt.kt @@ -0,0 +1,52 @@ +package io.noties.markwon.app.utils + +import android.content.Context +import io.noties.markwon.app.sample.Sample +import io.noties.markwon.sample.annotations.MarkwonArtifact +import java.io.InputStream + +val MarkwonArtifact.displayName: String + get() = "@${artifactName()}" + +val String.tagDisplayName: String + get() = "#$this" + +private const val SAMPLE_PREFIX = "io.noties.markwon.app." + +fun Sample.readCode(context: Context): Sample.Code { + val assets = context.assets + + // keep sample and nested directories + val path = javaClassName + .removePrefix(SAMPLE_PREFIX) + .replace('.', '/') + + fun obtain(path: String): InputStream? { + return try { + assets.open(path) + } catch (t: Throwable) { + null + } + } + + // now, we have 2 possibilities -> Kotlin or Java + var language: Sample.Language = Sample.Language.KOTLIN + var stream = obtain("$path.kt") + if (stream == null) { + language = Sample.Language.JAVA + stream = obtain("$path.java") + } + + if (stream == null) { + throw IllegalStateException("Cannot obtain sample file at path: $path") + } + + val code = stream.readStringAndClose() + + return Sample.Code(language, code) +} + +fun loadReadMe(context: Context): String { + val stream = context.assets.open("README.md") + return stream.readStringAndClose() +} \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/utils/TextViewUtils.kt b/app-sample/src/main/java/io/noties/markwon/app/utils/TextViewUtils.kt new file mode 100644 index 00000000..8ff0660a --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/utils/TextViewUtils.kt @@ -0,0 +1,9 @@ +package io.noties.markwon.app.utils + +import android.text.TextUtils +import android.widget.TextView + +fun TextView.textOrHide(text: CharSequence?) { + this.text = text + this.hidden = TextUtils.isEmpty(text) +} \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/utils/TextWatcherAdapter.java b/app-sample/src/main/java/io/noties/markwon/app/utils/TextWatcherAdapter.java new file mode 100644 index 00000000..143fb964 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/utils/TextWatcherAdapter.java @@ -0,0 +1,37 @@ +package io.noties.markwon.app.utils; + +import android.text.Editable; +import android.text.TextWatcher; + +import androidx.annotation.NonNull; + +public abstract class TextWatcherAdapter implements TextWatcher { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + + } + + @Override + public void afterTextChanged(Editable s) { + + } + + public interface AfterTextChanged { + void afterTextChanged(Editable s); + } + + @NonNull + public static TextWatcher afterTextChanged(@NonNull AfterTextChanged afterTextChanged) { + return new TextWatcherAdapter() { + @Override + public void afterTextChanged(Editable s) { + afterTextChanged.afterTextChanged(s); + } + }; + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/utils/ThrowableUtils.kt b/app-sample/src/main/java/io/noties/markwon/app/utils/ThrowableUtils.kt new file mode 100644 index 00000000..4c753895 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/utils/ThrowableUtils.kt @@ -0,0 +1,13 @@ +package io.noties.markwon.app.utils + +import java.io.PrintWriter +import java.io.StringWriter + +object ThrowableUtils + +fun Throwable.stackTraceString(): String { + val stringWriter = StringWriter() + val printWriter = PrintWriter(stringWriter) + this.printStackTrace(printWriter) + return stringWriter.toString() +} \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/utils/UncaughtExceptionHandler.kt b/app-sample/src/main/java/io/noties/markwon/app/utils/UncaughtExceptionHandler.kt new file mode 100644 index 00000000..56fd6598 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/utils/UncaughtExceptionHandler.kt @@ -0,0 +1,10 @@ +package io.noties.markwon.app.utils + +@Suppress("unused") +class UncaughtExceptionHandler(private val origin: Thread.UncaughtExceptionHandler?) + : Thread.UncaughtExceptionHandler { + + override fun uncaughtException(t: Thread?, e: Throwable?) { + + } +} \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/utils/UpdateUtils.kt b/app-sample/src/main/java/io/noties/markwon/app/utils/UpdateUtils.kt new file mode 100644 index 00000000..d9a225e8 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/utils/UpdateUtils.kt @@ -0,0 +1,63 @@ +package io.noties.markwon.app.utils + +import io.noties.markwon.app.App +import io.noties.markwon.app.BuildConfig +import okhttp3.Call +import okhttp3.Callback +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import java.io.IOException + +object UpdateUtils { + + sealed class Result { + class UpdateAvailable(val url: String) : Result() + object NoUpdate : Result() + class Error(val throwable: Throwable) : Result() + } + + fun checkForUpdate(updateAction: (Result) -> Unit): Cancellable { + var action: ((Result) -> Unit)? = updateAction + + val future = App.executorService + .submit { + val url = "${BuildConfig.GIT_REPOSITORY}/raw/sample-store/version" + val request = Request.Builder() + .get() + .url(url) + .build() + OkHttpClient().newCall(request).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + action?.invoke(Result.Error(e)) + } + + override fun onResponse(call: Call, response: Response) { + try { + val revision = response.body()?.string() + val hasUpdate = revision != null && BuildConfig.GIT_SHA != revision + if (hasUpdate) { + action?.invoke(Result.UpdateAvailable(apkUrl)) + } else { + action?.invoke(Result.NoUpdate) + } + } catch (e: IOException) { + action?.invoke(Result.Error(e)) + } + } + }) + } + + return object : Cancellable { + override val isCancelled: Boolean + get() = future.isDone + + override fun cancel() { + action = null + future.cancel(true) + } + } + } + + private const val apkUrl = "${BuildConfig.GIT_REPOSITORY}/raw/sample-store/markwon-debug.apk" +} \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/utils/ViewGroupUtils.kt b/app-sample/src/main/java/io/noties/markwon/app/utils/ViewGroupUtils.kt new file mode 100644 index 00000000..f6cd05b8 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/utils/ViewGroupUtils.kt @@ -0,0 +1,7 @@ +package io.noties.markwon.app.utils + +import android.view.View +import android.view.ViewGroup + +val ViewGroup.children: List + get() = (0 until childCount).map { getChildAt(it) } \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/utils/ViewUtils.kt b/app-sample/src/main/java/io/noties/markwon/app/utils/ViewUtils.kt new file mode 100644 index 00000000..58c6e36e --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/utils/ViewUtils.kt @@ -0,0 +1,35 @@ +package io.noties.markwon.app.utils + +import android.view.View +import android.view.View.GONE +import android.view.View.VISIBLE +import android.view.ViewGroup +import android.view.ViewTreeObserver + +var View.hidden: Boolean + get() = visibility == GONE + set(value) { + visibility = if (value) GONE else VISIBLE + } + +fun View.onPreDraw(action: () -> Unit) { + viewTreeObserver.addOnPreDrawListener(object : ViewTreeObserver.OnPreDrawListener { + override fun onPreDraw(): Boolean { + val vto = viewTreeObserver + if (vto.isAlive) { + vto.removeOnPreDrawListener(this) + } + action() + // do not block drawing + return true + } + }) +} + +var View.active: Boolean + get() = isActivated + set(newValue) { + isActivated = newValue + + (this as? ViewGroup)?.children?.forEach { it.active = newValue } + } \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/widget/FlowLayout.kt b/app-sample/src/main/java/io/noties/markwon/app/widget/FlowLayout.kt new file mode 100644 index 00000000..aa82b137 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/widget/FlowLayout.kt @@ -0,0 +1,127 @@ +package io.noties.markwon.app.widget + +import android.content.Context +import android.util.AttributeSet +import android.view.ViewGroup +import io.noties.markwon.app.R +import io.noties.markwon.app.utils.hidden +import kotlin.math.max + +class FlowLayout(context: Context, attrs: AttributeSet) : ViewGroup(context, attrs) { + + private val spacingVertical: Int + private val spacingHorizontal: Int + + init { + val array = context.obtainStyledAttributes(attrs, R.styleable.FlowLayout) + try { + val spacing = array.getDimensionPixelSize(R.styleable.FlowLayout_fl_spacing, 0) + spacingVertical = array.getDimensionPixelSize(R.styleable.FlowLayout_fl_spacingVertical, spacing) + spacingHorizontal = array.getDimensionPixelSize(R.styleable.FlowLayout_fl_spacingHorizontal, spacing) + } finally { + array.recycle() + } + } + + override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { + for (i in 0 until childCount) { + val child = getChildAt(i) + val params = child.layoutParams as LayoutParams + val left = paddingLeft + params.x + val top = paddingTop + params.y + child.layout( + left, + top, + left + child.measuredWidth, + top + child.measuredHeight + ) + } + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val width = MeasureSpec.getSize(widthMeasureSpec) + + // we must have width (match_parent or exact dimension) + if (width <= 0) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + return + } + + val availableWidth = width - paddingLeft - paddingRight + + // child must not exceed our width + val childWidthSpec = MeasureSpec.makeMeasureSpec(availableWidth, MeasureSpec.AT_MOST) + + // we also could enforce flexible height here (instead of exact one) + val childHeightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED) + + var x = 0 + var y = 0 + + var lineHeight = 0 + + for (i in 0 until childCount) { + val child = getChildAt(i) + if (child.hidden) { + continue + } + + // measure + child.measure(childWidthSpec, childHeightSpec) + + val params = child.layoutParams as LayoutParams + val measuredWidth = child.measuredWidth + + if (measuredWidth > (availableWidth - x)) { + // new line + // make next child start at child measure width (starting at x = 0) + params.x = 0 + params.y = y + lineHeight + spacingVertical + + x = measuredWidth + spacingHorizontal + // move vertically by max value of child height on this line + y += lineHeight + spacingVertical + + lineHeight = child.measuredHeight + + } else { + // we fit this line + params.x = x + params.y = y + + x += measuredWidth + spacingHorizontal + lineHeight = max(lineHeight, child.measuredHeight) + } + } + + val height = y + lineHeight + paddingTop + paddingBottom + + setMeasuredDimension(width, height) + } + + override fun generateDefaultLayoutParams(): ViewGroup.LayoutParams { + return LayoutParams() + } + + override fun checkLayoutParams(p: ViewGroup.LayoutParams?): Boolean { + return p is LayoutParams + } + + override fun generateLayoutParams(attrs: AttributeSet): ViewGroup.LayoutParams { + return LayoutParams(context, attrs) + } + + override fun generateLayoutParams(p: ViewGroup.LayoutParams): ViewGroup.LayoutParams { + return LayoutParams(p) + } + + class LayoutParams : ViewGroup.LayoutParams { + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) + constructor(width: Int, height: Int) : super(width, height) + constructor(params: ViewGroup.LayoutParams) : super(params) + constructor() : this(WRAP_CONTENT, WRAP_CONTENT) + + var x: Int = 0 + var y: Int = 0 + } +} \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/widget/SearchBar.kt b/app-sample/src/main/java/io/noties/markwon/app/widget/SearchBar.kt new file mode 100644 index 00000000..7999fac4 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/widget/SearchBar.kt @@ -0,0 +1,92 @@ +package io.noties.markwon.app.widget + +import android.content.Context +import android.util.AttributeSet +import android.view.Gravity +import android.view.View +import android.widget.LinearLayout +import io.noties.markwon.app.R +import io.noties.markwon.app.utils.KeyEventUtils +import io.noties.markwon.app.utils.KeyboardUtils +import io.noties.markwon.app.utils.TextWatcherAdapter +import io.noties.markwon.app.utils.hidden + + +class SearchBar(context: Context, attrs: AttributeSet?) : LinearLayout(context, attrs) { + + private val focus: View + private val textField: TextField + private val clear: View + private val cancel: View + + var onSearchListener: ((String?) -> Unit)? = null + + init { + orientation = HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + + View.inflate(context, R.layout.view_search_bar, this) + + focus = findViewById(R.id.focus) + textField = findViewById(R.id.text_field) + clear = findViewById(R.id.clear) + cancel = findViewById(R.id.cancel) + + // listen for text state + textField.addTextChangedListener(TextWatcherAdapter.afterTextChanged { + textFieldChanged(it) + }) + + fun looseFocus() { + KeyboardUtils.hide(textField) + focus.requestFocus() + } + + // on back pressed - lose focus and hide keyboard + textField.onBackPressedListener = { + // hide keyboard and lose focus + looseFocus() + } + + textField.setOnFocusChangeListener { _, hasFocus -> + cancel.hidden = textField.text.isEmpty() && !hasFocus + } + + textField.setOnEditorActionListener { _, _, event -> + if (KeyEventUtils.isActionUp(event)) { + looseFocus() + } + return@setOnEditorActionListener true + } + + clear.setOnClickListener { + textField.setText("") + // ensure that we have focus when clear is clicked + if (!textField.hasFocus()) { + textField.requestFocus() + // additionally ensure keyboard is showing + KeyboardUtils.show(textField) + } + } + + cancel.setOnClickListener { + textField.setText("") + looseFocus() + } + + isSaveEnabled = false + textField.isSaveEnabled = false + } + + fun search(text: String) { + textField.setText(text) + } + + private fun textFieldChanged(text: CharSequence) { + val isEmpty = text.isEmpty() + clear.hidden = isEmpty + cancel.hidden = isEmpty && !textField.hasFocus() + + onSearchListener?.invoke(if (text.isEmpty()) null else text.toString()) + } +} \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/widget/TextField.kt b/app-sample/src/main/java/io/noties/markwon/app/widget/TextField.kt new file mode 100644 index 00000000..c6c627a8 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/widget/TextField.kt @@ -0,0 +1,29 @@ +package io.noties.markwon.app.widget + +import android.content.Context +import android.util.AttributeSet +import android.view.KeyEvent +import android.widget.EditText +import io.noties.markwon.app.utils.KeyEventUtils + +class TextField(context: Context, attrs: AttributeSet?) : EditText(context, attrs) { + var onBackPressedListener: (() -> Unit)? = null + + override fun onDetachedFromWindow() { + onBackPressedListener = null + super.onDetachedFromWindow() + } + + override fun onKeyPreIme(keyCode: Int, event: KeyEvent?): Boolean { + if (isAttachedToWindow) { + onBackPressedListener?.also { listener -> + if (hasFocus() + && KeyEvent.KEYCODE_BACK == keyCode + && KeyEventUtils.isActionUp(event)) { + listener() + } + } + } + return super.onKeyPreIme(keyCode, event) + } +} \ No newline at end of file diff --git a/app-sample/src/main/res/anim/screen_in.xml b/app-sample/src/main/res/anim/screen_in.xml new file mode 100644 index 00000000..ee314aa4 --- /dev/null +++ b/app-sample/src/main/res/anim/screen_in.xml @@ -0,0 +1,13 @@ + + + + + + + + \ No newline at end of file diff --git a/app-sample/src/main/res/anim/screen_in_pop.xml b/app-sample/src/main/res/anim/screen_in_pop.xml new file mode 100644 index 00000000..44de9d54 --- /dev/null +++ b/app-sample/src/main/res/anim/screen_in_pop.xml @@ -0,0 +1,13 @@ + + + + + + + + \ No newline at end of file diff --git a/app-sample/src/main/res/anim/screen_out.xml b/app-sample/src/main/res/anim/screen_out.xml new file mode 100644 index 00000000..4ce2f3e6 --- /dev/null +++ b/app-sample/src/main/res/anim/screen_out.xml @@ -0,0 +1,13 @@ + + + + + + + + \ No newline at end of file diff --git a/app-sample/src/main/res/anim/screen_out_pop.xml b/app-sample/src/main/res/anim/screen_out_pop.xml new file mode 100644 index 00000000..dd168243 --- /dev/null +++ b/app-sample/src/main/res/anim/screen_out_pop.xml @@ -0,0 +1,13 @@ + + + + + + + + \ No newline at end of file diff --git a/app-sample/src/main/res/color/tab_bar_item.xml b/app-sample/src/main/res/color/tab_bar_item.xml new file mode 100644 index 00000000..315f6d25 --- /dev/null +++ b/app-sample/src/main/res/color/tab_bar_item.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app-sample/src/main/res/drawable/bg_artifact.xml b/app-sample/src/main/res/drawable/bg_artifact.xml new file mode 100644 index 00000000..b039c039 --- /dev/null +++ b/app-sample/src/main/res/drawable/bg_artifact.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app-sample/src/main/res/drawable/bg_search_bar.xml b/app-sample/src/main/res/drawable/bg_search_bar.xml new file mode 100644 index 00000000..c7417ecd --- /dev/null +++ b/app-sample/src/main/res/drawable/bg_search_bar.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app-sample/src/main/res/drawable/bg_splash.xml b/app-sample/src/main/res/drawable/bg_splash.xml new file mode 100644 index 00000000..66e4b273 --- /dev/null +++ b/app-sample/src/main/res/drawable/bg_splash.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app-sample/src/main/res/drawable/bg_tag.xml b/app-sample/src/main/res/drawable/bg_tag.xml new file mode 100644 index 00000000..3e246adb --- /dev/null +++ b/app-sample/src/main/res/drawable/bg_tag.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/sample/src/main/res/drawable/custom_task_list.xml b/app-sample/src/main/res/drawable/custom_task_list.xml similarity index 100% rename from sample/src/main/res/drawable/custom_task_list.xml rename to app-sample/src/main/res/drawable/custom_task_list.xml diff --git a/sample/src/main/res/drawable/ic_android_black_24dp.xml b/app-sample/src/main/res/drawable/ic_android_black_24dp.xml similarity index 100% rename from sample/src/main/res/drawable/ic_android_black_24dp.xml rename to app-sample/src/main/res/drawable/ic_android_black_24dp.xml diff --git a/app-sample/src/main/res/drawable/ic_arrow_back_white_24dp.xml b/app-sample/src/main/res/drawable/ic_arrow_back_white_24dp.xml new file mode 100644 index 00000000..69ceb083 --- /dev/null +++ b/app-sample/src/main/res/drawable/ic_arrow_back_white_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app-sample/src/main/res/drawable/ic_close_white_24dp.xml b/app-sample/src/main/res/drawable/ic_close_white_24dp.xml new file mode 100644 index 00000000..85811738 --- /dev/null +++ b/app-sample/src/main/res/drawable/ic_close_white_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app-sample/src/main/res/drawable/ic_code_white_24dp.xml b/app-sample/src/main/res/drawable/ic_code_white_24dp.xml new file mode 100644 index 00000000..3925a7c2 --- /dev/null +++ b/app-sample/src/main/res/drawable/ic_code_white_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/sample/src/main/res/drawable/ic_home_black_36dp.xml b/app-sample/src/main/res/drawable/ic_home_black_36dp.xml similarity index 100% rename from sample/src/main/res/drawable/ic_home_black_36dp.xml rename to app-sample/src/main/res/drawable/ic_home_black_36dp.xml diff --git a/app/src/main/res/drawable-v26/ic_launcher_background.xml b/app-sample/src/main/res/drawable/ic_launcher_background.xml similarity index 100% rename from app/src/main/res/drawable-v26/ic_launcher_background.xml rename to app-sample/src/main/res/drawable/ic_launcher_background.xml diff --git a/sample/src/main/res/drawable/ic_memory_black_48dp.xml b/app-sample/src/main/res/drawable/ic_memory_black_48dp.xml similarity index 100% rename from sample/src/main/res/drawable/ic_memory_black_48dp.xml rename to app-sample/src/main/res/drawable/ic_memory_black_48dp.xml diff --git a/app-sample/src/main/res/drawable/ic_open_in_browser_white_24dp.xml b/app-sample/src/main/res/drawable/ic_open_in_browser_white_24dp.xml new file mode 100644 index 00000000..168a069c --- /dev/null +++ b/app-sample/src/main/res/drawable/ic_open_in_browser_white_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app-sample/src/main/res/drawable/ic_remove_red_eye_white_24dp.xml b/app-sample/src/main/res/drawable/ic_remove_red_eye_white_24dp.xml new file mode 100644 index 00000000..14954cbf --- /dev/null +++ b/app-sample/src/main/res/drawable/ic_remove_red_eye_white_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app-sample/src/main/res/drawable/ic_search_white_24dp.xml b/app-sample/src/main/res/drawable/ic_search_white_24dp.xml new file mode 100644 index 00000000..7f8f6ceb --- /dev/null +++ b/app-sample/src/main/res/drawable/ic_search_white_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/sample/src/main/res/drawable/ic_sentiment_satisfied_red_64dp.xml b/app-sample/src/main/res/drawable/ic_sentiment_satisfied_red_64dp.xml similarity index 100% rename from sample/src/main/res/drawable/ic_sentiment_satisfied_red_64dp.xml rename to app-sample/src/main/res/drawable/ic_sentiment_satisfied_red_64dp.xml diff --git a/app-sample/src/main/res/drawable/ic_stat_name.xml b/app-sample/src/main/res/drawable/ic_stat_name.xml new file mode 100644 index 00000000..a24cc224 --- /dev/null +++ b/app-sample/src/main/res/drawable/ic_stat_name.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + diff --git a/app-sample/src/main/res/layout/activity_read_me.xml b/app-sample/src/main/res/layout/activity_read_me.xml new file mode 100644 index 00000000..bb37ae71 --- /dev/null +++ b/app-sample/src/main/res/layout/activity_read_me.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app-sample/src/main/res/layout/adapt_check_for_update.xml b/app-sample/src/main/res/layout/adapt_check_for_update.xml new file mode 100644 index 00000000..6f3446e2 --- /dev/null +++ b/app-sample/src/main/res/layout/adapt_check_for_update.xml @@ -0,0 +1,15 @@ + + + +