Compare commits

..

No commits in common. "9715d958ae2a439b55a08115411397f545aa7c4d" and "3749b7ff0764de1b8be92bacb98bc9954e29d048" have entirely different histories.

3 changed files with 166 additions and 124 deletions

View File

@ -264,9 +264,6 @@ 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
@ -295,8 +292,6 @@ 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 = {
@ -307,16 +302,9 @@ fun EditableFullScreenNoteView(
ensureFocus() ensureFocus()
editorState.toggleItalic() editorState.toggleItalic()
}, },
onUnderline = { onUnderline = { ensureFocus(); editorState.toggleUnderline() },
ensureFocus() onHeading = { ensureFocus(); editorState.setHeading(1) },
editorState.toggleUnderline() onBullet = { ensureFocus(); editorState.toggleBulletList() }
},
onUndo = {
editorState.undo()
},
onRedo = {
editorState.redo()
}
) )
} }
} }

View File

@ -25,15 +25,13 @@ 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,
onUndo: () -> Unit = {}, onBullet: () -> Unit
onRedo: () -> Unit = {}
) { ) {
Surface( Surface(
modifier = modifier, modifier = modifier,
@ -85,27 +83,17 @@ fun DraggableMiniMarkdownToolbar(
onClick = onUnderline onClick = onUnderline
) )
// Divider ToolbarIcon(
Box( icon = Icons.Default.Title,
modifier = Modifier onClick = onHeading
.width(1.dp)
.height(24.dp)
.background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f))
) )
// Undo
ToolbarIcon( ToolbarIcon(
icon = Icons.Default.Undo, icon = Icons.Default.FormatListBulleted,
onClick = onUndo, onClick = onBullet
enabled = canUndo
) )
// Redo
ToolbarIcon(
icon = Icons.Default.Redo,
onClick = onRedo,
enabled = canRedo
)
} }
} }
} }
@ -114,12 +102,10 @@ 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
@ -132,17 +118,12 @@ 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 = when { tint = if (isActive) activeColor else MaterialTheme.colorScheme.onSurface,
!enabled -> disabledColor
isActive -> activeColor
else -> MaterialTheme.colorScheme.onSurface
},
modifier = Modifier.size(18.dp) modifier = Modifier.size(18.dp)
) )
} }

View File

@ -6,6 +6,7 @@ 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) {
@ -34,13 +35,13 @@ class RichEditorState(initial: AnnotatedString) {
fun undo() { fun undo() {
if (!canUndo()) return if (!canUndo()) return
redoStack.add(value) redoStack.add(value)
value = undoStack.removeAt(undoStack.lastIndex) value = undoStack.removeLast()
} }
fun redo() { fun redo() {
if (!canRedo()) return if (!canRedo()) return
undoStack.add(value) undoStack.add(value)
value = redoStack.removeAt(redoStack.lastIndex) value = redoStack.removeLast()
} }
/* ===================== /* =====================
@ -49,7 +50,7 @@ class RichEditorState(initial: AnnotatedString) {
private val activeStyles = mutableStateListOf<SpanStyle>() private val activeStyles = mutableStateListOf<SpanStyle>()
/* ===================== /* =====================
VALUE CHANGE VALUE CHANGE (KEY)
===================== */ ===================== */
fun onValueChange(newValue: TextFieldValue) { fun onValueChange(newValue: TextFieldValue) {
val old = value val old = value
@ -65,10 +66,10 @@ class RichEditorState(initial: AnnotatedString) {
snapshot() snapshot()
// Build new annotated string by preserving old spans // 1) build new annotated string by preserving old spans
val built = buildPreservingSpans(old, newValue) val built = buildPreservingSpans(old, newValue)
// Auto-convert markdown patterns // 2) auto-convert markdown patterns around cursor
val converted = autoConvertMarkdown(built) val converted = autoConvertMarkdown(built)
value = converted value = converted
@ -77,73 +78,18 @@ 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)
val oldText = old.text // copy old spans (clamped)
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++
}
val changeEnd = changeStart + maxOf(0, -delta)
// Copy and adjust old spans
old.annotatedString.spanStyles.forEach { r -> old.annotatedString.spanStyles.forEach { r ->
var start = r.start val s = r.start.coerceIn(0, newValue.text.length)
var end = r.end val e = r.end.coerceIn(0, newValue.text.length)
if (s < e) builder.addStyle(r.item, s, e)
// 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 // apply sticky styles to newly inserted char (simple heuristic)
start = start.coerceIn(0, newLen) val insertPos = newValue.selection.start - 1
end = end.coerceIn(0, newLen) if (insertPos >= 0 && insertPos < newValue.text.length) {
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 -> activeStyles.forEach { st ->
builder.addStyle(st, insertStart, insertEnd) builder.addStyle(st, insertPos, insertPos + 1)
}
} }
} }
@ -197,14 +143,68 @@ class RichEditorState(initial: AnnotatedString) {
} }
/* ===================== /* =====================
AUTO-CONVERT MARKDOWN 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)
===================== */ ===================== */
private fun autoConvertMarkdown(v: TextFieldValue): TextFieldValue { private fun autoConvertMarkdown(v: TextFieldValue): TextFieldValue {
var cur = v var cur = v
// Bold before italic to avoid conflicts // order matters: bold before italic
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 // remove end marker then start marker (preserve spans)
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, "")
// Apply bold style // after removing start marker, content shifts -2
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)
// Adjust cursor // cursor shifts back 4 chars total
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,6 +268,74 @@ 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
===================== */ ===================== */
@ -300,6 +368,11 @@ 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,
@ -319,7 +392,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 }