Compare commits

...

2 Commits

Author SHA1 Message Date
9715d958ae Fitur undo redo 2025-12-25 17:25:33 +07:00
a61c5f45ad Delete Fitur Heading dan Bullet List 2025-12-25 17:06:00 +07:00
3 changed files with 125 additions and 167 deletions

View File

@ -264,6 +264,9 @@ fun EditableFullScreenNoteView(
} }
} }
// GANTI SELURUH bagian DraggableMiniMarkdownToolbar di EditableFullScreenNoteView.kt
// Letakkan kode ini di dalam Box setelah Scaffold, sebelum closing bracket
if (isContentFocused) { if (isContentFocused) {
DraggableMiniMarkdownToolbar( DraggableMiniMarkdownToolbar(
modifier = Modifier modifier = Modifier
@ -292,6 +295,8 @@ fun EditableFullScreenNoteView(
isBoldActive = editorState.isBoldActive(), isBoldActive = editorState.isBoldActive(),
isItalicActive = editorState.isItalicActive(), isItalicActive = editorState.isItalicActive(),
isUnderlineActive = editorState.isUnderlineActive(), isUnderlineActive = editorState.isUnderlineActive(),
canUndo = editorState.canUndo(),
canRedo = editorState.canRedo(),
onDrag = ::moveToolbar, onDrag = ::moveToolbar,
onBold = { onBold = {
@ -302,9 +307,16 @@ fun EditableFullScreenNoteView(
ensureFocus() ensureFocus()
editorState.toggleItalic() editorState.toggleItalic()
}, },
onUnderline = { ensureFocus(); editorState.toggleUnderline() }, onUnderline = {
onHeading = { ensureFocus(); editorState.setHeading(1) }, ensureFocus()
onBullet = { ensureFocus(); editorState.toggleBulletList() } editorState.toggleUnderline()
},
onUndo = {
editorState.undo()
},
onRedo = {
editorState.redo()
}
) )
} }
} }

View File

@ -25,13 +25,15 @@ fun DraggableMiniMarkdownToolbar(
isBoldActive: Boolean, isBoldActive: Boolean,
isItalicActive: Boolean, isItalicActive: Boolean,
isUnderlineActive: Boolean, isUnderlineActive: Boolean,
canUndo: Boolean = false,
canRedo: Boolean = false,
// ACTIONS // ACTIONS
onBold: () -> Unit, onBold: () -> Unit,
onItalic: () -> Unit, onItalic: () -> Unit,
onHeading: () -> Unit,
onUnderline: () -> Unit, onUnderline: () -> Unit,
onBullet: () -> Unit onUndo: () -> Unit = {},
onRedo: () -> Unit = {}
) { ) {
Surface( Surface(
modifier = modifier, modifier = modifier,
@ -83,17 +85,27 @@ fun DraggableMiniMarkdownToolbar(
onClick = onUnderline onClick = onUnderline
) )
ToolbarIcon( // Divider
icon = Icons.Default.Title, Box(
onClick = onHeading modifier = Modifier
.width(1.dp)
.height(24.dp)
.background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f))
) )
// Undo
ToolbarIcon( ToolbarIcon(
icon = Icons.Default.FormatListBulleted, icon = Icons.Default.Undo,
onClick = onBullet onClick = onUndo,
enabled = canUndo
) )
// Redo
ToolbarIcon(
icon = Icons.Default.Redo,
onClick = onRedo,
enabled = canRedo
)
} }
} }
} }
@ -102,10 +114,12 @@ fun DraggableMiniMarkdownToolbar(
private fun ToolbarIcon( private fun ToolbarIcon(
icon: androidx.compose.ui.graphics.vector.ImageVector, icon: androidx.compose.ui.graphics.vector.ImageVector,
onClick: () -> Unit, onClick: () -> Unit,
isActive: Boolean = false isActive: Boolean = false,
enabled: Boolean = true
) { ) {
val activeBg = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f) val activeBg = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
val activeColor = MaterialTheme.colorScheme.primary val activeColor = MaterialTheme.colorScheme.primary
val disabledColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f)
Box( Box(
modifier = Modifier modifier = Modifier
@ -118,14 +132,19 @@ private fun ToolbarIcon(
) { ) {
IconButton( IconButton(
onClick = onClick, onClick = onClick,
modifier = Modifier.size(36.dp) modifier = Modifier.size(36.dp),
enabled = enabled
) { ) {
Icon( Icon(
icon, icon,
contentDescription = null, contentDescription = null,
tint = if (isActive) activeColor else MaterialTheme.colorScheme.onSurface, tint = when {
!enabled -> disabledColor
isActive -> activeColor
else -> MaterialTheme.colorScheme.onSurface
},
modifier = Modifier.size(18.dp) modifier = Modifier.size(18.dp)
) )
} }
} }
} }

View File

