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 88f505f..bd14db9 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 @@ -264,6 +264,8 @@ fun EditableFullScreenNoteView( } } + // Update bagian DraggableMiniMarkdownToolbar call: + if (isContentFocused) { DraggableMiniMarkdownToolbar( modifier = Modifier @@ -302,9 +304,10 @@ fun EditableFullScreenNoteView( ensureFocus() editorState.toggleItalic() }, - onUnderline = { ensureFocus(); editorState.toggleUnderline() }, - onHeading = { ensureFocus(); editorState.setHeading(1) }, - onBullet = { ensureFocus(); editorState.toggleBulletList() } + onUnderline = { + ensureFocus() + editorState.toggleUnderline() + } ) } } 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 66052eb..a845653 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 @@ -29,9 +29,7 @@ fun DraggableMiniMarkdownToolbar( // ACTIONS onBold: () -> Unit, onItalic: () -> Unit, - onHeading: () -> Unit, - onUnderline: () -> Unit, - onBullet: () -> Unit + onUnderline: () -> Unit ) { Surface( modifier = modifier, @@ -82,18 +80,6 @@ fun DraggableMiniMarkdownToolbar( isActive = isUnderlineActive, onClick = onUnderline ) - - ToolbarIcon( - icon = Icons.Default.Title, - onClick = onHeading - ) - - ToolbarIcon( - icon = Icons.Default.FormatListBulleted, - onClick = onBullet - ) - - } } } @@ -128,4 +114,4 @@ private fun ToolbarIcon( ) } } -} +} \ No newline at end of file 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 b102e18..9d01124 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 @@ -6,7 +6,6 @@ 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) { @@ -18,39 +17,13 @@ class RichEditorState(initial: AnnotatedString) { ) ) - /* ===================== - 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 (KEY) + VALUE CHANGE ===================== */ fun onValueChange(newValue: TextFieldValue) { val old = value @@ -64,12 +37,10 @@ class RichEditorState(initial: AnnotatedString) { return } - snapshot() - - // 1) build new annotated string by preserving old spans + // Build new annotated string by preserving old spans val built = buildPreservingSpans(old, newValue) - // 2) auto-convert markdown patterns around cursor + // Auto-convert markdown patterns val converted = autoConvertMarkdown(built) value = converted @@ -78,18 +49,73 @@ class RichEditorState(initial: AnnotatedString) { private fun buildPreservingSpans(old: TextFieldValue, newValue: TextFieldValue): TextFieldValue { val builder = AnnotatedString.Builder(newValue.text) - // 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) + val oldText = old.text + val newText = newValue.text + + // Detect where text was inserted/deleted + val oldLen = oldText.length + val newLen = newText.length + val delta = newLen - oldLen + + // Find insertion/deletion point + var changeStart = 0 + while (changeStart < minOf(oldLen, newLen) && + oldText.getOrNull(changeStart) == newText.getOrNull(changeStart)) { + changeStart++ } - // apply sticky styles to newly inserted char (simple heuristic) - val insertPos = newValue.selection.start - 1 - if (insertPos >= 0 && insertPos < newValue.text.length) { - activeStyles.forEach { st -> - builder.addStyle(st, insertPos, insertPos + 1) + val changeEnd = changeStart + maxOf(0, -delta) + + // Copy and adjust old spans + old.annotatedString.spanStyles.forEach { r -> + var start = r.start + var end = r.end + + // Adjust span positions based on where text changed + when { + // Span is completely before change + end <= changeStart -> { + // No adjustment needed + } + // Span is completely after change + start >= changeEnd -> { + start += delta + end += delta + } + // Span contains the change point + start < changeStart && end > changeEnd -> { + end += delta + } + // Span starts before change, ends in change area + start < changeStart && end in (changeStart + 1)..changeEnd -> { + end = changeStart + } + // Span starts in change area, ends after + start in changeStart until changeEnd && end >= changeEnd -> { + start = changeStart + delta + end += delta + } + // Span is completely inside change area - skip it + else -> return@forEach + } + + // Clamp to valid range + start = start.coerceIn(0, newLen) + end = end.coerceIn(0, newLen) + + if (start < end) { + builder.addStyle(r.item, start, end) + } + } + + // Apply sticky styles to newly inserted character(s) + if (delta > 0 && activeStyles.isNotEmpty()) { + val insertStart = changeStart + val insertEnd = (changeStart + delta).coerceIn(0, newLen) + if (insertStart < insertEnd) { + activeStyles.forEach { st -> + builder.addStyle(st, insertStart, insertEnd) + } } } @@ -109,7 +135,6 @@ class RichEditorState(initial: AnnotatedString) { private fun toggleStyle(style: SpanStyle) { val sel = value.selection.normalized() - snapshot() if (!sel.collapsed) applyStyleToSelection(style) else toggleTypingStyle(style) @@ -143,68 +168,14 @@ class RichEditorState(initial: AnnotatedString) { } /* ===================== - 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).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) { - 1 -> 28.sp - 2 -> 22.sp - 3 -> 18.sp - else -> return - } - - val builder = AnnotatedString.Builder(text) - 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()) - } - - 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 isBullet = text.startsWith(prefix, startIndex = lineStart) - - if (isBullet) { - replaceTextPreserveSpans( - start = lineStart, - end = lineStart + prefix.length, - replacement = "", - newCursor = (sel.start - prefix.length).coerceAtLeast(lineStart) - ) - } else { - replaceTextPreserveSpans( - start = lineStart, - end = lineStart, - replacement = prefix, - newCursor = sel.start + prefix.length - ) - } - } - - /* ===================== - AUTO-CONVERT MARKDOWN (LEVEL 3) + AUTO-CONVERT MARKDOWN ===================== */ private fun autoConvertMarkdown(v: TextFieldValue): TextFieldValue { var cur = v - // order matters: bold before italic + // Bold before italic to avoid conflicts cur = convertBold(cur) cur = convertItalic(cur) - cur = convertHeading(cur) - cur = convertDashBullet(cur) return cur } @@ -223,17 +194,17 @@ class RichEditorState(initial: AnnotatedString) { if (contentEnd <= contentStart) return v if (text.substring(contentStart, contentEnd).contains('\n')) return v - // remove end marker then start marker (preserve spans) + // Remove end marker then start marker var out = v out = replaceTextPreserveSpansLocal(out, cursor - 2, cursor, "") out = replaceTextPreserveSpansLocal(out, startMarker, startMarker + 2, "") - // after removing start marker, content shifts -2 + // Apply bold style val newStart = startMarker val newEnd = contentEnd - 2 out = addStylePreserve(out, SpanStyle(fontWeight = FontWeight.Bold), newStart, newEnd) - // cursor shifts back 4 chars total + // Adjust cursor out = out.copy(selection = TextRange((cursor - 4).coerceAtLeast(newEnd))) return out } @@ -243,13 +214,13 @@ class RichEditorState(initial: AnnotatedString) { val text = v.text val cursor = v.selection.start if (cursor < 1) return v - // avoid triggering on bold closing (**) + // 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 + // Avoid ** as start if (text.getOrNull(startMarker - 1) == '*') return v val contentStart = startMarker + 1 @@ -268,74 +239,6 @@ class RichEditorState(initial: AnnotatedString) { 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 ===================== */ @@ -368,11 +271,6 @@ class RichEditorState(initial: AnnotatedString) { /* ===================== 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, @@ -392,7 +290,7 @@ class RichEditorState(initial: AnnotatedString) { var rs = r.start var re = r.end - // adjust spans + // Adjust spans when { re <= s -> Unit rs >= e -> { rs += delta; re += delta } @@ -431,4 +329,4 @@ private fun SpanStyle.hasSameStyle(other: SpanStyle): Boolean = textDecoration == other.textDecoration private fun TextRange.normalized(): TextRange = - if (start <= end) this else TextRange(end, start) + if (start <= end) this else TextRange(end, start) \ No newline at end of file