diff --git a/.github/workflows/app-build.yml b/.github/workflows/app-build.yml index 2be14a0..a7ccf12 100644 --- a/.github/workflows/app-build.yml +++ b/.github/workflows/app-build.yml @@ -13,5 +13,7 @@ jobs: java-version: 17.0.6 cache: 'gradle' distribution: adopt + - name: Run Tests + run: ./gradlew test - name: Build with Gradle run: ./gradlew build \ No newline at end of file diff --git a/app/src/main/assets/android-quill-sample.json b/app/src/main/assets/android-quill-sample.json new file mode 100644 index 0000000..2bcd356 --- /dev/null +++ b/app/src/main/assets/android-quill-sample.json @@ -0,0 +1,165 @@ +{ + "spans": [ + { + "insert": "RichEditor", + "attributes": { + "bold": true, + "header": 1 + } + }, + { + "insert": "\nAndroid ", + "attributes": {} + }, + { + "insert": "WYSIWYG ", + "attributes": { + "bold": true, + "italic": true + } + }, + { + "insert": "Rich editor for ", + "attributes": {} + }, + { + "insert": "Jetpack compose.\n\n", + "attributes": { + "bold": true, + "underline": true + } + }, + { + "insert": "Features", + "attributes": { + "bold": true, + "header": 3 + } + }, + { + "insert": "\nThe editor offers the following ", + "attributes": {} + }, + { + "insert": "options:\n", + "attributes": { + "bold": true, + "italic": true, + "underline": true + } + }, + { + "insert": "\n", + "attributes": {} + }, + { + "insert": "Bold\n", + "attributes": { + "bold": true + } + }, + { + "insert": "Italic\n", + "attributes": { + "italic": true + } + }, + { + "insert": "Underline\n", + "attributes": { + "underline": true + } + }, + { + "insert": "Different ", + "attributes": {} + }, + { + "insert": "Headings\n\n", + "attributes": { + "bold": true, + "italic": true, + "underline": true + } + }, + { + "insert": "Bullet List:\n", + "attributes": { + "bold": true + } + }, + { + "insert": "Item 1\n", + "attributes": { + "list": "bullet" + } + }, + { + "insert": "Item 2\n", + "attributes": { + "list": "bullet" + } + }, + { + "insert": "Item 3\n", + "attributes": { + "list": "bullet" + } + }, + { + "insert": "Item 4\n", + "attributes": { + "list": "bullet" + } + }, + { + "insert": "Item 5\n", + "attributes": { + "list": "bullet" + } + }, + { + "insert": "Item 6\n", + "attributes": { + "list": "bullet" + } + }, + { + "insert": "\n", + "attributes": {} + }, + { + "insert": "Credits", + "attributes": { + "bold": true, + "header": 3 + } + }, + { + "insert": "\n\n", + "attributes": {} + }, + { + "insert": "RichEditor ", + "attributes": { + "bold": true + } + }, + { + "insert": "for compose is developed and maintained by the ", + "attributes": {} + }, + { + "insert": "canopas team.\n\n", + "attributes": { + "bold": true, + "italic": true, + "underline": true + } + }, + { + "insert": "Thank You! 😊\n\n", + "attributes": {} + } + ] +} \ No newline at end of file diff --git a/app/src/main/assets/sample-data.json b/app/src/main/assets/sample-data.json deleted file mode 100644 index 5052232..0000000 --- a/app/src/main/assets/sample-data.json +++ /dev/null @@ -1,110 +0,0 @@ -{ - "spans": [ - { - "from": 0, - "to": 10, - "style": "h1" - }, - { - "from": 0, - "to": 10, - "style": "bold" - }, - { - "from": 19, - "to": 27, - "style": "bold" - }, - { - "from": 44, - "to": 59, - "style": "bold" - }, - { - "from": 44, - "to": 59, - "style": "italic" - }, - { - "from": 44, - "to": 59, - "style": "underline" - }, - { - "from": 61, - "to": 69, - "style": "h3" - }, - { - "from": 62, - "to": 69, - "style": "bold" - }, - { - "from": 103, - "to": 118, - "style": "bold" - }, - { - "from": 119, - "to": 126, - "style": "italic" - }, - { - "from": 130, - "to": 138, - "style": "underline" - }, - { - "from": 160, - "to": 167, - "style": "h3" - }, - { - "from": 161, - "to": 167, - "style": "bold" - }, - { - "from": 169, - "to": 180, - "style": "bold" - }, - { - "from": 224, - "to": 235, - "style": "bold" - }, - { - "from": 224, - "to": 235, - "style": "italic" - }, - { - "from": 224, - "to": 235, - "style": "underline" - }, - { - "from": 237, - "to": 251, - "style": "h4" - }, - { - "from": 238, - "to": 250, - "style": "bold" - }, - { - "from": 238, - "to": 250, - "style": "italic" - }, - { - "from": 238, - "to": 250, - "style": "underline" - } - ], - "text": "Rich Editor\nAndroid WYSIWYG Rich editor for Jetpack compose.\n\nFeatures\nThe editor offers the following options\n\n- Bold\n- Italic\n- Underline\n- Different headers\n\nCredits\nRich Editor for compose is owned and maintained by the canopas team\n\nThanks You ☺️\n" -} \ No newline at end of file diff --git a/app/src/main/java/com/example/texteditor/MainActivity.kt b/app/src/main/java/com/example/texteditor/MainActivity.kt index 5847626..4c14a2a 100644 --- a/app/src/main/java/com/example/texteditor/MainActivity.kt +++ b/app/src/main/java/com/example/texteditor/MainActivity.kt @@ -39,10 +39,10 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.window.PopupProperties -import com.canopas.editor.ui.data.RichEditorState +import com.canopas.editor.ui.data.QuillEditorState import com.canopas.editor.ui.ui.RichEditor import com.canopas.editor.ui.utils.TextSpanStyle -import com.example.texteditor.parser.JsonEditorParser +import com.example.texteditor.parser.QuillJsonEditorParser import com.example.texteditor.ui.theme.TextEditorTheme class MainActivity : ComponentActivity() { @@ -66,21 +66,21 @@ fun Sample() { TextEditorTheme { val context = LocalContext.current - val state = remember { + val quillState = remember { val input = - context.assets.open("sample-data.json").bufferedReader().use { it.readText() } - RichEditorState.Builder() + context.assets.open("android-quill-sample.json").bufferedReader().use { it.readText() } + QuillEditorState.Builder() .setInput(input) - .adapter(JsonEditorParser()) + .adapter(QuillJsonEditorParser()) .build() } Column { - StyleContainer(state) + StyleContainer(quillState) RichEditor( - state = state, + state = quillState, modifier = Modifier .fillMaxWidth() .weight(1f) @@ -94,7 +94,7 @@ fun Sample() { @Composable fun StyleContainer( - state: RichEditorState, + state: QuillEditorState, ) { Row( Modifier @@ -123,6 +123,12 @@ fun StyleContainer( value = state, ) + StyleButton( + icon = R.drawable.baseline_format_list_bulleted_24, + style = TextSpanStyle.BulletStyle, + value = state, + ) + IconButton( modifier = Modifier .padding(2.dp) @@ -144,7 +150,7 @@ fun StyleContainer( @Composable fun TitleStyleButton( - value: RichEditorState + value: QuillEditorState ) { var expanded by remember { mutableStateOf(false) } @@ -220,7 +226,7 @@ fun DropDownItem( fun StyleButton( @DrawableRes icon: Int, style: TextSpanStyle, - value: RichEditorState, + value: QuillEditorState, ) { IconButton( modifier = Modifier diff --git a/app/src/main/java/com/example/texteditor/parser/JsonEditorParser.kt b/app/src/main/java/com/example/texteditor/parser/JsonEditorParser.kt deleted file mode 100644 index c0dd155..0000000 --- a/app/src/main/java/com/example/texteditor/parser/JsonEditorParser.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.example.texteditor.parser - -import com.canopas.editor.ui.model.RichText -import com.canopas.editor.ui.model.RichTextSpan -import com.canopas.editor.ui.parser.EditorAdapter -import com.google.gson.Gson -import com.google.gson.GsonBuilder -import com.google.gson.reflect.TypeToken - -class JsonEditorParser : EditorAdapter { - - private val gson: Gson = GsonBuilder() - .registerTypeAdapter(RichTextSpan::class.java, RichTextSpanAdapter()) - .create() - - override fun encode(input: String): RichText { - return gson.fromJson(input, object : TypeToken() {}.type) - } - - override fun decode(editorValue: RichText): String { - return gson.toJson(editorValue) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/example/texteditor/parser/QuillJsonEditorParser.kt b/app/src/main/java/com/example/texteditor/parser/QuillJsonEditorParser.kt new file mode 100644 index 0000000..4633d59 --- /dev/null +++ b/app/src/main/java/com/example/texteditor/parser/QuillJsonEditorParser.kt @@ -0,0 +1,23 @@ +package com.example.texteditor.parser + +import com.canopas.editor.ui.model.QuillSpan +import com.canopas.editor.ui.model.Span +import com.canopas.editor.ui.parser.QuillEditorAdapter +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import com.google.gson.reflect.TypeToken + +class QuillJsonEditorParser : QuillEditorAdapter { + + private val gson: Gson = GsonBuilder() + .registerTypeAdapter(Span::class.java, QuillRichTextStateAdapter()) + .create() + + override fun encode(input: String): QuillSpan { + return gson.fromJson(input, object : TypeToken() {}.type) + } + + override fun decode(editorValue: QuillSpan): String { + return gson.toJson(editorValue) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/texteditor/parser/QuillRichTextStateAdapter.kt b/app/src/main/java/com/example/texteditor/parser/QuillRichTextStateAdapter.kt new file mode 100644 index 0000000..cc08f45 --- /dev/null +++ b/app/src/main/java/com/example/texteditor/parser/QuillRichTextStateAdapter.kt @@ -0,0 +1,45 @@ +package com.example.texteditor.parser + +import android.util.Log +import com.canopas.editor.ui.model.Attributes +import com.canopas.editor.ui.model.Span +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonParseException +import com.google.gson.JsonSerializationContext +import com.google.gson.JsonSerializer +import java.lang.reflect.Type + +class QuillRichTextStateAdapter : JsonSerializer, JsonDeserializer { + override fun serialize( + src: Span?, + typeOfSrc: Type?, + context: JsonSerializationContext? + ): JsonElement { + val jsonObject = JsonObject() + jsonObject.add("insert", context?.serialize(src?.insert)) + jsonObject.add("attributes", context?.serialize(src?.attributes)) + return jsonObject + } + + override fun deserialize( + json: JsonElement?, + typeOfT: Type?, + context: JsonDeserializationContext? + ): Span { + try { + val jsonObject = json?.asJsonObject ?: throw JsonParseException("Invalid JSON") + val insert = jsonObject.get("insert") + val attributes = jsonObject.get("attributes") + return Span( + insert = context?.deserialize(insert, String::class.java), + attributes = context?.deserialize(attributes, Attributes::class.java) + ) + } catch (e: Exception) { + Log.e("QuillRichTextStateAdapter", "deserialize: ", e) + throw JsonParseException("Invalid JSON") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/texteditor/parser/RichTextStateAdapter.kt b/app/src/main/java/com/example/texteditor/parser/RichTextStateAdapter.kt deleted file mode 100644 index 0409208..0000000 --- a/app/src/main/java/com/example/texteditor/parser/RichTextStateAdapter.kt +++ /dev/null @@ -1,55 +0,0 @@ -package com.example.texteditor.parser - -import com.canopas.editor.ui.model.RichTextSpan -import com.canopas.editor.ui.utils.TextSpanStyle -import com.google.gson.JsonDeserializationContext -import com.google.gson.JsonDeserializer -import com.google.gson.JsonElement -import com.google.gson.JsonObject -import com.google.gson.JsonParseException -import com.google.gson.JsonSerializationContext -import com.google.gson.JsonSerializer -import java.lang.reflect.Type - -class RichTextSpanAdapter : JsonSerializer, JsonDeserializer { - override fun serialize( - src: RichTextSpan?, - typeOfSrc: Type?, - context: JsonSerializationContext? - ): JsonElement { - val jsonObject = JsonObject() - jsonObject.addProperty("from", src?.from) - jsonObject.addProperty("to", src?.to) - jsonObject.addProperty("style", src?.style?.key ?: "") - return jsonObject - } - - override fun deserialize( - json: JsonElement?, - typeOfT: Type?, - context: JsonDeserializationContext? - ): RichTextSpan { - val jsonObject = json?.asJsonObject ?: throw JsonParseException("Invalid JSON") - val fromIndex = jsonObject.get("from").asInt - val toIndex = jsonObject.get("to").asInt - val spansString = jsonObject.get("style").asString - val spanStyle = spansString.toSpanStyle() - return RichTextSpan(fromIndex, toIndex, spanStyle) - } -} - -fun String.toSpanStyle(): TextSpanStyle { - return spanStyleParserMap[this] ?: TextSpanStyle.Default -} - -val spanStyleParserMap = mapOf( - "bold" to TextSpanStyle.BoldStyle, - "italic" to TextSpanStyle.ItalicStyle, - "underline" to TextSpanStyle.UnderlineStyle, - "h1" to TextSpanStyle.H1Style, - "h2" to TextSpanStyle.H2Style, - "h3" to TextSpanStyle.H3Style, - "h4" to TextSpanStyle.H4Style, - "h5" to TextSpanStyle.H5Style, - "h6" to TextSpanStyle.H6Style, -) \ No newline at end of file diff --git a/app/src/main/res/drawable/baseline_format_list_bulleted_24.xml b/app/src/main/res/drawable/baseline_format_list_bulleted_24.xml new file mode 100644 index 0000000..00ab46e --- /dev/null +++ b/app/src/main/res/drawable/baseline_format_list_bulleted_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/build.gradle b/build.gradle index 47e8bda..8e2a8dc 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,7 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { - id 'com.android.application' version '8.0.2' apply false - id 'com.android.library' version '8.0.2' apply false + id 'com.android.application' version '8.2.2' apply false + id 'com.android.library' version '8.2.2' apply false id 'org.jetbrains.kotlin.android' version '1.7.20' apply false id 'io.github.gradle-nexus.publish-plugin' version "1.3.0" } diff --git a/editor/build.gradle b/editor/build.gradle index 15878d3..09166a3 100644 --- a/editor/build.gradle +++ b/editor/build.gradle @@ -54,31 +54,46 @@ android { withSourcesJar() } } + testOptions { + unitTests { + includeAndroidResources = true + } + unitTests.all { + jvmArgs '-noverify' + } + } } dependencies { implementation 'androidx.core:core-ktx:1.12.0' implementation 'androidx.appcompat:appcompat:1.6.1' - implementation 'com.google.android.material:material:1.10.0' + implementation 'com.google.android.material:material:1.11.0' implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.2' - implementation 'androidx.activity:activity-compose:1.8.0' + implementation 'androidx.activity:activity-compose:1.8.2' implementation platform('androidx.compose:compose-bom:2023.10.00') implementation 'androidx.compose.ui:ui' implementation 'androidx.compose.ui:ui-graphics' implementation 'androidx.compose.ui:ui-tooling-preview' implementation 'androidx.compose.material3:material3' - testImplementation 'junit:junit:4.13.2' - androidTestImplementation 'androidx.test.ext:junit:1.1.5' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' + implementation 'androidx.test:core-ktx:1.5.0' + implementation 'androidx.test.ext:junit-ktx:1.1.5' + testImplementation("junit:junit:4.13.2") + testImplementation "org.robolectric:robolectric:4.11.1" + testImplementation("org.junit.jupiter:junit-jupiter:5.8.1") + androidTestImplementation("androidx.test.ext:junit:1.1.5") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") + androidTestImplementation("androidx.compose.ui:ui-test-junit4") androidTestImplementation platform('androidx.compose:compose-bom:2023.10.00') - androidTestImplementation 'androidx.compose.ui:ui-test-junit4' debugImplementation 'androidx.compose.ui:ui-tooling' debugImplementation 'androidx.compose.ui:ui-test-manifest' implementation("io.coil-kt:coil-compose:2.4.0") implementation 'androidx.media3:media3-ui:1.1.0' implementation 'androidx.media3:media3-exoplayer:1.1.0' + testImplementation ("org.mockito.kotlin:mockito-kotlin:4.0.0") + testImplementation ("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4") + testImplementation ("org.mockito:mockito-inline:2.13.0") - + implementation 'com.google.code.gson:gson:2.10.1' } \ No newline at end of file diff --git a/editor/src/main/java/com/canopas/editor/ui/data/QuillEditorState.kt b/editor/src/main/java/com/canopas/editor/ui/data/QuillEditorState.kt new file mode 100644 index 0000000..be3f078 --- /dev/null +++ b/editor/src/main/java/com/canopas/editor/ui/data/QuillEditorState.kt @@ -0,0 +1,136 @@ +package com.canopas.editor.ui.data + +import com.canopas.editor.ui.data.QuillTextManager.Companion.headerLevel +import com.canopas.editor.ui.data.QuillTextManager.Companion.isHeaderStyle +import com.canopas.editor.ui.model.Attributes +import com.canopas.editor.ui.model.ListType +import com.canopas.editor.ui.model.QuillSpan +import com.canopas.editor.ui.model.QuillTextSpan +import com.canopas.editor.ui.model.Span +import com.canopas.editor.ui.parser.QuillDefaultAdapter +import com.canopas.editor.ui.parser.QuillEditorAdapter +import com.canopas.editor.ui.utils.TextSpanStyle + +class QuillEditorState internal constructor( + private val input: String, + private val adapter: QuillEditorAdapter = QuillDefaultAdapter(), +) { + + internal var manager: QuillTextManager + + init { + manager = QuillTextManager(getQuillSpan()) + } + + fun getQuillSpan(): QuillSpan { + return if (input.isNotEmpty()) adapter.encode(input) else QuillSpan(emptyList()) + } + + fun output(): String { + return adapter.decode(getRichText()) + } + + fun reset() { + manager.reset() + } + + fun hasStyle(style: TextSpanStyle) = manager.hasStyle(style) + + fun toggleStyle(style: TextSpanStyle) { + manager.toggleStyle(style) + } + + fun updateStyle(style: TextSpanStyle) { + manager.setStyle(style) + } + + internal fun getRichText() : QuillSpan { + val quillGroupedSpans = manager.quillTextSpans.groupBy { it.from to it.to } + val quillTextSpans = + quillGroupedSpans.map { (fromTo, spanList) -> + val (from, to) = fromTo + val uniqueStyles = spanList.map { it.style }.flatten().distinct() + QuillTextSpan(from, to, uniqueStyles) + } + + val groupedSpans = mutableListOf() + quillTextSpans.forEachIndexed { index, span -> + var insert = manager.editableText.substring(span.from, span.to + 1) + if (insert == " " || insert == "") { + return@forEachIndexed + } + val nextSpan = quillTextSpans.getOrNull(index + 1) + val previousSpan = quillTextSpans.getOrNull(index - 1) + val nextInsert = + nextSpan?.let { manager.editableText.substring(nextSpan.from, nextSpan.to + 1) } + if (nextInsert == " " || nextInsert == "") { + insert += nextInsert + } + var attributes = + Attributes( + header = + if (span.style.any { it.isHeaderStyle() }) + span.style.find { it.isHeaderStyle() }?.headerLevel() + else null, + bold = if (span.style.contains(TextSpanStyle.BoldStyle)) true else null, + italic = if (span.style.contains(TextSpanStyle.ItalicStyle)) true else null, + underline = + if (span.style.contains(TextSpanStyle.UnderlineStyle)) true else null, + list = + if (span.style.contains(TextSpanStyle.BulletStyle)) ListType.bullet + else null + ) + + if (insert == "\n") { + attributes = Attributes() + } + + if ( + previousSpan?.style?.contains(TextSpanStyle.BulletStyle) == true && + nextInsert == "\n" && + !insert.contains("\n") + ) { + insert += "\n" + } + if ( + insert == "\n" && + span.style.contains(TextSpanStyle.BulletStyle) && + previousSpan?.style?.contains(TextSpanStyle.BulletStyle) == true && + nextSpan?.style?.contains(TextSpanStyle.BulletStyle) == true + ) { + return@forEachIndexed + } + insert = insert.replace("\u200B", "") + // Merge consecutive spans with the same attributes into one + if ( + groupedSpans.isNotEmpty() && + groupedSpans.last().attributes == attributes && + (attributes.list == null || + (groupedSpans.last().insert?.contains('\n') == false)) + ) { + groupedSpans.last().insert += insert + } else { + groupedSpans.add(Span(insert, attributes)) + } + } + + return QuillSpan(groupedSpans) + } + + class Builder { + private var adapter: QuillEditorAdapter = QuillDefaultAdapter() + private var input: String = "" + + fun setInput(input: String) = apply { + this.input = input + } + + fun adapter(adapter: QuillEditorAdapter) = apply { + this.adapter = adapter + } + + fun build(): QuillEditorState { + return QuillEditorState(input, adapter) + } + } +} \ No newline at end of file diff --git a/editor/src/main/java/com/canopas/editor/ui/data/QuillTextManager.kt b/editor/src/main/java/com/canopas/editor/ui/data/QuillTextManager.kt new file mode 100644 index 0000000..015b57e --- /dev/null +++ b/editor/src/main/java/com/canopas/editor/ui/data/QuillTextManager.kt @@ -0,0 +1,679 @@ +package com.canopas.editor.ui.data + +import android.text.Editable +import android.text.Spannable +import android.text.style.BulletSpan +import android.text.style.RelativeSizeSpan +import android.text.style.StyleSpan +import android.text.style.UnderlineSpan +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.ui.text.TextRange +import com.canopas.editor.ui.model.ListType +import com.canopas.editor.ui.model.QuillSpan +import com.canopas.editor.ui.model.QuillTextSpan +import com.canopas.editor.ui.utils.TextSpanStyle +import kotlin.math.max +import kotlin.math.min + +class QuillTextManager(quillSpan: QuillSpan) { + + private var editable: Editable = + Editable.Factory() + .newEditable(quillSpan.spans.joinToString(separator = "") { it.insert ?: "" }) + internal val quillTextSpans: MutableList = mutableListOf() + + init { + quillSpan.spans.forEachIndexed { index, span -> + val attributes = span.attributes + val startIndex = if (index == 0) 0 else quillTextSpans.last().to + 1 + val fromIndex = editableText.indexOf(span.insert ?: "", startIndex = startIndex) + val endIndex = fromIndex + (span.insert?.length ?: 0) - 1 + + val textSpanStyles = attributes?.let { attrs -> + mutableListOf().apply { + attrs.header?.let { header -> + TextSpanStyle.HeaderMap.headerMap["$header"]?.let { add(it) } + } + if (attrs.bold == true) add(TextSpanStyle.BoldStyle) + if (attrs.italic == true) add(TextSpanStyle.ItalicStyle) + if (attrs.underline == true) add(TextSpanStyle.UnderlineStyle) + if (attrs.list == ListType.bullet) add(TextSpanStyle.BulletStyle) + } + } ?: mutableListOf(TextSpanStyle.Default) + + quillTextSpans.add( + QuillTextSpan( + from = fromIndex, + to = endIndex, + style = textSpanStyles + ) + ) + } + } + + internal val editableText: String + get() = editable.toString() + + private var selection = TextRange(0, 0) + internal val currentStyles = mutableStateListOf() + private var rawText: String = editableText + + internal fun setEditable(editable: Editable) { + editable.append(editableText) + this.editable = editable + if (editableText.isNotEmpty()) updateText() + } + + private fun updateText() { + editable.removeSpans() + editable.removeSpans() + editable.removeSpans() + editable.removeSpans() + + try { + quillTextSpans.forEach { + it.style.forEach { style -> + editable.setSpan( + style.style, + it.from, + it.to + 1, + Spannable.SPAN_INCLUSIVE_EXCLUSIVE + ) + } + } + } catch (e: Exception) { + e.printStackTrace() + } + + updateCurrentSpanStyle() + } + + private fun updateCurrentSpanStyle() { + if (selection.collapsed && selection.min == 0) return + currentStyles.clear() + + val currentStyles = if (selection.collapsed) { + getRichSpanByTextIndex(textIndex = selection.min - 1) + } else { + getRichSpanListByTextRange(selection).distinct() + } + + updateSpanStyle(currentStyles) + } + + private fun updateSpanStyle(currentStyles: List) { + val currentSpan = quillTextSpans.findLast { + it.from <= selection.min - 2 && it.to >= selection.min - 2 && it.style.contains( + TextSpanStyle.BulletStyle + ) + } + + if (currentSpan != null && this.selection.collapsed) { + if (editable[selection.min - 1] == '\n' && editable[selection.min - 2] == '\n') { + removeStyle(TextSpanStyle.BulletStyle) + } else { + this.currentStyles.addAll(currentStyles) + } + } else { + this.currentStyles.addAll(currentStyles) + } + } + + private fun getRichSpanByTextIndex(textIndex: Int): List { + return quillTextSpans + .filter { textIndex >= it.from && textIndex <= it.to } + .map { it.style } + .flatten() + } + + private fun getRichSpanListByTextRange(selection: TextRange): List { + val matchingSpans = mutableListOf() + val currentSpan = quillTextSpans.find { it.from <= selection.min && it.to >= selection.min } + if (currentSpan != null) { + matchingSpans.addAll(currentSpan.style) + } + return matchingSpans + } + + fun toggleStyle(style: TextSpanStyle) { + if (currentStyles.contains(style)) { + removeStyle(style) + } else { + addStyle(style) + } + } + + private fun removeStyle(style: TextSpanStyle) { + if (currentStyles.contains(style)) { + if (style == TextSpanStyle.BulletStyle && editable[selection.min - 1] == '\n') { + editable.delete(selection.min - 1, selection.min) + } else if (style == TextSpanStyle.BulletStyle && editable[selection.min - 1] != '\n') { + return + } + currentStyles.remove(style) + } + + if (!selection.collapsed) { + val fromIndex = selection.min + val toIndex = selection.max - 1 + + quillTextSpans.firstOrNull { it.from <= fromIndex && it.to >= toIndex } + ?.let { selectedSpan -> + val index = quillTextSpans.indexOf(selectedSpan) + val updatedStyle = selectedSpan.style.filterNot { it == style } + val newSpans = mutableListOf() + + when { + fromIndex == selectedSpan.from && toIndex == selectedSpan.to -> { + quillTextSpans[index] = selectedSpan.copy(style = updatedStyle) + } + + fromIndex == selectedSpan.from && toIndex < selectedSpan.to -> { + newSpans.add(QuillTextSpan(fromIndex, toIndex, updatedStyle)) + newSpans.add( + QuillTextSpan( + toIndex + 1, + selectedSpan.to, + selectedSpan.style + ) + ) + quillTextSpans.removeAt(index) + quillTextSpans.addAll(index, newSpans) + } + + fromIndex > selectedSpan.from -> { + newSpans.add( + QuillTextSpan( + selectedSpan.from, + fromIndex - 1, + selectedSpan.style + ) + ) + newSpans.add(QuillTextSpan(fromIndex, toIndex, updatedStyle)) + newSpans.add( + QuillTextSpan( + toIndex + 1, + selectedSpan.to, + selectedSpan.style + ) + ) + quillTextSpans.removeAt(index) + quillTextSpans.addAll(index, newSpans) + } + + else -> {} + } + } + updateText() + } + } + + private fun addStyle(style: TextSpanStyle) { + when { + selection.min > 0 -> { + if (style != TextSpanStyle.BulletStyle || editable[selection.min - 1] == '\n') { + currentStyles.add(style) + if (style == TextSpanStyle.BulletStyle && selection.collapsed) { + editable.insert(selection.min, "\u200B") + } + } + } + + selection.min == 0 && style == TextSpanStyle.BulletStyle && selection.collapsed -> { + currentStyles.add(style) + editable.insert(selection.min, "\u200B") + } + + !currentStyles.contains(style) -> currentStyles.add(style) + } + + if ((style.isHeaderStyle() || style.isDefault()) && selection.collapsed) { + handleAddHeaderStyle(style) + } + + if (!selection.collapsed || selection.min <= 0 || + (style != TextSpanStyle.BulletStyle || editable[selection.min - 1] == '\n') + ) { + applyStylesToSelectedText(style) + } + } + + private fun handleAddHeaderStyle(style: TextSpanStyle, text: String = rawText) { + if (text.isEmpty()) return + val fromIndex = selection.min + val toIndex = if (selection.collapsed) fromIndex else selection.max + + val currentSpan = quillTextSpans.find { it.from <= fromIndex && it.to >= toIndex } + val index = quillTextSpans.indexOf(currentSpan) + quillTextSpans[index] = + currentSpan?.copy( + style = currentSpan.style.filterNot { it.isHeaderStyle() } + listOf(style) + ) ?: return + updateText() + } + + private fun handleRemoveHeaderStyle(text: String = rawText) { + if (text.isEmpty()) return + + val fromIndex = selection.min + val toIndex = selection.max + + val startIndex: Int = max(0, text.lastIndexOf("\n", fromIndex - 1)) + var endIndex: Int = text.indexOf("\n", toIndex) + if (endIndex == -1) endIndex = text.length - 1 + + val nextNewlineIndex = text.lastIndexOf("\n", startIndex) + + if (quillTextSpans.none { it.from < nextNewlineIndex && it.to >= startIndex && it.style.any { it.isHeaderStyle() } }) return + + quillTextSpans.removeAll { + it.from < endIndex && it.to >= startIndex && it.style.size == 1 && it.style.first() + .isHeaderStyle() + } + } + + private fun applyStylesToSelectedText(style: TextSpanStyle) { + if (selection.collapsed) return + + val fromIndex = selection.min + val toIndex = selection.max + + val selectedSpan = quillTextSpans.find { it.from <= fromIndex && (it.to + 1) >= toIndex } + val index = quillTextSpans.indexOf(selectedSpan) + + when { + selectedSpan != null -> { + if (fromIndex == selectedSpan.from && toIndex < selectedSpan.to) { + val newSpan = QuillTextSpan( + from = fromIndex, + to = toIndex - 1, + style = selectedSpan.style + listOf(style) + ) + val nextSpan = QuillTextSpan( + from = toIndex, + to = selectedSpan.to, + style = selectedSpan.style + ) + quillTextSpans[index] = newSpan + quillTextSpans.add(index + 1, nextSpan) + } else if (fromIndex > selectedSpan.from && toIndex < selectedSpan.to) { + val previousSpan = QuillTextSpan( + from = selectedSpan.from, + to = fromIndex - 1, + style = selectedSpan.style + ) + val newSpan = QuillTextSpan( + from = fromIndex, + to = toIndex - 1, + style = selectedSpan.style + listOf(style) + ) + val nextSpan = QuillTextSpan( + from = toIndex, + to = selectedSpan.to, + style = selectedSpan.style + ) + quillTextSpans[index] = previousSpan + quillTextSpans.add(index + 1, newSpan) + quillTextSpans.add(index + 2, nextSpan) + } else if (fromIndex > selectedSpan.from && (toIndex == selectedSpan.to || toIndex == (selectedSpan.to + 1))) { + val previousSpan = QuillTextSpan( + from = selectedSpan.from, + to = fromIndex - 1, + style = selectedSpan.style + ) + val newSpan = QuillTextSpan( + from = fromIndex, + to = toIndex - 1, + style = selectedSpan.style + listOf(style) + ) + quillTextSpans[index] = previousSpan + quillTextSpans.add(index + 1, newSpan) + } else { + quillTextSpans[index] = + selectedSpan.copy(style = selectedSpan.style + listOf(style)) + } + } + + else -> quillTextSpans.add( + QuillTextSpan( + from = fromIndex, + to = toIndex - 1, + style = listOf(style) + ) + ) + } + + updateText() + } + + fun setStyle(style: TextSpanStyle) { + currentStyles.clear() + currentStyles.add(style) + + if ((style.isHeaderStyle() || style.isDefault())) { + handleAddHeaderStyle(style) + return + } + if (!selection.collapsed) { + applyStylesToSelectedText(style) + } + } + + fun onTextFieldValueChange(newText: Editable, selection: TextRange) { + this.selection = selection + if (newText.length > rawText.length) handleAddingCharacters(newText) + else if (newText.length < rawText.length) handleRemovingCharacters(newText) + + updateText() + this.rawText = newText.toString() + } + + internal fun handleAddingCharacters(newValue: Editable) { + val typedCharsCount = newValue.length - rawText.length + val startTypeIndex = selection.min - typedCharsCount + + if (newValue.getOrNull(startTypeIndex) == '\n' && currentStyles.any { it.isHeaderStyle() }) { + currentStyles.clear() + quillTextSpans.find { it.from <= startTypeIndex && it.to >= startTypeIndex } + ?.let { span -> + val index = quillTextSpans.indexOf(span) + val styles = span.style.filterNot { it.isHeaderStyle() } + val updatedSpan = span.copy(style = styles) + quillTextSpans[index] = updatedSpan + } + } + + val selectedStyles = currentStyles.distinct() + moveSpans(startTypeIndex, typedCharsCount) + + val currentSpan = + quillTextSpans.find { it.from <= startTypeIndex && it.to >= startTypeIndex } + val isBulletStyle = selectedStyles.contains(TextSpanStyle.BulletStyle) + + currentSpan?.let { span -> + val index = quillTextSpans.indexOf(span) + val styles = (span.style + selectedStyles).distinct() + val from = span.from + val to = span.to + + when { + span.style == selectedStyles -> { + if (isBulletStyle && newValue.getOrNull(startTypeIndex) == '\n') { + if (newValue.getOrNull(startTypeIndex - 1) != '\n') { + when (startTypeIndex) { + to -> { + quillTextSpans.add( + index + 1, + span.copy( + from = startTypeIndex, + to = startTypeIndex + typedCharsCount - 1, + style = selectedStyles + ) + ) + quillTextSpans.add( + index + 2, + span.copy( + from = startTypeIndex + typedCharsCount, + to = to + typedCharsCount, + style = selectedStyles + ) + ) + } + + in (from + 1) until to -> { + val newSpans = mutableListOf() + newSpans.add(span.copy(to = startTypeIndex - 1, style = styles)) + newSpans.add( + span.copy( + from = startTypeIndex, + to = startTypeIndex + typedCharsCount - 1, + style = selectedStyles + ) + ) + newSpans.add( + span.copy( + from = startTypeIndex + typedCharsCount, + to = to + typedCharsCount, + style = styles + ) + ) + quillTextSpans.removeAt(index) + quillTextSpans.addAll(index, newSpans) + } + + else -> { + val updatedSpan = + span.copy(to = to + typedCharsCount, style = selectedStyles) + quillTextSpans[index] = updatedSpan + quillTextSpans.add(index + 1, updatedSpan) + } + } + } else { + quillTextSpans[index] = + span.copy(to = to + typedCharsCount, style = styles.filterNot { + it == TextSpanStyle.BulletStyle + }) + } + } else { + quillTextSpans[index] = span.copy(to = to + typedCharsCount, style = styles) + } + } + + span.style != selectedStyles -> { + quillTextSpans.removeAt(index) + val newSpans = mutableListOf() + if (startTypeIndex != from) { + newSpans.add(span.copy(to = startTypeIndex - 1)) + } + newSpans.add( + span.copy( + from = startTypeIndex, + to = startTypeIndex + typedCharsCount - 1, + style = selectedStyles + ) + ) + newSpans.add( + span.copy( + from = startTypeIndex + typedCharsCount, + to = to + typedCharsCount, + style = styles + ) + ) + quillTextSpans.addAll(index, newSpans) + } + + startTypeIndex == from && to == startTypeIndex -> { + quillTextSpans[index] = + span.copy(to = to + typedCharsCount, style = selectedStyles) + } + + startTypeIndex == from && to > startTypeIndex -> { + quillTextSpans[index] = + span.copy(to = startTypeIndex + typedCharsCount - 1, style = selectedStyles) + quillTextSpans.add( + index + 1, + span.copy( + from = startTypeIndex + typedCharsCount, + to = to + typedCharsCount, + style = styles + ) + ) + } + + startTypeIndex > from && to == startTypeIndex -> { + quillTextSpans[index] = span.copy(to = to + typedCharsCount, style = styles) + } + + startTypeIndex in (from + 1) until to -> { + val newSpans = mutableListOf() + newSpans.add(span.copy(to = startTypeIndex - 1, style = styles)) + newSpans.add( + span.copy( + from = startTypeIndex, + to = startTypeIndex + typedCharsCount - 1, + style = selectedStyles + ) + ) + newSpans.add( + span.copy( + from = startTypeIndex + typedCharsCount, + to = to + typedCharsCount, + style = styles + ) + ) + quillTextSpans.removeAt(index) + quillTextSpans.addAll(index, newSpans) + } + + else -> {} + } + } ?: quillTextSpans.add( + QuillTextSpan( + from = startTypeIndex, + to = startTypeIndex + typedCharsCount - 1, + style = selectedStyles + ) + ) + } + + private fun moveSpans(startTypeIndex: Int, by: Int) { + val filteredSpans = quillTextSpans.filter { it.from > startTypeIndex } + + filteredSpans.forEach { + val index = quillTextSpans.indexOf(it) + quillTextSpans[index] = + it.copy( + from = it.from + by, + to = it.to + by, + ) + } + } + + private fun handleRemovingCharacters(newText: Editable) { + if (newText.isEmpty()) { + quillTextSpans.clear() + currentStyles.clear() + return + } + + val removedCharsCount = rawText.length - newText.length + val startRemoveIndex = selection.min + removedCharsCount + val endRemoveIndex = selection.min + val removeRange = endRemoveIndex until startRemoveIndex + + val newLineIndex = rawText.substring(endRemoveIndex, startRemoveIndex).indexOf("\n") + + if (newLineIndex != -1) { + handleRemoveHeaderStyle(newText.toString()) + } + + val iterator = quillTextSpans.iterator() + + val partsCopy = quillTextSpans.toMutableList() + + while (iterator.hasNext()) { + val part = iterator.next() + val index = partsCopy.indexOf(part) + val previousPart = partsCopy.getOrNull(index - 1) + val nextPart = partsCopy.getOrNull(index + 1) + + if (removeRange.last < part.from) { + if (part.style.contains(TextSpanStyle.BulletStyle)) { + if ( + previousPart?.style?.contains(TextSpanStyle.BulletStyle) == true || + nextPart?.style?.contains(TextSpanStyle.BulletStyle) == true + ) { + partsCopy[index] = + part.copy( + from = part.from - removedCharsCount, + to = part.to - removedCharsCount + ) + } else { + partsCopy[index] = + part.copy( + from = part.from - removedCharsCount, + to = part.to - removedCharsCount, + style = part.style.filterNot { it == TextSpanStyle.BulletStyle } + ) + } + } else { + partsCopy[index] = + part.copy( + from = part.from - removedCharsCount, + to = part.to - removedCharsCount + ) + } + } else if (removeRange.first <= part.from && removeRange.last >= part.to) { + partsCopy.removeAt(index) + } else if (removeRange.first <= part.from) { + partsCopy[index] = + part.copy( + from = max(0, removeRange.first), + to = min(newText.length, part.to - removedCharsCount) + ) + } else if (removeRange.last <= part.to) { + partsCopy[index] = part.copy(to = part.to - removedCharsCount) + } else if (removeRange.first < part.to) { + partsCopy[index] = part.copy(to = removeRange.first) + } + } + + quillTextSpans.clear() + quillTextSpans.addAll(partsCopy) + } + + internal fun adjustSelection(selection: TextRange) { + if (this.selection != selection) { + this.selection = selection + updateCurrentSpanStyle() + } + } + + fun hasStyle(style: TextSpanStyle) = currentStyles.contains(style) + + fun reset() { + quillTextSpans.clear() + this.rawText = "" + this.editable.clear() + updateText() + } + + companion object { + fun TextSpanStyle.isDefault(): Boolean { + return this == TextSpanStyle.Default + } + + fun TextSpanStyle.isHeaderStyle(): Boolean { + val headers = + listOf( + TextSpanStyle.H1Style, + TextSpanStyle.H2Style, + TextSpanStyle.H3Style, + TextSpanStyle.H4Style, + TextSpanStyle.H5Style, + TextSpanStyle.H6Style, + ) + + return headers.contains(this) + } + + internal fun TextSpanStyle.headerLevel(): Int? { + return when (this) { + TextSpanStyle.H1Style -> 1 + TextSpanStyle.H2Style -> 2 + TextSpanStyle.H3Style -> 3 + TextSpanStyle.H4Style -> 4 + TextSpanStyle.H5Style -> 5 + TextSpanStyle.H6Style -> 6 + else -> null + } + } + + internal inline fun Editable.removeSpans() { + val allSpans = getSpans(0, length, T::class.java) + for (span in allSpans) { + removeSpan(span) + } + } + } +} diff --git a/editor/src/main/java/com/canopas/editor/ui/data/RichEditorState.kt b/editor/src/main/java/com/canopas/editor/ui/data/RichEditorState.kt deleted file mode 100644 index b11dea4..0000000 --- a/editor/src/main/java/com/canopas/editor/ui/data/RichEditorState.kt +++ /dev/null @@ -1,59 +0,0 @@ -package com.canopas.editor.ui.data - -import com.canopas.editor.ui.model.RichText -import com.canopas.editor.ui.parser.DefaultAdapter -import com.canopas.editor.ui.parser.EditorAdapter -import com.canopas.editor.ui.utils.TextSpanStyle - -class RichEditorState internal constructor( - private val input: String, - private val adapter: EditorAdapter = DefaultAdapter(), -) { - - internal var manager: RichTextManager - - init { - manager = RichTextManager(getRichText()) - } - - private fun getRichText(): RichText { - return if (input.isNotEmpty()) adapter.encode(input) else RichText() - } - - fun output(): String { - return adapter.decode(manager.richText) - } - - fun reset() { - manager.reset() - } - - fun hasStyle(style: TextSpanStyle) = manager.hasStyle(style) - - fun toggleStyle(style: TextSpanStyle) { - manager.toggleStyle(style) - } - - fun updateStyle(style: TextSpanStyle) { - manager.setStyle(style) - } - - class Builder { - private var adapter: EditorAdapter = DefaultAdapter() - private var input: String = "" - - fun setInput(input: String) = apply { - this.input = input - } - - fun adapter(adapter: EditorAdapter) = apply { - this.adapter = adapter - } - - fun build(): RichEditorState { - return RichEditorState(input, adapter) - } - } -} - - diff --git a/editor/src/main/java/com/canopas/editor/ui/data/RichTextManager.kt b/editor/src/main/java/com/canopas/editor/ui/data/RichTextManager.kt deleted file mode 100644 index 91e1aa0..0000000 --- a/editor/src/main/java/com/canopas/editor/ui/data/RichTextManager.kt +++ /dev/null @@ -1,441 +0,0 @@ -package com.canopas.editor.ui.data - -import android.text.Editable -import android.text.Spannable -import android.text.style.RelativeSizeSpan -import android.text.style.StyleSpan -import android.text.style.UnderlineSpan -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.ui.text.TextRange -import com.canopas.editor.ui.model.RichText -import com.canopas.editor.ui.model.RichTextSpan -import com.canopas.editor.ui.utils.TextSpanStyle -import kotlin.math.max -import kotlin.math.min - -class RichTextManager(richText: RichText) { - - private var editable: Editable = Editable.Factory().newEditable(richText.text) - private val spans: MutableList = richText.spans - private val editableText: String get() = editable.toString() - - private var selection = TextRange(0, 0) - private val currentStyles = mutableStateListOf() - private var rawText: String = richText.text - - internal val richText: RichText - get() = RichText(editableText, spans) - - internal fun setEditable(editable: Editable) { - editable.append(editableText) - this.editable = editable - if (editableText.isNotEmpty()) updateText() - } - - private fun updateText() { - editable.removeSpans() - editable.removeSpans() - editable.removeSpans() - - spans.forEach { - editable.setSpan( - it.style.style, - it.from, - it.to + 1, - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE - ) - } - - updateCurrentSpanStyle() - } - - private fun updateCurrentSpanStyle() { - if (this.selection.collapsed && this.selection.min == 0) return - this.currentStyles.clear() - - val currentStyles = if (selection.collapsed) { - getRichSpanByTextIndex(textIndex = selection.min - 1) - } else { - getRichSpanListByTextRange(selection).distinct() - } - - this.currentStyles.addAll(currentStyles) - } - - private fun getRichSpanByTextIndex(textIndex: Int): List { - return spans.filter { textIndex >= it.from && textIndex <= it.to } - .map { it.style } - } - - private fun getRichSpanListByTextRange(selection: TextRange): List { - val matchingSpans = mutableListOf() - - for (part in spans) { - val partRange = TextRange(part.from, part.to) - if (selection.overlaps(partRange)) { - part.style.let { - matchingSpans.add(it) - } - } - } - return matchingSpans - } - - fun toggleStyle(style: TextSpanStyle) { - if (currentStyles.contains(style)) { - removeStyle(style) - } else { - addStyle(style) - } - } - - private fun removeStyle(style: TextSpanStyle) { - if (currentStyles.contains(style)) { - currentStyles.remove(style) - } - - if (!selection.collapsed) { - val fromIndex = selection.min - val toIndex = selection.max - - val selectedParts = spans.filter { part -> - part.from < toIndex && part.to >= fromIndex && part.style == style - } - removeStylesFromSelectedPart(selectedParts, fromIndex, toIndex) - updateText() - } - } - - private fun addStyle(style: TextSpanStyle) { - if (!currentStyles.contains(style)) { - currentStyles.add(style) - } - - if ((style.isHeaderStyle() || style.isDefault()) && selection.collapsed) { - handleAddHeaderStyle(style) - } - - if (!selection.collapsed) { - applyStylesToSelectedText(style) - } - } - - private fun handleAddHeaderStyle( - style: TextSpanStyle, - text: String = rawText - ) { - if (text.isEmpty()) return - val fromIndex = selection.min - val toIndex = if (selection.collapsed) fromIndex else selection.max - - val startIndex: Int = max(0, text.lastIndexOf("\n", fromIndex - 1)) - var endIndex: Int = text.indexOf("\n", toIndex) - - if (endIndex == -1) endIndex = text.length - 1 - val selectedParts = - spans.filter { endIndex >= it.to && startIndex <= it.from && it.style.isHeaderStyle() } - - spans.removeAll(selectedParts) - spans.add( - RichTextSpan( - from = startIndex, - to = endIndex, - style = style - ) - ) - updateText() - } - - private fun handleRemoveHeaderStyle( - text: String = rawText - ) { - if (text.isEmpty()) return - - val fromIndex = selection.min - val toIndex = selection.max - - val startIndex: Int = max(0, text.lastIndexOf("\n", fromIndex - 1)) - var endIndex: Int = text.indexOf("\n", toIndex) - - if (endIndex == -1) endIndex = text.length - 1 - - val nextNewlineIndex = text.lastIndexOf("\n", startIndex) - - val parts = spans.filter { part -> - part.from < nextNewlineIndex && part.to >= startIndex - } - if (parts.isEmpty() && fromIndex - 1 == nextNewlineIndex) return - - val selectedParts = spans.filter { part -> - part.from < endIndex && part.to >= startIndex - } - - spans.removeAll(selectedParts.filter { it.style.isHeaderStyle() }) - } - - private fun removeStylesFromSelectedPart( - selectedParts: List, - fromIndex: Int, toIndex: Int - ) { - selectedParts.forEach { part -> - val index = spans.indexOf(part) - if (index !in spans.indices) return@forEach - - if (part.from < fromIndex && part.to >= toIndex) { - spans[index] = part.copy(to = fromIndex - 1) - spans.add(index + 1, part.copy(from = toIndex)) - } else if (part.from < fromIndex) { - spans[index] = part.copy(to = fromIndex - 1) - } else if (part.to > toIndex) { - spans[index] = part.copy(from = toIndex) - } else { - spans.removeAt(index) - } - } - } - - private fun applyStylesToSelectedText(style: TextSpanStyle) { - if (selection.collapsed) return - - val fromIndex = selection.min - val toIndex = selection.max - - val selectedParts = spans.filter { part -> - part.from < toIndex && part.to >= fromIndex - } - val startParts = spans.filter { fromIndex - 1 in it.from..it.to } - val endParts = spans.filter { toIndex in it.from..it.to } - - val updateToIndex: (RichTextSpan, Int) -> Unit = { part, index -> - val partIndex = spans.indexOf(part) - spans[partIndex] = part.copy(to = index) - } - - val updateFromIndex: (RichTextSpan, Int) -> Unit = { part, index -> - val partIndex = spans.indexOf(part) - spans[partIndex] = part.copy(from = index) - } - - if (startParts.isEmpty() && endParts.isEmpty() && selectedParts.isNotEmpty()) { - spans.add(RichTextSpan(from = fromIndex, to = toIndex - 1, style = style)) - } else if (style in startParts.map { it.style }) { - startParts.filter { it.style == style }.forEach { updateToIndex(it, toIndex - 1) } - } else if (style in endParts.map { it.style }) { - endParts.filter { it.style == style } - .forEach { part -> updateFromIndex(part, fromIndex) } - } else { - spans.add(RichTextSpan(from = fromIndex, to = toIndex - 1, style = style)) - } - - updateText() - } - - fun setStyle(style: TextSpanStyle) { - currentStyles.clear() - currentStyles.add(style) - - if ((style.isHeaderStyle() || style.isDefault())) { - handleAddHeaderStyle(style) - return - } - if (!selection.collapsed) { - applyStylesToSelectedText(style) - } - } - - fun onTextFieldValueChange(newText: Editable, selection: TextRange) { - this.selection = selection - if (newText.length > rawText.length) - handleAddingCharacters(newText) - else if (newText.length < rawText.length) - handleRemovingCharacters(newText) - - updateText() - this.rawText = newText.toString() - - } - - private fun handleAddingCharacters(newValue: Editable) { - val typedChars = newValue.length - rawText.length - val startTypeIndex = selection.min - typedChars - - if (newValue.getOrNull(startTypeIndex) == '\n' && currentStyles.any { it.isHeaderStyle() }) { - currentStyles.clear() - } - - val selectedStyles = currentStyles.toMutableList() - - moveSpans(startTypeIndex, typedChars) - - val startParts = spans.filter { startTypeIndex - 1 in it.from..it.to } - val endParts = spans.filter { startTypeIndex in it.from..it.to } - val commonParts = startParts.intersect(endParts.toSet()) - - startParts.filter { it !in commonParts } - .forEach { - if (selectedStyles.contains(it.style)) { - val index = spans.indexOf(it) - spans[index] = it.copy(to = it.to + typedChars) - selectedStyles.remove(it.style) - } - } - - endParts.filter { it !in commonParts } - .forEach { processSpan(it, typedChars, startTypeIndex, selectedStyles, true) } - - commonParts.forEach { processSpan(it, typedChars, startTypeIndex, selectedStyles) } - - selectedStyles.forEach { - spans.add( - RichTextSpan( - from = startTypeIndex, - to = startTypeIndex + typedChars - 1, - style = it - ) - ) - } - } - - private fun processSpan( - richTextSpan: RichTextSpan, - typedChars: Int, - startTypeIndex: Int, - selectedStyles: MutableList, - forward: Boolean = false - ) { - - val newFromIndex = richTextSpan.from + typedChars - val newToIndex = richTextSpan.to + typedChars - - val index = spans.indexOf(richTextSpan) - if (selectedStyles.contains(richTextSpan.style)) { - spans[index] = richTextSpan.copy(to = newToIndex) - selectedStyles.remove(richTextSpan.style) - } else { - if (forward) { - spans[index] = richTextSpan.copy( - from = newFromIndex, - to = newToIndex - ) - } else { - spans[index] = richTextSpan.copy(to = startTypeIndex - 1) - spans.add( - index + 1, richTextSpan.copy( - from = startTypeIndex + typedChars, - to = newToIndex - ) - ) - selectedStyles.remove(richTextSpan.style) - } - } - } - - private fun moveSpans(startTypeIndex: Int, by: Int) { - val filteredSpans = spans.filter { it.from > startTypeIndex } - - filteredSpans.forEach { - val index = spans.indexOf(it) - spans[index] = it.copy( - from = it.from + by, - to = it.to + by, - ) - } - } - - private fun handleRemovingCharacters(newText: Editable) { - if (newText.isEmpty()) { - spans.clear() - currentStyles.clear() - return - } - - val removedCharsCount = rawText.length - newText.length - val startRemoveIndex = selection.min + removedCharsCount - val endRemoveIndex = selection.min - val removeRange = endRemoveIndex until startRemoveIndex - - val newLineIndex = rawText.substring(endRemoveIndex, startRemoveIndex).indexOf("\n") - - if (newLineIndex != -1) { - handleRemoveHeaderStyle(newText.toString()) - } - - val iterator = spans.iterator() - - val partsCopy = spans.toMutableList() - - while (iterator.hasNext()) { - val part = iterator.next() - val index = partsCopy.indexOf(part) - - if (removeRange.last < part.from) { - partsCopy[index] = part.copy( - from = part.from - removedCharsCount, - to = part.to - removedCharsCount - ) - } else if (removeRange.first <= part.from && removeRange.last >= part.to) { - // Remove the element from the copy. - partsCopy.removeAt(index) - } else if (removeRange.first <= part.from) { - partsCopy[index] = part.copy( - from = max(0, removeRange.first), - to = min(newText.length, part.to - removedCharsCount) - ) - } else if (removeRange.last <= part.to) { - partsCopy[index] = part.copy(to = part.to - removedCharsCount) - } else if (removeRange.first < part.to) { - partsCopy[index] = part.copy(to = removeRange.first) - } - } - - spans.clear() - spans.addAll(partsCopy) - } - - internal fun adjustSelection(selection: TextRange) { - if (this.selection != selection) { - this.selection = selection - updateCurrentSpanStyle() - } - } - - fun hasStyle(style: TextSpanStyle) = currentStyles.contains(style) - - fun reset() { - spans.clear() - this.rawText = "" - this.editable.clear() - updateText() - } - - companion object { - private fun TextRange.overlaps(range: TextRange): Boolean { - return end > range.start && start < range.end - } - - fun TextSpanStyle.isDefault(): Boolean { - return this == TextSpanStyle.Default - } - - fun TextSpanStyle.isHeaderStyle(): Boolean { - val headers = listOf( - TextSpanStyle.H1Style, - TextSpanStyle.H2Style, - TextSpanStyle.H3Style, - TextSpanStyle.H4Style, - TextSpanStyle.H5Style, - TextSpanStyle.H6Style, - ) - - return headers.contains(this) - } - - internal inline fun Editable.removeSpans() { - val allSpans = getSpans(0, length, T::class.java) - for (span in allSpans) { - removeSpan(span) - } - } - - } -} \ No newline at end of file diff --git a/editor/src/main/java/com/canopas/editor/ui/model/QuillSpan.kt b/editor/src/main/java/com/canopas/editor/ui/model/QuillSpan.kt new file mode 100644 index 0000000..f64015b --- /dev/null +++ b/editor/src/main/java/com/canopas/editor/ui/model/QuillSpan.kt @@ -0,0 +1,22 @@ +package com.canopas.editor.ui.model + +data class QuillSpan( + val spans: List +) + +data class Span( + var insert: String?, + val attributes: Attributes? = null +) + +data class Attributes( + val header: Int? = null, + val bold: Boolean? = null, + val italic: Boolean? = null, + val underline: Boolean? = null, + val list: ListType? = null +) + +enum class ListType { + ordered, bullet +} \ No newline at end of file diff --git a/editor/src/main/java/com/canopas/editor/ui/model/RichTextSpan.kt b/editor/src/main/java/com/canopas/editor/ui/model/QuillTextSpan.kt similarity index 66% rename from editor/src/main/java/com/canopas/editor/ui/model/RichTextSpan.kt rename to editor/src/main/java/com/canopas/editor/ui/model/QuillTextSpan.kt index aff0222..610191b 100644 --- a/editor/src/main/java/com/canopas/editor/ui/model/RichTextSpan.kt +++ b/editor/src/main/java/com/canopas/editor/ui/model/QuillTextSpan.kt @@ -2,8 +2,8 @@ package com.canopas.editor.ui.model import com.canopas.editor.ui.utils.TextSpanStyle -data class RichTextSpan( +data class QuillTextSpan( val from: Int, val to: Int, - val style: TextSpanStyle, + val style: List, ) \ No newline at end of file diff --git a/editor/src/main/java/com/canopas/editor/ui/model/RichText.kt b/editor/src/main/java/com/canopas/editor/ui/model/RichText.kt deleted file mode 100644 index f1fd4a8..0000000 --- a/editor/src/main/java/com/canopas/editor/ui/model/RichText.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.canopas.editor.ui.model - -data class RichText( - val text: String = "", - val spans: MutableList = mutableListOf() -) \ No newline at end of file diff --git a/editor/src/main/java/com/canopas/editor/ui/parser/EditorAdapter.kt b/editor/src/main/java/com/canopas/editor/ui/parser/EditorAdapter.kt deleted file mode 100644 index 0ed6869..0000000 --- a/editor/src/main/java/com/canopas/editor/ui/parser/EditorAdapter.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.canopas.editor.ui.parser - -import com.canopas.editor.ui.model.RichText - -interface EditorAdapter { - fun encode(input: String): RichText - fun decode(editorValue: RichText): String -} - -class DefaultAdapter : EditorAdapter { - override fun encode(input: String): RichText { - return RichText("") - } - - override fun decode(editorValue: RichText): String { - return editorValue.text - } -} \ No newline at end of file diff --git a/editor/src/main/java/com/canopas/editor/ui/parser/QuillEditorAdapter.kt b/editor/src/main/java/com/canopas/editor/ui/parser/QuillEditorAdapter.kt new file mode 100644 index 0000000..9ee434d --- /dev/null +++ b/editor/src/main/java/com/canopas/editor/ui/parser/QuillEditorAdapter.kt @@ -0,0 +1,18 @@ +package com.canopas.editor.ui.parser + +import com.canopas.editor.ui.model.QuillSpan + +interface QuillEditorAdapter { + fun encode(input: String): QuillSpan + fun decode(editorValue: QuillSpan): String +} + +class QuillDefaultAdapter : QuillEditorAdapter { + override fun encode(input: String): QuillSpan { + return QuillSpan(listOf()) + } + + override fun decode(editorValue: QuillSpan): String { + return editorValue.spans.toString() + } +} \ No newline at end of file diff --git a/editor/src/main/java/com/canopas/editor/ui/ui/RichEditor.kt b/editor/src/main/java/com/canopas/editor/ui/ui/RichEditor.kt index 4e3094a..00ddcc2 100644 --- a/editor/src/main/java/com/canopas/editor/ui/ui/RichEditor.kt +++ b/editor/src/main/java/com/canopas/editor/ui/ui/RichEditor.kt @@ -14,11 +14,11 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.TextRange import androidx.compose.ui.viewinterop.AndroidView import androidx.core.widget.doAfterTextChanged -import com.canopas.editor.ui.data.RichEditorState +import com.canopas.editor.ui.data.QuillEditorState @Composable fun RichEditor( - state: RichEditorState, + state: QuillEditorState, modifier: Modifier = Modifier, ) { diff --git a/editor/src/main/java/com/canopas/editor/ui/utils/ElementsSpanStyle.kt b/editor/src/main/java/com/canopas/editor/ui/utils/ElementsSpanStyle.kt index 1f50762..de89ea8 100644 --- a/editor/src/main/java/com/canopas/editor/ui/utils/ElementsSpanStyle.kt +++ b/editor/src/main/java/com/canopas/editor/ui/utils/ElementsSpanStyle.kt @@ -1,6 +1,9 @@ package com.canopas.editor.ui.utils +import android.graphics.Color import android.graphics.Typeface +import android.os.Build +import android.text.style.BulletSpan import android.text.style.RelativeSizeSpan import android.text.style.StyleSpan import android.text.style.UnderlineSpan @@ -63,6 +66,23 @@ sealed interface TextSpanStyle { } } + object BulletStyle : TextSpanStyle { + override val key: String + get() = "bullet" + override val style: Any + get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + BulletSpan(16, Color.BLACK, 8) + } else { + BulletSpan(16) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Default) return false + return key == other.key + } + } + object H1Style : TextSpanStyle { override val key: String get() = "h1" @@ -79,7 +99,7 @@ sealed interface TextSpanStyle { object H2Style : TextSpanStyle { override val key: String - get() = "h3" + get() = "h2" override val style: Any get() = RelativeSizeSpan(1.4f) @@ -141,4 +161,15 @@ sealed interface TextSpanStyle { return key == other.key } } + + object HeaderMap { + internal val headerMap = mapOf( + "1" to H1Style, + "2" to H2Style, + "3" to H3Style, + "4" to H4Style, + "5" to H5Style, + "6" to H6Style + ) + } } \ No newline at end of file diff --git a/editor/src/test/assets/android-quill-sample.json b/editor/src/test/assets/android-quill-sample.json new file mode 100644 index 0000000..b9c8b43 --- /dev/null +++ b/editor/src/test/assets/android-quill-sample.json @@ -0,0 +1,38 @@ +{ + "spans": [ + { + "insert": "RichEditor", + "attributes": { + "bold": true, + "header": 1 + } + }, + { + "insert": "\nf", + "attributes": { + "bold": true + } + }, + { + "insert": "o", + "attributes": {} + }, + { + "insert": "r ", + "attributes": { + "bold": true + } + }, + { + "insert": "\nAndroid ", + "attributes": {} + }, + { + "insert": "WYSIWYG ", + "attributes": { + "bold": true, + "italic": true + } + } + ] +} \ No newline at end of file diff --git a/editor/src/test/java/com/canopas/editor/MainCoroutineRule.kt b/editor/src/test/java/com/canopas/editor/MainCoroutineRule.kt new file mode 100644 index 0000000..c4de81e --- /dev/null +++ b/editor/src/test/java/com/canopas/editor/MainCoroutineRule.kt @@ -0,0 +1,26 @@ +package com.canopas.editor + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +class MainCoroutineRule @OptIn(ExperimentalCoroutinesApi::class) constructor( + private val dispatcher: TestDispatcher = UnconfinedTestDispatcher() +) : TestWatcher() { + @OptIn(ExperimentalCoroutinesApi::class) + override fun starting(description: Description) { + super.starting(description) + Dispatchers.setMain(dispatcher) + } + + @OptIn(ExperimentalCoroutinesApi::class) + override fun finished(description: Description) { + super.finished(description) + Dispatchers.resetMain() + } +} diff --git a/editor/src/test/java/com/canopas/editor/jsonparser/RichTextStateAdapter.kt b/editor/src/test/java/com/canopas/editor/jsonparser/RichTextStateAdapter.kt new file mode 100644 index 0000000..0165e44 --- /dev/null +++ b/editor/src/test/java/com/canopas/editor/jsonparser/RichTextStateAdapter.kt @@ -0,0 +1,43 @@ +package com.canopas.editor.jsonparser + +import com.canopas.editor.ui.model.Attributes +import com.canopas.editor.ui.model.Span +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonParseException +import com.google.gson.JsonSerializationContext +import com.google.gson.JsonSerializer +import java.lang.reflect.Type + +class RichTextStateAdapter : JsonSerializer, JsonDeserializer { + override fun serialize( + src: Span?, + typeOfSrc: Type?, + context: JsonSerializationContext? + ): JsonElement { + val jsonObject = JsonObject() + jsonObject.add("insert", context?.serialize(src?.insert)) + jsonObject.add("attributes", context?.serialize(src?.attributes)) + return jsonObject + } + + override fun deserialize( + json: JsonElement?, + typeOfT: Type?, + context: JsonDeserializationContext? + ): Span { + try { + val jsonObject = json?.asJsonObject ?: throw JsonParseException("Invalid JSON") + val insert = jsonObject.get("insert") + val attributes = jsonObject.get("attributes") + return Span( + insert = context?.deserialize(insert, String::class.java), + attributes = context?.deserialize(attributes, Attributes::class.java) + ) + } catch (e: Exception) { + throw JsonParseException("Invalid JSON") + } + } +} diff --git a/editor/src/test/java/com/canopas/editor/jsonparser/TestEditorParser.kt b/editor/src/test/java/com/canopas/editor/jsonparser/TestEditorParser.kt new file mode 100644 index 0000000..f4d147d --- /dev/null +++ b/editor/src/test/java/com/canopas/editor/jsonparser/TestEditorParser.kt @@ -0,0 +1,23 @@ +package com.canopas.editor.jsonparser + +import com.canopas.editor.ui.model.QuillSpan +import com.canopas.editor.ui.model.Span +import com.canopas.editor.ui.parser.QuillEditorAdapter +import com.google.common.reflect.TypeToken +import com.google.gson.Gson +import com.google.gson.GsonBuilder + +class TestEditorParser : QuillEditorAdapter { + + private val gson: Gson = GsonBuilder() + .registerTypeAdapter(Span::class.java, RichTextStateAdapter()) + .create() + + override fun encode(input: String): QuillSpan { + return gson.fromJson(input, object : TypeToken() {}.type) + } + + override fun decode(editorValue: QuillSpan): String { + return gson.toJson(editorValue) + } +} diff --git a/editor/src/test/java/com/canopas/editor/quilltextmanager/QuillTextManagerTest.kt b/editor/src/test/java/com/canopas/editor/quilltextmanager/QuillTextManagerTest.kt new file mode 100644 index 0000000..abf4fbb --- /dev/null +++ b/editor/src/test/java/com/canopas/editor/quilltextmanager/QuillTextManagerTest.kt @@ -0,0 +1,296 @@ +package com.canopas.editor.quilltextmanager + +import android.content.Context +import android.text.Editable +import androidx.compose.ui.text.TextRange +import androidx.test.core.app.ApplicationProvider +import com.canopas.editor.MainCoroutineRule +import com.canopas.editor.jsonparser.TestEditorParser +import com.canopas.editor.ui.data.QuillEditorState +import com.canopas.editor.ui.model.QuillTextSpan +import com.canopas.editor.ui.utils.TextSpanStyle +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.junit.Assert +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +@ExperimentalCoroutinesApi +class QuillTextManagerTest { + + @get:Rule + var mainCoroutineRule = MainCoroutineRule() + private lateinit var quillEditorState: QuillEditorState + private val context = ApplicationProvider.getApplicationContext() + private lateinit var editableInstance: Editable.Factory + private var sampleSpansListSize = 0 + + @Before + fun setup() { + val input = + context.assets.open("android-quill-sample.json").bufferedReader().use { it.readText() } + editableInstance = Editable.Factory.getInstance() + quillEditorState = QuillEditorState.Builder() + .setInput(input) + .adapter(TestEditorParser()) + .build() + sampleSpansListSize = quillEditorState.manager.quillTextSpans.size + } + + @Test + fun `test getQuillSpan`() { + val quillSpan = quillEditorState.getQuillSpan() + Assert.assertNotNull(quillSpan) + } + + @Test + fun `test output`() { + val output = quillEditorState.output() + Assert.assertNotNull(output) + } + + @Test + fun `test reset`() { + quillEditorState.reset() + Assert.assertTrue(quillEditorState.manager.quillTextSpans.isEmpty()) + } + + @Test + fun `test hasStyle`() { + val hasStyle = quillEditorState.hasStyle(TextSpanStyle.BoldStyle) + Assert.assertFalse(hasStyle) + } + + @Test + fun `test toggleStyle`() { + quillEditorState.toggleStyle(TextSpanStyle.BoldStyle) + val hasStyle = quillEditorState.hasStyle(TextSpanStyle.BoldStyle) + Assert.assertTrue(hasStyle) + } + + @Test + fun `test updateStyle`() { + quillEditorState.updateStyle(TextSpanStyle.BoldStyle) + val hasStyle = quillEditorState.hasStyle(TextSpanStyle.BoldStyle) + Assert.assertTrue(hasStyle) + } + + @Test + fun `scenario 1 - test span added successfully when text is selected and style is added`() { + quillEditorState.manager.adjustSelection(TextRange(0, 5)) + val spansSize = quillEditorState.manager.quillTextSpans.size + quillEditorState.toggleStyle(TextSpanStyle.ItalicStyle) + Assert.assertEquals(1, quillEditorState.manager.quillTextSpans.size - spansSize) + } + + @Test + fun `scenario 2 - test span removed successfully when text is selected and style is removed`() { + quillEditorState.manager.adjustSelection(TextRange(0, 5)) + val spansSize = quillEditorState.manager.quillTextSpans.size + quillEditorState.toggleStyle(TextSpanStyle.ItalicStyle) + // Verify span added + Assert.assertEquals(1, quillEditorState.manager.quillTextSpans.size - spansSize) + val newSpanSize = quillEditorState.manager.quillTextSpans.size + quillEditorState.toggleStyle(TextSpanStyle.ItalicStyle) + // Verify span removed + Assert.assertFalse(newSpanSize == spansSize) + Assert.assertEquals(0, quillEditorState.manager.quillTextSpans.size - newSpanSize) + } + + @Test + fun `scenario 3 - test span created successfully when user selects style and starts typing`() { + quillEditorState.manager.adjustSelection(TextRange(2, 2)) + val spansSize = quillEditorState.manager.quillTextSpans.size + quillEditorState.toggleStyle(TextSpanStyle.ItalicStyle) + quillEditorState.manager.handleAddingCharacters(editableInstance.newEditable("t")) + Assert.assertEquals(1, quillEditorState.manager.quillTextSpans.size - spansSize) + } + + @Test + fun `scenario 4 - test other span created successfully when user deselects style and starts typing`() { + quillEditorState.manager.adjustSelection(TextRange(2, 2)) + val spansSize = quillEditorState.manager.quillTextSpans.size + quillEditorState.toggleStyle(TextSpanStyle.ItalicStyle) + quillEditorState.manager.handleAddingCharacters(editableInstance.newEditable("t")) + // Verify span added + Assert.assertEquals(1, quillEditorState.manager.quillTextSpans.size - spansSize) + val newSpanSize = quillEditorState.manager.quillTextSpans.size + quillEditorState.toggleStyle(TextSpanStyle.ItalicStyle) + quillEditorState.manager.handleAddingCharacters(editableInstance.newEditable("t")) + // Verify other span added + Assert.assertEquals(1, quillEditorState.manager.quillTextSpans.size - newSpanSize) + // Verify 2 new spans added compared to android-quill-sample.json as original has 3 spans + Assert.assertEquals(sampleSpansListSize + 2, quillEditorState.manager.quillTextSpans.size) + } + + @Test + fun `scenario 5 - extend current span if user starts typing in middle of the word which have style on it`() { + val originalInsert = quillEditorState.manager.editableText + val newInsert = originalInsert.replace("RichEditor", "RichTEditor") + quillEditorState.manager.adjustSelection(TextRange(4, 4)) + quillEditorState.manager.setEditable(editableInstance.newEditable(newInsert)) + val to = quillEditorState.manager.quillTextSpans[0].to + val expectedSpan = + QuillTextSpan(0, 9, listOf(TextSpanStyle.H1Style, TextSpanStyle.BoldStyle)) + Assert.assertEquals(10, to + 1) + Assert.assertEquals( + "RichTEditor", + quillEditorState.manager.editableText.substringBefore("\n") + ) + Assert.assertEquals(expectedSpan, quillEditorState.manager.quillTextSpans[0]) + } + + @Test + fun `scenario 6 - extend current span if user starts typing just after styles text`() { + val originalInsert = quillEditorState.manager.editableText + val newInsert = originalInsert.replace("RichEditor", "RichEditorT") + quillEditorState.manager.adjustSelection(TextRange(9, 9)) + quillEditorState.manager.setEditable(editableInstance.newEditable(newInsert)) + val to = quillEditorState.manager.quillTextSpans[0].to + Assert.assertEquals(10, to + 1) + Assert.assertEquals( + "RichEditorT", + quillEditorState.manager.editableText.substringBefore("\n") + ) + } + + @Test + fun `scenario 7 - test span is moved by typed character if user starts typing just before styles text`() { + val originalInsert = quillEditorState.manager.editableText + val newInsert = originalInsert.replace("RichEditor", "TRichEditor") + quillEditorState.manager.adjustSelection(TextRange(0, 0)) + quillEditorState.manager.setEditable(editableInstance.newEditable(newInsert)) + val to = quillEditorState.manager.quillTextSpans[0].to + Assert.assertEquals(10, to + 1) + Assert.assertEquals( + "TRichEditor", + quillEditorState.manager.editableText.substringBefore("\n") + ) + } + + @Test + fun `scenario 8 - add span with selected style if user starts typing at initial position`() { + quillEditorState.toggleStyle(TextSpanStyle.ItalicStyle) + quillEditorState.manager.onTextFieldValueChange( + editableInstance.newEditable("t"), + TextRange(0, 0) + ) + Assert.assertTrue(quillEditorState.manager.quillTextSpans[0].style.contains(TextSpanStyle.ItalicStyle)) + } + + @Test + fun `scenario 9 - break spans into 2 when user removes style from middle of word by selection text`() { + val currentSpansSize = quillEditorState.manager.quillTextSpans.size + quillEditorState.manager.adjustSelection(TextRange(4, 6)) + quillEditorState.toggleStyle(TextSpanStyle.BoldStyle) + Assert.assertEquals(currentSpansSize + 2, quillEditorState.manager.quillTextSpans.size) + } + + @Test + fun `scenario 10 - break spans into 2 when user deselects style and starts typing in middle of any word which have style`() { + val currentSpansSize = quillEditorState.manager.quillTextSpans.size + quillEditorState.manager.adjustSelection(TextRange(4, 4)) + quillEditorState.toggleStyle(TextSpanStyle.BoldStyle) + val originalInsert = quillEditorState.manager.editableText + val newInsert = originalInsert.replace("RichEditor", "RichTEditor") + quillEditorState.manager.handleAddingCharacters(editableInstance.newEditable(newInsert)) + Assert.assertEquals(currentSpansSize + 2, quillEditorState.manager.quillTextSpans.size) + } + + @Test + fun `scenario 11 - update span length when any character is removed from it`() { + val previousToIndex = quillEditorState.manager.quillTextSpans[0].to + quillEditorState.manager.adjustSelection(TextRange(4, 4)) + val oldInsert = quillEditorState.manager.editableText + val newInsert = oldInsert.replace("RichEditor", "RichEdtor") + quillEditorState.manager.onTextFieldValueChange( + editableInstance.newEditable(newInsert), + TextRange(4, 4) + ) + val newToIndex = quillEditorState.manager.quillTextSpans[0].to + Assert.assertEquals(previousToIndex - 1, newToIndex) + } + + @Test + fun `scenario 12 - Move span by n position forward when user adds n character before styled text anywhere before that text`() { + val previousToIndex = quillEditorState.manager.quillTextSpans[0].to + quillEditorState.manager.adjustSelection(TextRange(0, 0)) + val oldInsert = quillEditorState.manager.editableText + val newInsert = oldInsert.replace("RichEditor", "TextRichEditor") + quillEditorState.manager.onTextFieldValueChange( + editableInstance.newEditable(newInsert), + TextRange(0, 0) + ) + val newToIndex = quillEditorState.manager.quillTextSpans[0].to + Assert.assertEquals(previousToIndex + 4, newToIndex) + } + + @Test + fun `scenario 13 - Move span by n position backward when user removes n character before styled text anywhere before that text`() { + val previousToIndex = quillEditorState.manager.quillTextSpans[1].to + quillEditorState.manager.adjustSelection(TextRange(0, 0)) + val oldInsert = quillEditorState.manager.editableText + val newInsert = oldInsert.replace("RichEditor", "Editor") + quillEditorState.manager.onTextFieldValueChange( + editableInstance.newEditable(newInsert), + TextRange(0, 0) + ) + val newToIndex = quillEditorState.manager.quillTextSpans[1].to + // Verify if 2nd span from list is moved by 4 positions + Assert.assertEquals(previousToIndex - 4, newToIndex) + } + + @Test + fun `scenario 14 - remove header style when user add new line`() { + quillEditorState.manager.adjustSelection(TextRange(0, 0)) + quillEditorState.toggleStyle(TextSpanStyle.H1Style) + quillEditorState.manager.onTextFieldValueChange( + editableInstance.newEditable("\n"), + TextRange(0, 0) + ) + // Verify if header style is removed + Assert.assertFalse(quillEditorState.manager.quillTextSpans[0].style.contains(TextSpanStyle.H1Style)) + // Verify that other styles are not removed + Assert.assertTrue(quillEditorState.manager.quillTextSpans[0].style.contains(TextSpanStyle.BoldStyle)) + } + + @Test + fun `scenario 15 - merge spans if style applied to selected text is equivalent to previous and next span`() { + println("Spans Text:${quillEditorState.manager.editableText.substring(12, 13)}") + val previousSpanSize = quillEditorState.getRichText().spans.size + quillEditorState.manager.adjustSelection(TextRange(12, 13)) + quillEditorState.updateStyle(TextSpanStyle.BoldStyle) + val newSize = quillEditorState.getRichText().spans.size + // Verify that 3 spans are merged into 1 + Assert.assertEquals(previousSpanSize - 2, newSize) + } + + @Test + fun `scenario 16 - if new line is entered in between text then remove header if available and split span into two`() { + quillEditorState.manager.adjustSelection(TextRange(4, 4)) + val previousSpanSize = quillEditorState.manager.quillTextSpans.size + val oldInsert = quillEditorState.manager.editableText + val newInsert = oldInsert.replace("RichEditor", "Rich\nEditor") + quillEditorState.manager.onTextFieldValueChange( + editableInstance.newEditable(newInsert), + TextRange(0, 0) + ) + Assert.assertEquals(previousSpanSize + 1, quillEditorState.manager.quillTextSpans.size) + Assert.assertTrue(quillEditorState.manager.quillTextSpans[0].style.contains(TextSpanStyle.BoldStyle)) + Assert.assertTrue(quillEditorState.manager.quillTextSpans[0].style.contains(TextSpanStyle.H1Style)) + Assert.assertFalse(quillEditorState.manager.quillTextSpans[1].style.contains(TextSpanStyle.H1Style)) + Assert.assertTrue(quillEditorState.manager.quillTextSpans[1].style.contains(TextSpanStyle.BoldStyle)) + } + + @Test + fun `scenario 17 - test remove all styles if selection range is 0,0`() { + quillEditorState.manager.onTextFieldValueChange( + editableInstance.newEditable("t"), + TextRange(0, 0) + ) + Assert.assertEquals(0, quillEditorState.manager.currentStyles.size) + } +} \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 499de54..aee776f 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Tue Jun 13 14:30:23 IST 2023 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists