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()
)
},
isBoldActive = editorState.isBoldActive(),
isItalicActive = editorState.isItalicActive(),
isUnderlineActive = editorState.isUnderlineActive(),
onDrag = ::moveToolbar,
onBold = {
ensureFocus()
@ -279,9 +282,9 @@ fun EditableFullScreenNoteView(
ensureFocus()
editorState.toggleItalic()
},
onHeading = {},
onBullet = {},
onCodeBlock = {}
onUnderline = { ensureFocus(); editorState.toggleUnderline() },
onHeading = { editorState.setHeading(1) },
onBullet = { editorState.toggleBulletList() }
)
}
}

View File

@ -24,13 +24,14 @@ fun DraggableMiniMarkdownToolbar(
// STATE
isBoldActive: Boolean,
isItalicActive: Boolean,
isUnderlineActive: Boolean,
// ACTIONS
onBold: () -> Unit,
onItalic: () -> Unit,
onHeading: () -> Unit,
onUnderline: () -> Unit,
onBullet: () -> Unit,
onCodeBlock: () -> Unit
) {
Surface(
modifier = modifier,
@ -76,6 +77,12 @@ fun DraggableMiniMarkdownToolbar(
onClick = onItalic
)
ToolbarIcon(
icon = Icons.Default.FormatUnderlined,
isActive = isUnderlineActive,
onClick = onUnderline
)
ToolbarIcon(
icon = Icons.Default.Title,
onClick = onHeading
@ -86,10 +93,6 @@ fun DraggableMiniMarkdownToolbar(
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.font.FontStyle
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.unit.sp
@Stable
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>()
/* ---------------------------
VALUE CHANGE (typing)
--------------------------- */
/* =====================
VALUE CHANGE
===================== */
fun onValueChange(newValue: TextFieldValue) {
val old = value
// Cursor / IME update only
if (newValue.text == old.text) {
value = old.copy(
selection = newValue.selection,
@ -36,20 +37,18 @@ class RichEditorState(initial: AnnotatedString) {
val builder = AnnotatedString.Builder(newValue.text)
// 1copy old spans
// copy old spans
old.annotatedString.spanStyles.forEach {
val start = it.start.coerceAtMost(newValue.text.length)
val end = it.end.coerceAtMost(newValue.text.length)
if (start < end) builder.addStyle(it.item, start, end)
val s = it.start.coerceAtMost(newValue.text.length)
val e = it.end.coerceAtMost(newValue.text.length)
if (s < e) builder.addStyle(it.item, s, e)
}
// 2⃣ apply active typing styles to NEW CHAR
val insertStart = newValue.selection.start - 1
val insertEnd = newValue.selection.start
if (insertStart >= 0 && insertEnd > insertStart) {
activeStyles.forEach { style ->
builder.addStyle(style, insertStart, insertEnd)
// apply active typing styles to new char
val insertPos = newValue.selection.start - 1
if (insertPos >= 0) {
activeStyles.forEach {
builder.addStyle(it, insertPos, insertPos + 1)
}
}
@ -60,9 +59,9 @@ class RichEditorState(initial: AnnotatedString) {
)
}
/* ---------------------------
/* =====================
TOGGLE STYLES
--------------------------- */
===================== */
fun toggleBold() =
toggleStyle(SpanStyle(fontWeight = FontWeight.Bold))
@ -70,20 +69,21 @@ class RichEditorState(initial: AnnotatedString) {
fun toggleItalic() =
toggleStyle(SpanStyle(fontStyle = FontStyle.Italic))
fun toggleUnderline() =
toggleStyle(SpanStyle(textDecoration = TextDecoration.Underline))
private fun toggleStyle(style: SpanStyle) {
val sel = value.selection.normalized()
val hasSelection = !sel.collapsed
if (hasSelection) {
if (!sel.collapsed) {
applyStyleToSelection(style)
} else {
toggleTypingStyle(style)
}
}
/* ---------------------------
APPLY TO SELECTION
--------------------------- */
/* =====================
SELECTION STYLE
===================== */
private fun applyStyleToSelection(style: SpanStyle) {
val sel = value.selection.normalized()
val start = sel.start
@ -94,10 +94,7 @@ class RichEditorState(initial: AnnotatedString) {
value.annotatedString.spanStyles.forEach {
val overlap = it.start < end && it.end > start
val same =
it.item.fontWeight == style.fontWeight &&
it.item.fontStyle == style.fontStyle
val same = it.item.hasSameStyle(style)
if (!(overlap && same)) {
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) {
val index = activeStyles.indexOfFirst {
it.fontWeight == style.fontWeight &&
it.fontStyle == style.fontStyle
val index = activeStyles.indexOfFirst { it.hasSameStyle(style) }
if (index >= 0) activeStyles.removeAt(index)
else activeStyles.add(style)
}
if (index >= 0) {
activeStyles.removeAt(index)
/* =====================
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
}
val builder = AnnotatedString.Builder(text)
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 {
activeStyles.add(style)
// 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 {
// 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
--------------------------- */
===================== */
fun isBoldActive(): Boolean =
isStyleActive(FontWeight.Bold, null)
fun isItalicActive(): Boolean =
isStyleActive(null, FontStyle.Italic)
fun isBoldActive() = isStyleActive(fontWeight = FontWeight.Bold)
fun isItalicActive() = isStyleActive(fontStyle = FontStyle.Italic)
fun isUnderlineActive() = isStyleActive(decoration = TextDecoration.Underline)
private fun isStyleActive(
weight: FontWeight?,
style: FontStyle?
fontWeight: FontWeight? = null,
fontStyle: FontStyle? = null,
decoration: TextDecoration? = null
): Boolean {
val sel = value.selection
if (!sel.collapsed) {
return value.annotatedString.spanStyles.any {
it.start <= sel.start &&
it.end >= sel.end &&
(weight == null || it.item.fontWeight == weight) &&
(style == null || it.item.fontStyle == style)
(fontWeight == null || it.item.fontWeight == fontWeight) &&
(fontStyle == null || it.item.fontStyle == fontStyle) &&
(decoration == null || it.item.textDecoration == decoration)
}
}
return activeStyles.any {
(weight == null || it.fontWeight == weight) &&
(style == null || it.fontStyle == style)
(fontWeight == null || it.fontWeight == fontWeight) &&
(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 =
if (start <= end) this else TextRange(end, start)