Menambah Underlined, Heading, dan Bullet List selain Bold dan Italic

This commit is contained in:
202310715297 RAIHAN ARIQ MUZAKKI 2025-12-19 00:43:54 +07:00
parent c0bbd3e54f
commit 7be456d7cb
3 changed files with 184 additions and 59 deletions

View File

@ -268,8 +268,11 @@ fun EditableFullScreenNoteView(
it.height.toFloat() it.height.toFloat()
) )
}, },
isBoldActive = editorState.isBoldActive(), isBoldActive = editorState.isBoldActive(),
isItalicActive = editorState.isItalicActive(), isItalicActive = editorState.isItalicActive(),
isUnderlineActive = editorState.isUnderlineActive(),
onDrag = ::moveToolbar, onDrag = ::moveToolbar,
onBold = { onBold = {
ensureFocus() ensureFocus()
@ -279,9 +282,9 @@ fun EditableFullScreenNoteView(
ensureFocus() ensureFocus()
editorState.toggleItalic() editorState.toggleItalic()
}, },
onHeading = {}, onUnderline = { ensureFocus(); editorState.toggleUnderline() },
onBullet = {}, onHeading = { editorState.setHeading(1) },
onCodeBlock = {} onBullet = { editorState.toggleBulletList() }
) )
} }
} }

View File

@ -24,13 +24,14 @@ fun DraggableMiniMarkdownToolbar(
// STATE // STATE
isBoldActive: Boolean, isBoldActive: Boolean,
isItalicActive: Boolean, isItalicActive: Boolean,
isUnderlineActive: Boolean,
// ACTIONS // ACTIONS
onBold: () -> Unit, onBold: () -> Unit,
onItalic: () -> Unit, onItalic: () -> Unit,
onHeading: () -> Unit, onHeading: () -> Unit,
onUnderline: () -> Unit,
onBullet: () -> Unit, onBullet: () -> Unit,
onCodeBlock: () -> Unit
) { ) {
Surface( Surface(
modifier = modifier, modifier = modifier,
@ -76,6 +77,12 @@ fun DraggableMiniMarkdownToolbar(
onClick = onItalic onClick = onItalic
) )
ToolbarIcon(
icon = Icons.Default.FormatUnderlined,
isActive = isUnderlineActive,
onClick = onUnderline
)
ToolbarIcon( ToolbarIcon(
icon = Icons.Default.Title, icon = Icons.Default.Title,
onClick = onHeading onClick = onHeading
@ -86,10 +93,6 @@ fun DraggableMiniMarkdownToolbar(
onClick = onBullet onClick = onBullet
) )
ToolbarIcon(
icon = Icons.Default.Code,
onClick = onCodeBlock
)
} }
} }
} }

View File

