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..7ba23c1 --- /dev/null +++ b/app/src/main/assets/android-quill-sample.json @@ -0,0 +1,3 @@ +{ + "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","attributes":{"list":"bullet"}},{"insert":"\n","attributes":{}},{"insert":"Item 2","attributes":{"list":"bullet"}},{"insert":"\n","attributes":{}},{"insert":"Item 3","attributes":{"list":"bullet"}},{"insert":"\n","attributes":{}},{"insert":"Item 4\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/src/main/java/com/canopas/editor/ui/data/RichEditorState.kt b/editor/src/main/java/com/canopas/editor/ui/data/QuillEditorState.kt similarity index 51% rename from editor/src/main/java/com/canopas/editor/ui/data/RichEditorState.kt rename to editor/src/main/java/com/canopas/editor/ui/data/QuillEditorState.kt index b11dea4..1beafe5 100644 --- a/editor/src/main/java/com/canopas/editor/ui/data/RichEditorState.kt +++ b/editor/src/main/java/com/canopas/editor/ui/data/QuillEditorState.kt @@ -1,27 +1,27 @@ 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.model.QuillSpan +import com.canopas.editor.ui.parser.QuillDefaultAdapter +import com.canopas.editor.ui.parser.QuillEditorAdapter import com.canopas.editor.ui.utils.TextSpanStyle -class RichEditorState internal constructor( +class QuillEditorState internal constructor( private val input: String, - private val adapter: EditorAdapter = DefaultAdapter(), + private val adapter: QuillEditorAdapter = QuillDefaultAdapter(), ) { - internal var manager: RichTextManager + internal var manager: QuillTextManager init { - manager = RichTextManager(getRichText()) + manager = QuillTextManager(getQuillSpan()) } - private fun getRichText(): RichText { - return if (input.isNotEmpty()) adapter.encode(input) else RichText() + private fun getQuillSpan(): QuillSpan { + return if (input.isNotEmpty()) adapter.encode(input) else QuillSpan(emptyList()) } fun output(): String { - return adapter.decode(manager.richText) + return adapter.decode(manager.richText) } fun reset() { @@ -39,21 +39,19 @@ class RichEditorState internal constructor( } class Builder { - private var adapter: EditorAdapter = DefaultAdapter() + private var adapter: QuillEditorAdapter = QuillDefaultAdapter() private var input: String = "" fun setInput(input: String) = apply { this.input = input } - fun adapter(adapter: EditorAdapter) = apply { + fun adapter(adapter: QuillEditorAdapter) = apply { this.adapter = adapter } - fun build(): RichEditorState { - return RichEditorState(input, 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..b013faa --- /dev/null +++ b/editor/src/main/java/com/canopas/editor/ui/data/QuillTextManager.kt @@ -0,0 +1,771 @@ +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.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.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 ?: "" }) + private 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 + + attributes?.let { + val textSpanStyles = mutableListOf() + + if (it.header != null) { + when (it.header) { + 1 -> TextSpanStyle.H1Style + 2 -> TextSpanStyle.H2Style + 3 -> TextSpanStyle.H3Style + 4 -> TextSpanStyle.H4Style + 5 -> TextSpanStyle.H5Style + 6 -> TextSpanStyle.H6Style + else -> null + }?.let { headerStyle -> textSpanStyles.add(headerStyle) } + } + + if (it.bold == true) { + textSpanStyles.add(TextSpanStyle.BoldStyle) + } + + if (it.italic == true) { + textSpanStyles.add(TextSpanStyle.ItalicStyle) + } + + if (it.underline == true) { + textSpanStyles.add(TextSpanStyle.UnderlineStyle) + } + + if (it.list == ListType.bullet) { + textSpanStyles.add(TextSpanStyle.BulletStyle) + } + + quillTextSpans.add( + QuillTextSpan( + from = fromIndex, + to = endIndex, + style = textSpanStyles + ) + ) + } ?: run { + quillTextSpans.add( + QuillTextSpan( + from = fromIndex, + to = endIndex, + style = listOf(TextSpanStyle.Default) + ) + ) + } + } + } + + private val editableText: String get() = editable.toString() + + private var selection = TextRange(0, 0) + private val currentStyles = mutableStateListOf() + private var rawText: String = editableText + + internal val richText: QuillSpan + get() { + + val quillGroupedSpans = 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 = editableText.substring(span.from, span.to + 1) + if (insert == " " || insert == "") { + return@forEachIndexed + } + val nextSpan = quillTextSpans.getOrNull(index + 1) + val nextInsert = + nextSpan?.let { 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() + } + + // Merge consecutive spans with the same attributes into one + if (groupedSpans.isNotEmpty() && groupedSpans.last().attributes == attributes && attributes.list == null) { + groupedSpans.last().insert += insert + } else { + groupedSpans.add(Span(insert, attributes)) + } + } + + return QuillSpan(groupedSpans) + } + + 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() + + quillTextSpans.forEach { + it.style.forEach { style -> + editable.setSpan( + 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() + } + + val currentSpan = + quillTextSpans.findLast { + it.from <= selection.min - 2 && it.to >= selection.min - 2 && it.style.contains( + TextSpanStyle.BulletStyle + ) + } + + if (currentSpan != null) { + if ( + currentSpan.style.contains(TextSpanStyle.BulletStyle) && + editable[selection.min - 1] == '\n' && + editable[selection.min - 2] != '\n' + ) { + addStyle(TextSpanStyle.BulletStyle) + } else if ( + currentSpan.style.contains(TextSpanStyle.BulletStyle) && + 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)) { + currentStyles.remove(style) + } + + if (!selection.collapsed) { + val fromIndex = selection.min + val toIndex = selection.max - 1 + + val selectedSpan = quillTextSpans.find { + it.from <= fromIndex && it.to >= toIndex + } + if (selectedSpan != null) { + if (fromIndex == selectedSpan.from && toIndex == selectedSpan.to) { + val index = quillTextSpans.indexOf(selectedSpan) + quillTextSpans[index] = + selectedSpan.copy(style = selectedSpan.style.filterNot { it == style }) + } else { + if (fromIndex == selectedSpan.from && toIndex < selectedSpan.to) { + val index = quillTextSpans.indexOf(selectedSpan) + quillTextSpans.removeAt(index) + quillTextSpans.add( + index, + QuillTextSpan( + from = fromIndex, + to = toIndex, + style = selectedSpan.style.filterNot { it == style } + ) + ) + quillTextSpans.add( + index + 1, + QuillTextSpan( + from = toIndex + 1, + to = selectedSpan.to, + style = selectedSpan.style + ) + ) + } else if (fromIndex > selectedSpan.from && toIndex < selectedSpan.to) { + val index = quillTextSpans.indexOf(selectedSpan) + quillTextSpans.removeAt(index) + quillTextSpans.add( + index, + QuillTextSpan( + from = selectedSpan.from, + to = fromIndex - 1, + style = selectedSpan.style + ) + ) + quillTextSpans.add( + index + 1, + QuillTextSpan( + from = fromIndex, + to = toIndex, + style = selectedSpan.style.filterNot { it == style } + ) + ) + quillTextSpans.add( + index + 2, + QuillTextSpan( + from = toIndex + 1, + to = selectedSpan.to, + style = selectedSpan.style + ) + ) + } else if (fromIndex > 0) { + val index = quillTextSpans.indexOf(selectedSpan) + quillTextSpans.removeAt(index) + quillTextSpans.add( + index, + QuillTextSpan( + from = selectedSpan.from, + to = fromIndex - 1, + style = selectedSpan.style + ) + ) + quillTextSpans.add( + index + 1, + QuillTextSpan( + from = fromIndex, + to = toIndex, + style = selectedSpan.style.filterNot { it == style } + ) + ) + quillTextSpans.add( + index + 2, + QuillTextSpan( + from = toIndex + 1, + to = selectedSpan.to, + style = selectedSpan.style + ) + ) + } + } + } + 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 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) + + val parts = quillTextSpans.filter { part -> + part.from < nextNewlineIndex && part.to >= startIndex + } + if (parts.isEmpty() && fromIndex - 1 == nextNewlineIndex) return + + val selectedParts = quillTextSpans.filter { part -> + part.from < endIndex && part.to >= startIndex + } + + quillTextSpans.removeAll(selectedParts.filter { + 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 + } + if (selectedSpan != null) { + if (fromIndex == selectedSpan.from && toIndex < selectedSpan.to) { + val index = quillTextSpans.indexOf(selectedSpan) + quillTextSpans.removeAt(index) + quillTextSpans.add( + index, + QuillTextSpan( + from = fromIndex, + to = toIndex - 1, + style = selectedSpan.style + listOf(style) + ) + ) + quillTextSpans.add( + index + 1, + QuillTextSpan( + from = toIndex, + to = selectedSpan.to, + style = selectedSpan.style + ) + ) + } else if (fromIndex > selectedSpan.from && toIndex < selectedSpan.to) { + val index = quillTextSpans.indexOf(selectedSpan) + quillTextSpans.removeAt(index) + quillTextSpans.add( + index, + QuillTextSpan( + from = selectedSpan.from, + to = fromIndex - 1, + style = selectedSpan.style + ) + ) + quillTextSpans.add( + index + 1, + QuillTextSpan( + from = fromIndex, + to = toIndex - 1, + style = selectedSpan.style + listOf(style) + ) + ) + quillTextSpans.add( + index + 2, + QuillTextSpan( + from = toIndex, + to = selectedSpan.to, + style = selectedSpan.style + ) + ) + } else if (fromIndex > selectedSpan.from && + (toIndex == selectedSpan.to || toIndex == (selectedSpan.to + 1)) + ) { + val index = quillTextSpans.indexOf(selectedSpan) + quillTextSpans.removeAt(index) + quillTextSpans.add( + index, + QuillTextSpan( + from = selectedSpan.from, + to = fromIndex - 1, + style = selectedSpan.style + ) + ) + quillTextSpans.add( + index + 1, + QuillTextSpan( + from = fromIndex, + to = toIndex - 1, + style = selectedSpan.style + listOf(style) + ) + ) + } else { + val index = quillTextSpans.indexOf(selectedSpan) + 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() + + } + + private fun handleAddingCharacters(newValue: Editable) { + val typedCharsCount = newValue.length - rawText.length + val startTypeIndex = selection.min - typedCharsCount + + if (newValue.getOrNull(startTypeIndex) == '\n' && currentStyles.any { it.isHeaderStyle() || it == TextSpanStyle.BulletStyle }) { + currentStyles.clear() + } + + val selectedStyles = currentStyles.distinct() + + moveSpans(startTypeIndex, typedCharsCount) + + val currentSpan = quillTextSpans.find { + it.from <= startTypeIndex && it.to >= startTypeIndex + } + + currentSpan?.let { span -> + val index = quillTextSpans.indexOf(span) + val styles = (span.style + selectedStyles).distinct() + + val from = span.from + val to = span.to + // TODO: Add support for bullet style + val isBulletStyle = TextSpanStyle.BulletStyle in styles + + when { + span.style == selectedStyles -> { + val updatedSpan = span.copy(to = to + typedCharsCount, style = styles) + quillTextSpans[index] = updatedSpan + } + + span.style != selectedStyles -> { + quillTextSpans.removeAt(index) + if (startTypeIndex == from) { + quillTextSpans.add( + index, + span.copy( + from = startTypeIndex, + to = startTypeIndex + typedCharsCount - 1, + style = selectedStyles + ) + ) + quillTextSpans.add( + index + 1, + span.copy( + from = startTypeIndex + typedCharsCount, + to = to + typedCharsCount, + style = span.style + ) + ) + } else { + quillTextSpans.add( + index, + span.copy(to = startTypeIndex - 1, style = span.style) + ) + quillTextSpans.add( + index + 1, + span.copy( + from = startTypeIndex, + to = startTypeIndex + typedCharsCount, + style = selectedStyles + ) + ) + quillTextSpans.add( + index + 2, + span.copy( + from = startTypeIndex + typedCharsCount + 1, + to = to + typedCharsCount, + style = span.style + ) + ) + } + } + + 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 -> { + quillTextSpans.removeAt(index) + quillTextSpans.add(index, span.copy(to = startTypeIndex - 1, style = styles)) + 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 = styles + ) + ) + } + } + } + if (currentSpan == null) { + val lastSpan = quillTextSpans.lastOrNull() + if (lastSpan != null) { + val lastStyles = lastSpan.style + if (lastStyles == selectedStyles) { + quillTextSpans[quillTextSpans.lastIndex] = + lastSpan.copy(to = lastSpan.to + typedCharsCount) + } else { + quillTextSpans.add( + QuillTextSpan( + from = startTypeIndex, + to = startTypeIndex + typedCharsCount - 1, + style = selectedStyles + ) + ) + } + } 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) + + 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) + } + } + + 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 { + 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) + } + + private 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) + } + } + + } +} \ No newline at end of file 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..f262599 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" 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