Menambah Underlined, Heading, dan Bullet List selain Bold dan Italic
This commit is contained in:
parent
c0bbd3e54f
commit
7be456d7cb
@ -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() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
// 1️⃣ copy 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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user