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 1b65754..64166be 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 @@ -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() } ) } } 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 ad578e9..093bf62 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 @@ -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 - ) } } } 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 278f412..a581842 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 @@ -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() - /* --------------------------- - 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) + } + + /* ===================== + 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) { - activeStyles.removeAt(index) + 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 { + // 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 { - 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 - --------------------------- */ + ===================== */ - 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)