From 7f5e2fd28de2d11e6716b2d53ff16e633376547a Mon Sep 17 00:00:00 2001 From: Raihan Ariq <202310715297@mhs.ubharajaya.ac.id> Date: Fri, 19 Dec 2025 01:58:17 +0700 Subject: [PATCH] Serializer untuk AnnotatedString agar Markdown tampilannya tetap pada Notes setelah di save --- .../note/EditableFullScreenNoteView.kt | 12 +- .../DraggableMiniMarkdownToolbar.kt | 3 +- .../screens/note/editor/RichEditorState.kt | 420 ++++++++++++------ .../util/AnnotatedStrtingSerializer.kt | 66 +++ 4 files changed, 362 insertions(+), 139 deletions(-) create mode 100644 app/src/main/java/com/example/notesai/util/AnnotatedStrtingSerializer.kt diff --git a/app/src/main/java/com/example/notesai/presentation/screens/note/EditableFullScreenNoteView.kt b/app/src/main/java/com/example/notesai/presentation/screens/note/EditableFullScreenNoteView.kt index 64166be..6b8daf8 100644 --- a/app/src/main/java/com/example/notesai/presentation/screens/note/EditableFullScreenNoteView.kt +++ b/app/src/main/java/com/example/notesai/presentation/screens/note/EditableFullScreenNoteView.kt @@ -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() } ) } } diff --git a/app/src/main/java/com/example/notesai/presentation/screens/note/components/DraggableMiniMarkdownToolbar.kt b/app/src/main/java/com/example/notesai/presentation/screens/note/components/DraggableMiniMarkdownToolbar.kt index 093bf62..66052eb 100644 --- a/app/src/main/java/com/example/notesai/presentation/screens/note/components/DraggableMiniMarkdownToolbar.kt +++ b/app/src/main/java/com/example/notesai/presentation/screens/note/components/DraggableMiniMarkdownToolbar.kt @@ -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 ) + } } } diff --git a/app/src/main/java/com/example/notesai/presentation/screens/note/editor/RichEditorState.kt b/app/src/main/java/com/example/notesai/presentation/screens/note/editor/RichEditorState.kt index a581842..b102e18 100644 --- a/app/src/main/java/com/example/notesai/presentation/screens/note/editor/RichEditorState.kt +++ b/app/src/main/java/com/example/notesai/presentation/screens/note/editor/RichEditorState.kt @@ -18,15 +18,44 @@ class RichEditorState(initial: AnnotatedString) { ) ) - // 🔥 active typing styles (sticky) + /* ===================== + UNDO / REDO + ===================== */ + private val undoStack = mutableStateListOf() + private val redoStack = mutableStateListOf() + + 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() /* ===================== - 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 && diff --git a/app/src/main/java/com/example/notesai/util/AnnotatedStrtingSerializer.kt b/app/src/main/java/com/example/notesai/util/AnnotatedStrtingSerializer.kt new file mode 100644 index 0000000..bdc3f1b --- /dev/null +++ b/app/src/main/java/com/example/notesai/util/AnnotatedStrtingSerializer.kt @@ -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 +) + +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(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 + } + } +}