From e77bcb8e06dddf5e09bbda783fb7a229238a731f Mon Sep 17 00:00:00 2001 From: cp-megh Date: Mon, 5 Feb 2024 16:30:01 +0530 Subject: [PATCH] WIP --- app/src/main/assets/android-quill-sample.json | 83 ++++++----- .../com/example/texteditor/MainActivity.kt | 7 +- .../baseline_format_list_bulleted_24.xml | 5 + .../editor/ui/data/QuillTextManager.kt | 137 ++++++++++++++---- .../com/canopas/editor/ui/model/QuillSpan.kt | 9 +- .../editor/ui/utils/ElementsSpanStyle.kt | 20 +++ 6 files changed, 191 insertions(+), 70 deletions(-) create mode 100644 app/src/main/res/drawable/baseline_format_list_bulleted_24.xml diff --git a/app/src/main/assets/android-quill-sample.json b/app/src/main/assets/android-quill-sample.json index a25dfbe..2cd0daa 100644 --- a/app/src/main/assets/android-quill-sample.json +++ b/app/src/main/assets/android-quill-sample.json @@ -1,96 +1,101 @@ { "spans": [ { + "insert": "Android Quill", "attributes": { - "header": 1, - "bold": true - }, - "insert": "Android Quill" + "bold": true, + "header": 1 + } }, { "insert": "\n" }, { + "insert": "\nRich", "attributes": { - "header": 2, - "bold": true - }, - "insert": "\nRich text editor for Android" + "bold": true, + "header": 2 + } + }, + { + "insert": " text ", + "attributes": { + "header": 2 + } + }, + { + "insert": "editor for Android", + "attributes": { + "bold": true, + "header": 2 + } }, { "insert": "\n" }, { + "insert": "Quill component for Android\n", "attributes": { "header": 3, "italic": true - }, - "insert": "Quill component for Android" + } }, { + "insert": "Bullet Journal", "attributes": { - "color": "rgba(0, 0, 0, 0.847)" - }, - "insert": " and " + "bold": true + } }, { + "insert": ":\nTrack personal and group journals (ToDo, Note, Ledger) from multiple views with timely reminders", "attributes": { "bold": true - }, - "insert": "Bullet Journal" - }, - { - "insert": ":\nTrack personal and group journals (ToDo, Note, Ledger) from multiple views with timely reminders" + } }, { - "attributes": { - "bold": true - }, "insert": "\n" }, { - "insert": "Share your tasks and notes with teammates, and see changes as they happen in real-time, across all devices" + "insert": "Share your tasks and notes with teammates, and see changes as they happen in real-time, across all devices", + "attributes": {} }, { - "attributes": { - "list": "ordered" - }, "insert": "\n" }, { - "insert": "Check out what you and your teammates are working on each day" - }, - { + "insert": "Splitting bills with friends can never be easier.", "attributes": { - "list": "ordered" - }, - "insert": "\n" + "list": "bullet" + } }, { - "insert": "\nSplitting bills with friends can never be easier." + "insert": "\n" }, { + "insert": "Testing span addition to the editor.", "attributes": { "list": "bullet" - }, - "insert": "\n" + } }, { - "insert": "Start creating a group and invite your friends to join." + "insert": "\n" }, { + "insert": "Start creating a group and invite your friends to join.", "attributes": { "list": "bullet" - }, - "insert": "\n" + } }, { - "insert": "Create a BuJo of Ledger type to see expense or balance summary." + "insert": "\n" }, { + "insert": "Create a BuJo of Ledger type to see expense or balance summary.", "attributes": { "list": "bullet" - }, + } + }, + { "insert": "\n" } ] diff --git a/app/src/main/java/com/example/texteditor/MainActivity.kt b/app/src/main/java/com/example/texteditor/MainActivity.kt index a951d01..4c14a2a 100644 --- a/app/src/main/java/com/example/texteditor/MainActivity.kt +++ b/app/src/main/java/com/example/texteditor/MainActivity.kt @@ -42,7 +42,6 @@ import androidx.compose.ui.window.PopupProperties 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 @@ -124,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) 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/editor/src/main/java/com/canopas/editor/ui/data/QuillTextManager.kt b/editor/src/main/java/com/canopas/editor/ui/data/QuillTextManager.kt index d768846..e49931a 100644 --- a/editor/src/main/java/com/canopas/editor/ui/data/QuillTextManager.kt +++ b/editor/src/main/java/com/canopas/editor/ui/data/QuillTextManager.kt @@ -2,13 +2,14 @@ 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 android.util.Log 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.RichTextSpan import com.canopas.editor.ui.model.Span @@ -56,6 +57,10 @@ class QuillTextManager(quillSpan: QuillSpan) { style.add(TextSpanStyle.UnderlineStyle) } + if (it.list == ListType.bullet) { + style.add(TextSpanStyle.BulletStyle) + } + style.forEach { spans.add( RichTextSpan( @@ -65,11 +70,18 @@ class QuillTextManager(quillSpan: QuillSpan) { ) ) } + } ?: run { + spans.add( + RichTextSpan( + from = fromIndex, + to = endIndex, + style = TextSpanStyle.Default + ) + ) } } } - private val editableText: String get() = editable.toString() private var selection = TextRange(0, 0) @@ -77,25 +89,70 @@ class QuillTextManager(quillSpan: QuillSpan) { private var rawText: String = editableText internal val richText: QuillSpan - get() = QuillSpan(spans = spans.map { span -> - val attributes = when (span.style) { - TextSpanStyle.BoldStyle -> Attributes(bold = true) - TextSpanStyle.ItalicStyle -> Attributes(italic = true) - TextSpanStyle.UnderlineStyle -> Attributes(underline = true) - TextSpanStyle.H1Style -> Attributes(header = 1) - TextSpanStyle.H2Style -> Attributes(header = 2) - TextSpanStyle.H3Style -> Attributes(header = 3) - TextSpanStyle.H4Style -> Attributes(header = 4) - TextSpanStyle.H5Style -> Attributes(header = 5) - TextSpanStyle.H6Style -> Attributes(header = 6) - else -> null + get() { + val groupedSpans = mutableListOf() + var currentInsert = "" + var currentAttributes: Attributes? = null + + spans.forEach { span -> + val insert = editableText.substring(span.from, span.to + 1) + val attributes = Attributes( + header = if (span.style.isHeaderStyle()) span.style.headerLevel() else null, + bold = if (span.style == TextSpanStyle.BoldStyle) true else null, + italic = if (span.style == TextSpanStyle.ItalicStyle) true else null, + underline = if (span.style == TextSpanStyle.UnderlineStyle) true else null, + list = if (span.style == TextSpanStyle.BulletStyle) ListType.bullet else null + ) + + if (insert == currentInsert && attributes == currentAttributes) { + // Same insert and attributes, continue to the next span + return@forEach + } + + if (insert == currentInsert) { + // Same insert but different attributes, update the currentAttributes + currentAttributes = mergeAttributes(currentAttributes, attributes) + } else { + // Different insert, add the currentInsert with currentAttributes to the list + if (currentInsert.isNotEmpty()) { + groupedSpans.add(Span(currentInsert, currentAttributes)) + } + + // Update currentInsert and currentAttributes for the new insert + currentInsert = insert + currentAttributes = attributes + } } - Span( - insert = editableText.substring(span.from, span.to + 1), - attributes = attributes - ) - }) + // Add the last insert with attributes to the list + if (currentInsert.isNotEmpty()) { + groupedSpans.add(Span(currentInsert, currentAttributes)) + } + + return QuillSpan(groupedSpans) + } + + private fun mergeAttributes(currentAttributes: Attributes?, newAttributes: Attributes): Attributes { + return Attributes( + header = newAttributes.header ?: currentAttributes?.header, + bold = newAttributes.bold ?: currentAttributes?.bold, + italic = newAttributes.italic ?: currentAttributes?.italic, + underline = newAttributes.underline ?: currentAttributes?.underline, + list = newAttributes.list ?: currentAttributes?.list + ) + } + + 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 fun setEditable(editable: Editable) { editable.append(editableText) @@ -107,6 +164,7 @@ class QuillTextManager(quillSpan: QuillSpan) { editable.removeSpans() editable.removeSpans() editable.removeSpans() + editable.removeSpans() spans.forEach { editable.setSpan( @@ -130,7 +188,30 @@ class QuillTextManager(quillSpan: QuillSpan) { getRichSpanListByTextRange(selection).distinct() } - this.currentStyles.addAll(currentStyles) + val currentSpan = + spans.findLast { + it.from <= selection.min - 2 && it.to >= selection.min - 2 && it.style == TextSpanStyle.BulletStyle + } + + if (currentSpan != null) { + if ( + currentSpan.style == TextSpanStyle.BulletStyle && + editable[selection.min - 1] == '\n' && + editable[selection.min - 2] != '\n' + ) { + addStyle(TextSpanStyle.BulletStyle) + } else if ( + currentSpan.style == 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 { @@ -327,16 +408,16 @@ class QuillTextManager(quillSpan: QuillSpan) { } private fun handleAddingCharacters(newValue: Editable) { - val typedChars = newValue.length - rawText.length - val startTypeIndex = selection.min - typedChars + val typedCharsCount = newValue.length - rawText.length + val startTypeIndex = selection.min - typedCharsCount - if (newValue.getOrNull(startTypeIndex) == '\n' && currentStyles.any { it.isHeaderStyle() }) { + if (newValue.getOrNull(startTypeIndex) == '\n' && currentStyles.any { it.isHeaderStyle() || it == TextSpanStyle.BulletStyle }) { currentStyles.clear() } val selectedStyles = currentStyles.toMutableList() - moveSpans(startTypeIndex, typedChars) + moveSpans(startTypeIndex, typedCharsCount) val startParts = spans.filter { startTypeIndex - 1 in it.from..it.to } val endParts = spans.filter { startTypeIndex in it.from..it.to } @@ -346,21 +427,21 @@ class QuillTextManager(quillSpan: QuillSpan) { .forEach { if (selectedStyles.contains(it.style)) { val index = spans.indexOf(it) - spans[index] = it.copy(to = it.to + typedChars) + spans[index] = it.copy(to = it.to + typedCharsCount) selectedStyles.remove(it.style) } } endParts.filter { it !in commonParts } - .forEach { processSpan(it, typedChars, startTypeIndex, selectedStyles, true) } + .forEach { processSpan(it, typedCharsCount, startTypeIndex, selectedStyles, true) } - commonParts.forEach { processSpan(it, typedChars, startTypeIndex, selectedStyles) } + commonParts.forEach { processSpan(it, typedCharsCount, startTypeIndex, selectedStyles) } selectedStyles.forEach { spans.add( RichTextSpan( from = startTypeIndex, - to = startTypeIndex + typedChars - 1, + to = startTypeIndex + typedCharsCount - 1, style = it ) ) 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 index 6f38841..c8b3e61 100644 --- a/editor/src/main/java/com/canopas/editor/ui/model/QuillSpan.kt +++ b/editor/src/main/java/com/canopas/editor/ui/model/QuillSpan.kt @@ -13,5 +13,10 @@ data class Attributes( val header: Int? = null, val bold: Boolean? = null, val italic: Boolean? = null, - val underline: Boolean? = null -) \ No newline at end of file + 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/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"