Serializer untuk AnnotatedString agar Markdown tampilannya tetap pada Notes setelah di save
This commit is contained in:
parent
7be456d7cb
commit
7f5e2fd28d
@ -23,6 +23,7 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.graphics.SolidColor // ✅ ADD
|
import androidx.compose.ui.graphics.SolidColor // ✅ ADD
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.platform.*
|
import androidx.compose.ui.platform.*
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.*
|
import androidx.compose.ui.unit.*
|
||||||
import com.example.notesai.data.model.Note
|
import com.example.notesai.data.model.Note
|
||||||
@ -53,9 +54,12 @@ fun EditableFullScreenNoteView(
|
|||||||
var isContentFocused by remember { mutableStateOf(false) }
|
var isContentFocused by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val editorState = remember(note.id) {
|
val editorState = remember(note.id) {
|
||||||
RichEditorState(MarkdownParser.parse(note.content))
|
RichEditorState(
|
||||||
|
AnnotatedStringSerializer.fromJson(note.content)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
val bringIntoViewRequester = remember { BringIntoViewRequester() }
|
val bringIntoViewRequester = remember { BringIntoViewRequester() }
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
@ -71,7 +75,7 @@ fun EditableFullScreenNoteView(
|
|||||||
if (title.isNotBlank()) {
|
if (title.isNotBlank()) {
|
||||||
onSave(
|
onSave(
|
||||||
title,
|
title,
|
||||||
MarkdownSerializer.toMarkdown(editorState.value.annotatedString)
|
AnnotatedStringSerializer.toJson(editorState.value.annotatedString)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -283,8 +287,8 @@ fun EditableFullScreenNoteView(
|
|||||||
editorState.toggleItalic()
|
editorState.toggleItalic()
|
||||||
},
|
},
|
||||||
onUnderline = { ensureFocus(); editorState.toggleUnderline() },
|
onUnderline = { ensureFocus(); editorState.toggleUnderline() },
|
||||||
onHeading = { editorState.setHeading(1) },
|
onHeading = { ensureFocus(); editorState.setHeading(1) }, // sementara H1
|
||||||
onBullet = { editorState.toggleBulletList() }
|
onBullet = { ensureFocus(); editorState.toggleBulletList() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -31,7 +31,7 @@ fun DraggableMiniMarkdownToolbar(
|
|||||||
onItalic: () -> Unit,
|
onItalic: () -> Unit,
|
||||||
onHeading: () -> Unit,
|
onHeading: () -> Unit,
|
||||||
onUnderline: () -> Unit,
|
onUnderline: () -> Unit,
|
||||||
onBullet: () -> Unit,
|
onBullet: () -> Unit
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
@ -93,6 +93,7 @@ fun DraggableMiniMarkdownToolbar(
|
|||||||
onClick = onBullet
|
onClick = onBullet
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,15 +18,44 @@ class RichEditorState(initial: AnnotatedString) {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
// 🔥 active typing styles (sticky)
|
/* =====================
|
||||||
|
UNDO / REDO
|
||||||
|
===================== */
|
||||||
|
private val undoStack = mutableStateListOf<TextFieldValue>()
|
||||||
|
private val redoStack = mutableStateListOf<TextFieldValue>()
|
||||||
|
|
||||||
|
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<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
|
||||||
|
|
||||||
|
// cursor/selection change only
|
||||||
if (newValue.text == old.text) {
|
if (newValue.text == old.text) {
|
||||||
value = old.copy(
|
value = old.copy(
|
||||||
selection = newValue.selection,
|
selection = newValue.selection,
|
||||||
@ -35,24 +64,36 @@ class RichEditorState(initial: AnnotatedString) {
|
|||||||
return
|
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)
|
val builder = AnnotatedString.Builder(newValue.text)
|
||||||
|
|
||||||
// copy old spans
|
// copy old spans (clamped)
|
||||||
old.annotatedString.spanStyles.forEach {
|
old.annotatedString.spanStyles.forEach { r ->
|
||||||
val s = it.start.coerceAtMost(newValue.text.length)
|
val s = r.start.coerceIn(0, newValue.text.length)
|
||||||
val e = it.end.coerceAtMost(newValue.text.length)
|
val e = r.end.coerceIn(0, newValue.text.length)
|
||||||
if (s < e) builder.addStyle(it.item, s, e)
|
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
|
val insertPos = newValue.selection.start - 1
|
||||||
if (insertPos >= 0) {
|
if (insertPos >= 0 && insertPos < newValue.text.length) {
|
||||||
activeStyles.forEach {
|
activeStyles.forEach { st ->
|
||||||
builder.addStyle(it, insertPos, insertPos + 1)
|
builder.addStyle(st, insertPos, insertPos + 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
value = TextFieldValue(
|
return TextFieldValue(
|
||||||
annotatedString = builder.toAnnotatedString(),
|
annotatedString = builder.toAnnotatedString(),
|
||||||
selection = newValue.selection,
|
selection = newValue.selection,
|
||||||
composition = newValue.composition
|
composition = newValue.composition
|
||||||
@ -60,30 +101,25 @@ class RichEditorState(initial: AnnotatedString) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* =====================
|
/* =====================
|
||||||
TOGGLE STYLES
|
TOOLBAR TOGGLES
|
||||||
===================== */
|
===================== */
|
||||||
|
fun toggleBold() = toggleStyle(SpanStyle(fontWeight = FontWeight.Bold))
|
||||||
fun toggleBold() =
|
fun toggleItalic() = toggleStyle(SpanStyle(fontStyle = FontStyle.Italic))
|
||||||
toggleStyle(SpanStyle(fontWeight = FontWeight.Bold))
|
fun toggleUnderline() = toggleStyle(SpanStyle(textDecoration = TextDecoration.Underline))
|
||||||
|
|
||||||
fun toggleItalic() =
|
|
||||||
toggleStyle(SpanStyle(fontStyle = FontStyle.Italic))
|
|
||||||
|
|
||||||
fun toggleUnderline() =
|
|
||||||
toggleStyle(SpanStyle(textDecoration = TextDecoration.Underline))
|
|
||||||
|
|
||||||
private fun toggleStyle(style: SpanStyle) {
|
private fun toggleStyle(style: SpanStyle) {
|
||||||
val sel = value.selection.normalized()
|
val sel = value.selection.normalized()
|
||||||
if (!sel.collapsed) {
|
snapshot()
|
||||||
applyStyleToSelection(style)
|
|
||||||
} else {
|
if (!sel.collapsed) applyStyleToSelection(style)
|
||||||
toggleTypingStyle(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) {
|
private fun applyStyleToSelection(style: SpanStyle) {
|
||||||
val sel = value.selection.normalized()
|
val sel = value.selection.normalized()
|
||||||
val start = sel.start
|
val start = sel.start
|
||||||
@ -92,12 +128,10 @@ class RichEditorState(initial: AnnotatedString) {
|
|||||||
|
|
||||||
val builder = AnnotatedString.Builder(value.text)
|
val builder = AnnotatedString.Builder(value.text)
|
||||||
|
|
||||||
value.annotatedString.spanStyles.forEach {
|
value.annotatedString.spanStyles.forEach { r ->
|
||||||
val overlap = it.start < end && it.end > start
|
val overlap = r.start < end && r.end > start
|
||||||
val same = it.item.hasSameStyle(style)
|
val same = r.item.hasSameStyle(style)
|
||||||
if (!(overlap && same)) {
|
if (!(overlap && same)) builder.addStyle(r.item, r.start, r.end)
|
||||||
builder.addStyle(it.item, it.start, it.end)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
builder.addStyle(style, start, end)
|
builder.addStyle(style, start, end)
|
||||||
@ -109,22 +143,14 @@ class RichEditorState(initial: AnnotatedString) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* =====================
|
/* =====================
|
||||||
TYPING MODE
|
HEADING / BULLET (for toolbar)
|
||||||
===================== */
|
|
||||||
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)
|
|
||||||
===================== */
|
===================== */
|
||||||
fun setHeading(level: Int) {
|
fun setHeading(level: Int) {
|
||||||
|
snapshot()
|
||||||
|
|
||||||
val sel = value.selection
|
val sel = value.selection
|
||||||
val text = value.text
|
val text = value.text
|
||||||
|
val lineStart = text.lastIndexOf('\n', (sel.start - 1).coerceAtLeast(0)).let { if (it == -1) 0 else it + 1 }
|
||||||
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 lineEnd = text.indexOf('\n', sel.start).let { if (it == -1) text.length else it }
|
||||||
|
|
||||||
val size = when (level) {
|
val size = when (level) {
|
||||||
@ -135,113 +161,184 @@ class RichEditorState(initial: AnnotatedString) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val builder = AnnotatedString.Builder(text)
|
val builder = AnnotatedString.Builder(text)
|
||||||
|
value.annotatedString.spanStyles.forEach { r -> builder.addStyle(r.item, r.start, r.end) }
|
||||||
value.annotatedString.spanStyles.forEach {
|
builder.addStyle(SpanStyle(fontSize = size, fontWeight = FontWeight.Bold), lineStart, lineEnd)
|
||||||
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())
|
value = value.copy(annotatedString = builder.toAnnotatedString())
|
||||||
}
|
}
|
||||||
|
|
||||||
/* =====================
|
|
||||||
LIST (simple bullet)
|
|
||||||
===================== */
|
|
||||||
fun toggleBulletList() {
|
fun toggleBulletList() {
|
||||||
|
snapshot()
|
||||||
|
|
||||||
val sel = value.selection
|
val sel = value.selection
|
||||||
val text = value.text
|
val text = value.text
|
||||||
val prefix = "• "
|
val prefix = "• "
|
||||||
|
|
||||||
val lineStart = text.lastIndexOf('\n', (sel.start - 1).coerceAtLeast(0))
|
val lineStart = text.lastIndexOf('\n', (sel.start - 1).coerceAtLeast(0)).let { if (it == -1) 0 else it + 1 }
|
||||||
.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)
|
val isBullet = text.startsWith(prefix, startIndex = lineStart)
|
||||||
|
|
||||||
if (isBullet) {
|
if (isBullet) {
|
||||||
// REMOVE bullet prefix
|
replaceTextPreserveSpans(
|
||||||
val newText = text.removeRange(lineStart, lineStart + prefix.length)
|
start = lineStart,
|
||||||
val newAnnotated = rebuildWithDelta(
|
end = lineStart + prefix.length,
|
||||||
newText = newText,
|
replacement = "",
|
||||||
changeStart = lineStart,
|
newCursor = (sel.start - prefix.length).coerceAtLeast(lineStart)
|
||||||
delta = -prefix.length,
|
|
||||||
removedLen = prefix.length
|
|
||||||
)
|
|
||||||
|
|
||||||
val newCursor = (sel.start - prefix.length).coerceAtLeast(lineStart)
|
|
||||||
value = value.copy(
|
|
||||||
annotatedString = newAnnotated,
|
|
||||||
selection = TextRange(newCursor)
|
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// INSERT bullet prefix
|
replaceTextPreserveSpans(
|
||||||
val newText = text.substring(0, lineStart) + prefix + text.substring(lineStart)
|
start = lineStart,
|
||||||
val newAnnotated = rebuildWithDelta(
|
end = lineStart,
|
||||||
newText = newText,
|
replacement = prefix,
|
||||||
changeStart = lineStart,
|
newCursor = sel.start + prefix.length
|
||||||
delta = prefix.length,
|
|
||||||
removedLen = 0
|
|
||||||
)
|
|
||||||
|
|
||||||
val newCursor = sel.start + prefix.length
|
|
||||||
value = value.copy(
|
|
||||||
annotatedString = newAnnotated,
|
|
||||||
selection = TextRange(newCursor)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* =====================
|
||||||
|
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
|
TOOLBAR STATE
|
||||||
===================== */
|
===================== */
|
||||||
|
|
||||||
fun isBoldActive() = isStyleActive(fontWeight = FontWeight.Bold)
|
fun isBoldActive() = isStyleActive(fontWeight = FontWeight.Bold)
|
||||||
fun isItalicActive() = isStyleActive(fontStyle = FontStyle.Italic)
|
fun isItalicActive() = isStyleActive(fontStyle = FontStyle.Italic)
|
||||||
fun isUnderlineActive() = isStyleActive(decoration = TextDecoration.Underline)
|
fun isUnderlineActive() = isStyleActive(decoration = TextDecoration.Underline)
|
||||||
@ -267,12 +364,67 @@ class RichEditorState(initial: AnnotatedString) {
|
|||||||
(decoration == null || it.textDecoration == decoration)
|
(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
|
HELPERS
|
||||||
===================== */
|
===================== */
|
||||||
|
|
||||||
private fun SpanStyle.hasSameStyle(other: SpanStyle): Boolean =
|
private fun SpanStyle.hasSameStyle(other: SpanStyle): Boolean =
|
||||||
fontWeight == other.fontWeight &&
|
fontWeight == other.fontWeight &&
|
||||||
fontStyle == other.fontStyle &&
|
fontStyle == other.fontStyle &&
|
||||||
|
|||||||
@ -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<SpanDto>
|
||||||
|
)
|
||||||
|
|
||||||
|
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<RichTextDto>(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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user