Serializer untuk AnnotatedString agar Markdown tampilannya tetap pada Notes setelah di save

This commit is contained in:
202310715297 RAIHAN ARIQ MUZAKKI 2025-12-19 01:58:17 +07:00
parent 7be456d7cb
commit 7f5e2fd28d
4 changed files with 362 additions and 139 deletions

View File

@ -23,6 +23,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor // ✅ ADD
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.*
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.*
import com.example.notesai.data.model.Note
@ -53,9 +54,12 @@ fun EditableFullScreenNoteView(
var isContentFocused by remember { mutableStateOf(false) }
val editorState = remember(note.id) {
RichEditorState(MarkdownParser.parse(note.content))
RichEditorState(
AnnotatedStringSerializer.fromJson(note.content)
)
}
val focusRequester = remember { FocusRequester() }
val bringIntoViewRequester = remember { BringIntoViewRequester() }
val scrollState = rememberScrollState()
@ -71,7 +75,7 @@ fun EditableFullScreenNoteView(
if (title.isNotBlank()) {
onSave(
title,
MarkdownSerializer.toMarkdown(editorState.value.annotatedString)
AnnotatedStringSerializer.toJson(editorState.value.annotatedString)
)
}
}
@ -283,8 +287,8 @@ fun EditableFullScreenNoteView(
editorState.toggleItalic()
},
onUnderline = { ensureFocus(); editorState.toggleUnderline() },
onHeading = { editorState.setHeading(1) },
onBullet = { editorState.toggleBulletList() }
onHeading = { ensureFocus(); editorState.setHeading(1) }, // sementara H1
onBullet = { ensureFocus(); editorState.toggleBulletList() }
)
}
}

View File

@ -31,7 +31,7 @@ fun DraggableMiniMarkdownToolbar(
onItalic: () -> Unit,
onHeading: () -> Unit,
onUnderline: () -> Unit,
onBullet: () -> Unit,
onBullet: () -> Unit
) {
Surface(
modifier = modifier,
@ -93,6 +93,7 @@ fun DraggableMiniMarkdownToolbar(
onClick = onBullet
)
}
}
}

View File