@ -6,7 +6,6 @@ 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.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) {
@ -35,13 +34,13 @@ class RichEditorState(initial: AnnotatedString) {
fun undo() { fun undo() {
if (!canUndo()) return if (!canUndo()) return
redoStack.add(value) redoStack.add(value)
value = undoStack.removeLast() value = undoStack.removeAt(undoStack.lastIndex)
} }
fun redo() { fun redo() {
if (!canRedo()) return if (!canRedo()) return
undoStack.add(value) undoStack.add(value)
value = redoStack.removeLast() value = redoStack.removeAt(redoStack.lastIndex)
} }
/* ===================== /* =====================
@ -50,7 +49,7 @@ class RichEditorState(initial: AnnotatedString) {
private val activeStyles = mutableStateListOf<SpanStyle>() private val activeStyles = mutableStateListOf<SpanStyle>()
/* ===================== /* =====================
VALUE CHANGE (KEY) VALUE CHANGE
===================== */ ===================== */
fun onValueChange(newValue: TextFieldValue) { fun onValueChange(newValue: TextFieldValue) {
val old = value val old = value
@ -66,10 +65,10 @@ class RichEditorState(initial: AnnotatedString) {
snapshot() snapshot()
// 1) build new annotated string by preserving old spans // Build new annotated string by preserving old spans
val built = buildPreservingSpans(old, newValue) val built = buildPreservingSpans(old, newValue)
// 2) auto-convert markdown patterns around cursor // Auto-convert markdown patterns
val converted = autoConvertMarkdown(built) val converted = autoConvertMarkdown(built)
value = converted value = converted
@ -78,18 +77,73 @@ class RichEditorState(initial: AnnotatedString) {
private fun buildPreservingSpans(old: TextFieldValue, newValue: TextFieldValue): TextFieldValue { private fun buildPreservingSpans(old: TextFieldValue, newValue: TextFieldValue): TextFieldValue {
val builder = AnnotatedString.Builder(newValue.text) val builder = AnnotatedString.Builder(newValue.text)
// copy old spans (clamped) val oldText = old.text
old.annotatedString.spanStyles.forEach { r -> val newText = newValue.text
val s = r.start.coerceIn(0, newValue.text.length)
val e = r.end.coerceIn(0, newValue.text.length) // Detect where text was inserted/deleted
if (s < e) builder.addStyle(r.item, s, e) 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 changeEnd = changeStart + maxOf(0, -delta)
val insertPos = newValue.selection.start - 1
if (insertPos >= 0 && insertPos < newValue.text.length) { // Copy and adjust old spans
activeStyles.forEach { st -> old.annotatedString.spanStyles.forEach { r ->
builder.addStyle(st, insertPos, insertPos + 1) 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)
}
} }
} }
@ -143,68 +197,14 @@ class RichEditorState(initial: AnnotatedString) {
} }
/* ===================== /* =====================
HEADING / BULLET (for toolbar) AUTO-CONVERT MARKDOWN
===================== */
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)
===================== */ ===================== */
private fun autoConvertMarkdown(v: TextFieldValue): TextFieldValue { private fun autoConvertMarkdown(v: TextFieldValue): TextFieldValue {
var cur = v var cur = v
// order matters: bold before italic // Bold before italic to avoid conflicts
cur = convertBold(cur) cur = convertBold(cur)
cur = convertItalic(cur) cur = convertItalic(cur)
cur = convertHeading(cur)
cur = convertDashBullet(cur)
return cur return cur
} }
@ -223,17 +223,17 @@ class RichEditorState(initial: AnnotatedString) {
if (contentEnd <= contentStart) return v if (contentEnd <= contentStart) return v
if (text.substring(contentStart, contentEnd).contains('\n')) 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 var out = v
out = replaceTextPreserveSpansLocal(out, cursor - 2, cursor, "") out = replaceTextPreserveSpansLocal(out, cursor - 2, cursor, "")
out = replaceTextPreserveSpansLocal(out, startMarker, startMarker + 2, "") out = replaceTextPreserveSpansLocal(out, startMarker, startMarker + 2, "")
// after removing start marker, content shifts -2 // Apply bold style
val newStart = startMarker val newStart = startMarker
val newEnd = contentEnd - 2 val newEnd = contentEnd - 2
out = addStylePreserve(out, SpanStyle(fontWeight = FontWeight.Bold), newStart, newEnd) 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))) out = out.copy(selection = TextRange((cursor - 4).coerceAtLeast(newEnd)))
return out return out
} }
@ -243,13 +243,13 @@ class RichEditorState(initial: AnnotatedString) {
val text = v.text val text = v.text
val cursor = v.selection.start val cursor = v.selection.start
if (cursor < 1) return v 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 - 1) != '*') return v
if (text.getOrNull(cursor - 2) == '*') return v if (text.getOrNull(cursor - 2) == '*') return v
val startMarker = text.lastIndexOf('*', startIndex = cursor - 2) val startMarker = text.lastIndexOf('*', startIndex = cursor - 2)
if (startMarker == -1) return v if (startMarker == -1) return v
// avoid ** as start // Avoid ** as start
if (text.getOrNull(startMarker - 1) == '*') return v if (text.getOrNull(startMarker - 1) == '*') return v
val contentStart = startMarker + 1 val contentStart = startMarker + 1
@ -268,74 +268,6 @@ class RichEditorState(initial: AnnotatedString) {
return out 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 TOOLBAR STATE
===================== */ ===================== */
@ -368,11 +300,6 @@ class RichEditorState(initial: AnnotatedString) {
/* ===================== /* =====================
INTERNAL: text replace while preserving spans 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( private fun replaceTextPreserveSpansLocal(
v: TextFieldValue, v: TextFieldValue,
start: Int, start: Int,
@ -392,7 +319,7 @@ class RichEditorState(initial: AnnotatedString) {
var rs = r.start var rs = r.start
var re = r.end var re = r.end
// adjust spans // Adjust spans
when { when {
re <= s -> Unit re <= s -> Unit
rs >= e -> { rs += delta; re += delta } rs >= e -> { rs += delta; re += delta }
@ -431,4 +358,4 @@ private fun SpanStyle.hasSameStyle(other: SpanStyle): Boolean =
textDecoration == other.textDecoration 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)