@ -4,7 +4,9 @@ import androidx.compose.runtime.*
import androidx.compose.ui.text.* import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.sp
@Stable @Stable
class RichEditorState(initial: AnnotatedString) { class RichEditorState(initial: AnnotatedString) {
@ -16,16 +18,15 @@ class RichEditorState(initial: AnnotatedString) {
) )
) )
// 🔑 ACTIVE TYPING STYLES (sticky) // 🔥 active typing styles (sticky)
private val activeStyles = mutableStateListOf<SpanStyle>() private val activeStyles = mutableStateListOf<SpanStyle>()
/* --------------------------- /* =====================
VALUE CHANGE (typing) VALUE CHANGE
--------------------------- */ ===================== */
fun onValueChange(newValue: TextFieldValue) { fun onValueChange(newValue: TextFieldValue) {
val old = value val old = value
// Cursor / IME update only
if (newValue.text == old.text) { if (newValue.text == old.text) {
value = old.copy( value = old.copy(
selection = newValue.selection, selection = newValue.selection,
@ -36,20 +37,18 @@ class RichEditorState(initial: AnnotatedString) {
val builder = AnnotatedString.Builder(newValue.text) val builder = AnnotatedString.Builder(newValue.text)
// 1copy old spans // copy old spans
old.annotatedString.spanStyles.forEach { old.annotatedString.spanStyles.forEach {
val start = it.start.coerceAtMost(newValue.text.length) val s = it.start.coerceAtMost(newValue.text.length)
val end = it.end.coerceAtMost(newValue.text.length) val e = it.end.coerceAtMost(newValue.text.length)
if (start < end) builder.addStyle(it.item, start, end) if (s < e) builder.addStyle(it.item, s, e)
} }
// 2⃣ apply active typing styles to NEW CHAR // apply active typing styles to new char
val insertStart = newValue.selection.start - 1 val insertPos = newValue.selection.start - 1
val insertEnd = newValue.selection.start if (insertPos >= 0) {
activeStyles.forEach {
if (insertStart >= 0 && insertEnd > insertStart) { builder.addStyle(it, insertPos, insertPos + 1)
activeStyles.forEach { style ->
builder.addStyle(style, insertStart, insertEnd)
} }
} }
@ -60,9 +59,9 @@ class RichEditorState(initial: AnnotatedString) {
) )
} }
/* --------------------------- /* =====================
TOGGLE STYLES TOGGLE STYLES
--------------------------- */ ===================== */
fun toggleBold() = fun toggleBold() =
toggleStyle(SpanStyle(fontWeight = FontWeight.Bold)) toggleStyle(SpanStyle(fontWeight = FontWeight.Bold))
@ -70,20 +69,21 @@ class RichEditorState(initial: AnnotatedString) {
fun toggleItalic() = fun toggleItalic() =
toggleStyle(SpanStyle(fontStyle = FontStyle.Italic)) toggleStyle(SpanStyle(fontStyle = FontStyle.Italic))
fun toggleUnderline() =
toggleStyle(SpanStyle(textDecoration = TextDecoration.Underline))
private fun toggleStyle(style: SpanStyle) { private fun toggleStyle(style: SpanStyle) {
val sel = value.selection.normalized() val sel = value.selection.normalized()
val hasSelection = !sel.collapsed if (!sel.collapsed) {
if (hasSelection) {
applyStyleToSelection(style) applyStyleToSelection(style)
} else { } else {
toggleTypingStyle(style) toggleTypingStyle(style)
} }
} }
/* --------------------------- /* =====================
APPLY TO SELECTION SELECTION STYLE
--------------------------- */ ===================== */
private fun applyStyleToSelection(style: SpanStyle) { private fun applyStyleToSelection(style: SpanStyle) {
val sel = value.selection.normalized() val sel = value.selection.normalized()
val start = sel.start val start = sel.start
@ -94,10 +94,7 @@ class RichEditorState(initial: AnnotatedString) {
value.annotatedString.spanStyles.forEach { value.annotatedString.spanStyles.forEach {
val overlap = it.start < end && it.end > start val overlap = it.start < end && it.end > start
val same = val same = it.item.hasSameStyle(style)
it.item.fontWeight == style.fontWeight &&
it.item.fontStyle == style.fontStyle
if (!(overlap && same)) { if (!(overlap && same)) {
builder.addStyle(it.item, it.start, it.end) builder.addStyle(it.item, it.start, it.end)
} }
@ -111,53 +108,175 @@ class RichEditorState(initial: AnnotatedString) {
) )
} }
/* --------------------------- /* =====================
TYPING MODE (sticky) TYPING MODE
--------------------------- */ ===================== */
private fun toggleTypingStyle(style: SpanStyle) { private fun toggleTypingStyle(style: SpanStyle) {
val index = activeStyles.indexOfFirst { val index = activeStyles.indexOfFirst { it.hasSameStyle(style) }
it.fontWeight == style.fontWeight && if (index >= 0) activeStyles.removeAt(index)
it.fontStyle == style.fontStyle else activeStyles.add(style)
}
/* =====================
HEADING (per line)
===================== */
fun setHeading(level: Int) {
val sel = value.selection
val text = value.text
val lineStart = text.lastIndexOf('\n', sel.start - 1).let { if (it == -1) 0 else it + 1 }
val lineEnd = text.indexOf('\n', sel.start).let { if (it == -1) text.length else it }
val size = when (level) {
1 -> 28.sp
2 -> 22.sp
3 -> 18.sp
else -> return
} }
if (index >= 0) { val builder = AnnotatedString.Builder(text)
activeStyles.removeAt(index)
value.annotatedString.spanStyles.forEach {
if (!(it.start >= lineStart && it.end <= lineEnd)) {
builder.addStyle(it.item, it.start, it.end)
}
}
builder.addStyle(
SpanStyle(fontSize = size, fontWeight = FontWeight.Bold),
lineStart,
lineEnd
)
value = value.copy(annotatedString = builder.toAnnotatedString())
}
/* =====================
LIST (simple bullet)
===================== */
fun toggleBulletList() {
val sel = value.selection
val text = value.text
val prefix = ""
val lineStart = text.lastIndexOf('\n', (sel.start - 1).coerceAtLeast(0))
.let { if (it == -1) 0 else it + 1 }
val oldAnnotated = value.annotatedString
// helper: rebuild annotated string + shift spans
fun rebuildWithDelta(newText: String, changeStart: Int, delta: Int, removedLen: Int = 0): AnnotatedString {
val b = AnnotatedString.Builder(newText)
oldAnnotated.spanStyles.forEach { r ->
var s = r.start
var e = r.end
if (removedLen == 0) {
// INSERT
when {
e <= changeStart -> { /* keep */ }
s >= changeStart -> { s += delta; e += delta }
else -> { e += delta } // span crosses insertion point
}
} else {
// REMOVE range [changeStart, changeStart + removedLen)
val removeEnd = changeStart + removedLen
when {
e <= changeStart -> { /* keep */ }
s >= removeEnd -> { s -= removedLen; e -= removedLen }
s < changeStart && e > removeEnd -> { e -= removedLen } // span crosses whole removed segment
s < changeStart && e in (changeStart + 1)..removeEnd -> { e = changeStart } // ends inside removed
s in changeStart until removeEnd && e > removeEnd -> { s = changeStart; e -= removedLen } // starts inside removed
else -> return@forEach // fully inside removed -> drop span
}
}
// clamp
s = s.coerceIn(0, newText.length)
e = e.coerceIn(0, newText.length)
if (s < e) b.addStyle(r.item, s, e)
}
return b.toAnnotatedString()
}
val isBullet = text.startsWith(prefix, startIndex = lineStart)
if (isBullet) {
// REMOVE bullet prefix
val newText = text.removeRange(lineStart, lineStart + prefix.length)
val newAnnotated = rebuildWithDelta(
newText = newText,
changeStart = lineStart,
delta = -prefix.length,
removedLen = prefix.length
)
val newCursor = (sel.start - prefix.length).coerceAtLeast(lineStart)
value = value.copy(
annotatedString = newAnnotated,
selection = TextRange(newCursor)
)
} else { } else {
activeStyles.add(style) // INSERT bullet prefix
val newText = text.substring(0, lineStart) + prefix + text.substring(lineStart)
val newAnnotated = rebuildWithDelta(
newText = newText,
changeStart = lineStart,
delta = prefix.length,
removedLen = 0
)
val newCursor = sel.start + prefix.length
value = value.copy(
annotatedString = newAnnotated,
selection = TextRange(newCursor)
)
} }
} }
/* ---------------------------
/* =====================
TOOLBAR STATE TOOLBAR STATE
--------------------------- */ ===================== */
fun isBoldActive(): Boolean = fun isBoldActive() = isStyleActive(fontWeight = FontWeight.Bold)
isStyleActive(FontWeight.Bold, null) fun isItalicActive() = isStyleActive(fontStyle = FontStyle.Italic)
fun isUnderlineActive() = isStyleActive(decoration = TextDecoration.Underline)
fun isItalicActive(): Boolean =
isStyleActive(null, FontStyle.Italic)
private fun isStyleActive( private fun isStyleActive(
weight: FontWeight?, fontWeight: FontWeight? = null,
style: FontStyle? fontStyle: FontStyle? = null,
decoration: TextDecoration? = null
): Boolean { ): Boolean {
val sel = value.selection val sel = value.selection
if (!sel.collapsed) { if (!sel.collapsed) {
return value.annotatedString.spanStyles.any { return value.annotatedString.spanStyles.any {
it.start <= sel.start && it.start <= sel.start &&
it.end >= sel.end && it.end >= sel.end &&
(weight == null || it.item.fontWeight == weight) && (fontWeight == null || it.item.fontWeight == fontWeight) &&
(style == null || it.item.fontStyle == style) (fontStyle == null || it.item.fontStyle == fontStyle) &&
(decoration == null || it.item.textDecoration == decoration)
} }
} }
return activeStyles.any { return activeStyles.any {
(weight == null || it.fontWeight == weight) && (fontWeight == null || it.fontWeight == fontWeight) &&
(style == null || it.fontStyle == style) (fontStyle == null || it.fontStyle == fontStyle) &&
(decoration == null || it.textDecoration == decoration)
} }
} }
} }
/* -------- helper -------- */ /* =====================
HELPERS
===================== */
private fun SpanStyle.hasSameStyle(other: SpanStyle): Boolean =
fontWeight == other.fontWeight &&
fontStyle == other.fontStyle &&
textDecoration == other.textDecoration
private fun TextRange.normalized(): TextRange = private fun TextRange.normalized(): TextRange =
if (start <= end) this else TextRange(end, start) if (start <= end) this else TextRange(end, start)