@ -18,15 +18,44 @@ class RichEditorState(initial: AnnotatedString) {
)
)
// 🔥 active typing styles (sticky)
/* =====================
UNDO / REDO
===================== */
private val undoStack = mutableStateListOf<TextFieldValue>()
private val redoStack = mutableStateListOf<TextFieldValue>()
private fun snapshot() {
undoStack.add(value)
redoStack.clear()
}
fun canUndo() = undoStack.isNotEmpty()
fun canRedo() = redoStack.isNotEmpty()
fun undo() {
if (!canUndo()) return
redoStack.add(value)
value = undoStack.removeLast()
}
fun redo() {
if (!canRedo()) return
undoStack.add(value)
value = redoStack.removeLast()
}
/* =====================
STICKY TYPING STYLE
===================== */
private val activeStyles = mutableStateListOf<SpanStyle>()
/* =====================
VALUE CHANGE
VALUE CHANGE (KEY)
===================== */
fun onValueChange(newValue: TextFieldValue) {
val old = value
// cursor/selection change only
if (newValue.text == old.text) {
value = old.copy(
selection = newValue.selection,
@ -35,24 +64,36 @@ class RichEditorState(initial: AnnotatedString) {
return
}
snapshot()
// 1) build new annotated string by preserving old spans
val built = buildPreservingSpans(old, newValue)
// 2) auto-convert markdown patterns around cursor
val converted = autoConvertMarkdown(built)
value = converted
}
private fun buildPreservingSpans(old: TextFieldValue, newValue: TextFieldValue): TextFieldValue {
val builder = AnnotatedString.Builder(newValue.text)
// copy old spans
old.annotatedString.spanStyles.forEach {
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)
// copy old spans (clamped)
old.annotatedString.spanStyles.forEach { r ->
val s = r.start.coerceIn(0, newValue.text.length)
val e = r.end.coerceIn(0, newValue.text.length)
if (s < e) builder.addStyle(r.item, s, e)
}
// apply active typing styles to new char
// apply sticky styles to newly inserted char (simple heuristic)
val insertPos = newValue.selection.start - 1
if (insertPos >= 0) {
activeStyles.forEach {
builder.addStyle(it, insertPos, insertPos + 1)
if (insertPos >= 0 && insertPos < newValue.text.length) {
activeStyles.forEach { st ->
builder.addStyle(st, insertPos, insertPos + 1)
}
}
value = TextFieldValue(
return TextFieldValue(
annotatedString = builder.toAnnotatedString(),
selection = newValue.selection,
composition = newValue.composition
@ -60,30 +101,25 @@ class RichEditorState(initial: AnnotatedString) {
}
/* =====================
TOGGLE STYLES
TOOLBAR TOGGLES
===================== */
fun toggleBold() =
toggleStyle(SpanStyle(fontWeight = FontWeight.Bold))
fun toggleItalic() =
toggleStyle(SpanStyle(fontStyle = FontStyle.Italic))
fun toggleUnderline() =
toggleStyle(SpanStyle(textDecoration = TextDecoration.Underline))
fun toggleBold() = toggleStyle(SpanStyle(fontWeight = FontWeight.Bold))
fun toggleItalic() = toggleStyle(SpanStyle(fontStyle = FontStyle.Italic))
fun toggleUnderline() = toggleStyle(SpanStyle(textDecoration = TextDecoration.Underline))
private fun toggleStyle(style: SpanStyle) {
val sel = value.selection.normalized()
if (!sel.collapsed) {
applyStyleToSelection(style)
} else {
toggleTypingStyle(style)
}
snapshot()
if (!sel.collapsed) applyStyleToSelection(style)
else toggleTypingStyle(style)
}
private fun toggleTypingStyle(style: SpanStyle) {
val idx = activeStyles.indexOfFirst { it.hasSameStyle(style) }
if (idx >= 0) activeStyles.removeAt(idx) else activeStyles.add(style)
}
/* =====================
SELECTION STYLE
===================== */
private fun applyStyleToSelection(style: SpanStyle) {
val sel = value.selection.normalized()
val start = sel.start
@ -92,12 +128,10 @@ class RichEditorState(initial: AnnotatedString) {
val builder = AnnotatedString.Builder(value.text)
value.annotatedString.spanStyles.forEach {
val overlap = it.start < end && it.end > start
val same = it.item.hasSameStyle(style)
if (!(overlap && same)) {
builder.addStyle(it.item, it.start, it.end)
}
value.annotatedString.spanStyles.forEach { r ->
val overlap = r.start < end && r.end > start
val same = r.item.hasSameStyle(style)
if (!(overlap && same)) builder.addStyle(r.item, r.start, r.end)
}
builder.addStyle(style, start, end)
@ -109,22 +143,14 @@ class RichEditorState(initial: AnnotatedString) {
}
/* =====================
TYPING MODE
===================== */
private fun toggleTypingStyle(style: SpanStyle) {
val index = activeStyles.indexOfFirst { it.hasSameStyle(style) }
if (index >= 0) activeStyles.removeAt(index)
else activeStyles.add(style)
}
/* =====================
HEADING (per line)
HEADING / BULLET (for toolbar)
===================== */
fun setHeading(level: Int) {
snapshot()
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 lineStart = text.lastIndexOf('\n', (sel.start - 1).coerceAtLeast(0)).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) {
@ -135,113 +161,184 @@ class RichEditorState(initial: AnnotatedString) {
}
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.annotatedString.spanStyles.forEach { r -> builder.addStyle(r.item, r.start, r.end) }
builder.addStyle(SpanStyle(fontSize = size, fontWeight = FontWeight.Bold), lineStart, lineEnd)
value = value.copy(annotatedString = builder.toAnnotatedString())
}
/* =====================
LIST (simple bullet)
===================== */
fun toggleBulletList() {
snapshot()
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 lineStart = text.lastIndexOf('\n', (sel.start - 1).coerceAtLeast(0)).let { if (it == -1) 0 else it + 1 }
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)
replaceTextPreserveSpans(
start = lineStart,
end = lineStart + prefix.length,
replacement = "",
newCursor = (sel.start - prefix.length).coerceAtLeast(lineStart)
)
} 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)
replaceTextPreserveSpans(
start = lineStart,
end = lineStart,
replacement = prefix,
newCursor = sel.start + prefix.length
)
}
}
/* =====================
AUTO-CONVERT MARKDOWN (LEVEL 3)
===================== */
private fun autoConvertMarkdown(v: TextFieldValue): TextFieldValue {
var cur = v
// order matters: bold before italic
cur = convertBold(cur)
cur = convertItalic(cur)
cur = convertHeading(cur)
cur = convertDashBullet(cur)
return cur
}
// **word** -> bold(word), remove ** **
private fun convertBold(v: TextFieldValue): TextFieldValue {
val text = v.text
val cursor = v.selection.start
if (cursor < 2) return v
if (!(text.getOrNull(cursor - 1) == '*' && text.getOrNull(cursor - 2) == '*')) return v
val startMarker = text.lastIndexOf("**", startIndex = cursor - 3)
if (startMarker == -1) return v
val contentStart = startMarker + 2
val contentEnd = cursor - 2
if (contentEnd <= contentStart) return v
if (text.substring(contentStart, contentEnd).contains('\n')) return v
// remove end marker then start marker (preserve spans)
var out = v
out = replaceTextPreserveSpansLocal(out, cursor - 2, cursor, "")
out = replaceTextPreserveSpansLocal(out, startMarker, startMarker + 2, "")
// after removing start marker, content shifts -2
val newStart = startMarker
val newEnd = contentEnd - 2
out = addStylePreserve(out, SpanStyle(fontWeight = FontWeight.Bold), newStart, newEnd)
// cursor shifts back 4 chars total
out = out.copy(selection = TextRange((cursor - 4).coerceAtLeast(newEnd)))
return out
}
// *word* -> italic(word), remove * *
private fun convertItalic(v: TextFieldValue): TextFieldValue {
val text = v.text
val cursor = v.selection.start
if (cursor < 1) return v
// avoid triggering on bold closing (**)
if (text.getOrNull(cursor - 1) != '*') return v
if (text.getOrNull(cursor - 2) == '*') return v
val startMarker = text.lastIndexOf('*', startIndex = cursor - 2)
if (startMarker == -1) return v
// avoid ** as start
if (text.getOrNull(startMarker - 1) == '*') return v
val contentStart = startMarker + 1
val contentEnd = cursor - 1
if (contentEnd <= contentStart) return v
if (text.substring(contentStart, contentEnd).contains('\n')) return v
var out = v
out = replaceTextPreserveSpansLocal(out, cursor - 1, cursor, "")
out = replaceTextPreserveSpansLocal(out, startMarker, startMarker + 1, "")
val newStart = startMarker
val newEnd = contentEnd - 1
out = addStylePreserve(out, SpanStyle(fontStyle = FontStyle.Italic), newStart, newEnd)
out = out.copy(selection = TextRange((cursor - 2).coerceAtLeast(newEnd)))
return out
}
// "# " / "## " / "### " at line start -> heading + remove markers
private fun convertHeading(v: TextFieldValue): TextFieldValue {
val text = v.text
val cursor = v.selection.start
val lineStart = text.lastIndexOf('\n', (cursor - 1).coerceAtLeast(0)).let { if (it == -1) 0 else it + 1 }
val lineEnd = text.indexOf('\n', cursor).let { if (it == -1) text.length else it }
val linePrefix = text.substring(lineStart, minOf(lineStart + 4, text.length))
val level = when {
linePrefix.startsWith("### ") -> 3
linePrefix.startsWith("## ") -> 2
linePrefix.startsWith("# ") -> 1
else -> return v
}
// only trigger when user just typed the space after #'s OR when cursor is still on same line early
val removeLen = when (level) {
1 -> 2
2 -> 3
else -> 4
}
val triggerPos = lineStart + removeLen
if (cursor < triggerPos) return v
var out = v
out = replaceTextPreserveSpansLocal(out, lineStart, lineStart + removeLen, "")
// apply heading style to the line content
val newLineStart = lineStart
val newLineEnd = (lineEnd - removeLen).coerceAtLeast(newLineStart)
val size = when (level) {
1 -> 28.sp
2 -> 22.sp
else -> 18.sp
}
out = addStylePreserve(
out,
SpanStyle(fontSize = size, fontWeight = FontWeight.Bold),
newLineStart,
newLineEnd
)
// shift cursor back by removedLen
out = out.copy(selection = TextRange((cursor - removeLen).coerceAtLeast(newLineStart)))
return out
}
// "- " at line start -> "• "
private fun convertDashBullet(v: TextFieldValue): TextFieldValue {
val text = v.text
val cursor = v.selection.start
val lineStart = text.lastIndexOf('\n', (cursor - 1).coerceAtLeast(0)).let { if (it == -1) 0 else it + 1 }
val prefix = "- "
if (!text.startsWith(prefix, startIndex = lineStart)) return v
// only trigger when user already typed "- "
if (cursor < lineStart + 2) return v
var out = v
out = replaceTextPreserveSpansLocal(out, lineStart, lineStart + 2, "")
// cursor stays same length (2)
return out
}
/* =====================
TOOLBAR STATE
===================== */
fun isBoldActive() = isStyleActive(fontWeight = FontWeight.Bold)
fun isItalicActive() = isStyleActive(fontStyle = FontStyle.Italic)
fun isUnderlineActive() = isStyleActive(decoration = TextDecoration.Underline)
@ -267,12 +364,67 @@ class RichEditorState(initial: AnnotatedString) {
(decoration == null || it.textDecoration == decoration)
}
}
/* =====================
INTERNAL: text replace while preserving spans
===================== */
private fun replaceTextPreserveSpans(start: Int, end: Int, replacement: String, newCursor: Int) {
value = replaceTextPreserveSpansLocal(value, start, end, replacement)
.copy(selection = TextRange(newCursor))
}
private fun replaceTextPreserveSpansLocal(
v: TextFieldValue,
start: Int,
end: Int,
replacement: String
): TextFieldValue {
val oldText = v.text
val s = start.coerceIn(0, oldText.length)
val e = end.coerceIn(0, oldText.length)
if (s > e) return v
val newText = oldText.substring(0, s) + replacement + oldText.substring(e)
val delta = replacement.length - (e - s)
val b = AnnotatedString.Builder(newText)
v.annotatedString.spanStyles.forEach { r ->
var rs = r.start
var re = r.end
// adjust spans
when {
re <= s -> Unit
rs >= e -> { rs += delta; re += delta }
rs < s && re > e -> re += delta
rs < s && re in (s + 1)..e -> re = s
rs in s until e && re > e -> { rs = s + replacement.length; re += delta }
else -> return@forEach
}
rs = rs.coerceIn(0, newText.length)
re = re.coerceIn(0, newText.length)
if (rs < re) b.addStyle(r.item, rs, re)
}
return v.copy(annotatedString = b.toAnnotatedString())
}
private fun addStylePreserve(v: TextFieldValue, style: SpanStyle, start: Int, end: Int): TextFieldValue {
val s = start.coerceIn(0, v.text.length)
val e = end.coerceIn(0, v.text.length)
if (s >= e) return v
val b = AnnotatedString.Builder(v.text)
v.annotatedString.spanStyles.forEach { r -> b.addStyle(r.item, r.start, r.end) }
b.addStyle(style, s, e)
return v.copy(annotatedString = b.toAnnotatedString())
}
}
/* =====================
HELPERS
===================== */
private fun SpanStyle.hasSameStyle(other: SpanStyle): Boolean =
fontWeight == other.fontWeight &&
fontStyle == other.fontStyle &&

View File

@ -0,0 +1,66 @@
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
@Serializable
data class SpanDto(
val start: Int,
val end: Int,
val bold: Boolean = false,
val italic: Boolean = false,
val underline: Boolean = false
)
@Serializable
data class RichTextDto(
val text: String,
val spans: List<SpanDto>
)
object AnnotatedStringSerializer {
fun toJson(value: AnnotatedString): String {
val spans = value.spanStyles.map {
SpanDto(
start = it.start,
end = it.end,
bold = it.item.fontWeight != null,
italic = it.item.fontStyle != null,
underline = it.item.textDecoration != null
)
}
return Json.encodeToString(
RichTextDto(
text = value.text,
spans = spans
)
)
}
fun fromJson(json: String): AnnotatedString {
return try {
val dto = Json.decodeFromString<RichTextDto>(json)
val builder = AnnotatedString.Builder(dto.text)
dto.spans.forEach {
builder.addStyle(
SpanStyle(
fontWeight = if (it.bold) androidx.compose.ui.text.font.FontWeight.Bold else null,
fontStyle = if (it.italic) androidx.compose.ui.text.font.FontStyle.Italic else null,
textDecoration = if (it.underline) androidx.compose.ui.text.style.TextDecoration.Underline else null
),
it.start,
it.end
)
}
builder.toAnnotatedString()
} catch (e: Exception) {
AnnotatedString(json) // fallback plain
}
}
}