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()
|
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() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
// 1️⃣ copy 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)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user