capacitor-sora-editor 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1016 @@
1
+ package com.github.soraeditor.capacitor.ui
2
+
3
+ import android.graphics.Typeface
4
+ import android.view.View
5
+ import android.view.GestureDetector
6
+ import android.view.MotionEvent
7
+ import androidx.compose.animation.*
8
+ import androidx.compose.foundation.background
9
+ import androidx.compose.foundation.clickable
10
+ import androidx.compose.foundation.layout.*
11
+ import androidx.compose.foundation.shape.RoundedCornerShape
12
+ import androidx.compose.material.icons.Icons
13
+ import androidx.compose.material.icons.filled.*
14
+ import androidx.compose.material3.*
15
+ import androidx.compose.runtime.*
16
+ import androidx.compose.ui.Alignment
17
+ import androidx.compose.ui.Modifier
18
+ import androidx.compose.ui.graphics.Color
19
+ import androidx.compose.ui.unit.dp
20
+ import androidx.compose.ui.unit.sp
21
+ import androidx.compose.ui.viewinterop.AndroidView
22
+ import androidx.compose.foundation.rememberScrollState
23
+ import androidx.compose.foundation.verticalScroll
24
+ import androidx.compose.foundation.BorderStroke
25
+ import androidx.compose.ui.graphics.graphicsLayer
26
+ import androidx.compose.ui.input.pointer.pointerInput
27
+ import androidx.compose.foundation.gestures.detectDragGestures
28
+ import androidx.compose.foundation.border
29
+ import kotlinx.coroutines.launch
30
+ import com.github.soraeditor.capacitor.EditorUiState
31
+ import com.github.soraeditor.capacitor.EditorViewModel
32
+ import io.github.rosemoe.sora.widget.CodeEditor
33
+ import io.github.rosemoe.sora.widget.schemes.EditorColorScheme
34
+
35
+ class EditorControl {
36
+ private var editor: CodeEditor? = null
37
+
38
+ fun attach(editor: CodeEditor) {
39
+ this.editor = editor
40
+ }
41
+
42
+ fun jumpTo(pos: Int) {
43
+ if (editor == null) return
44
+ editor?.let {
45
+ if (androidx.core.view.ViewCompat.isLaidOut(it)) {
46
+ performJump(it, pos)
47
+ } else {
48
+ it.addOnLayoutChangeListener(object : View.OnLayoutChangeListener {
49
+ override fun onLayoutChange(v: View?, left: Int, top: Int, right: Int, bottom: Int, oldLeft: Int, oldTop: Int, oldRight: Int, oldBottom: Int) {
50
+ it.removeOnLayoutChangeListener(this)
51
+ performJump(it, pos)
52
+ }
53
+ })
54
+ }
55
+ }
56
+ }
57
+
58
+ fun getCurrentCursorPosition(): Int {
59
+ val editor = this.editor ?: return 0
60
+ try {
61
+ val cursor = editor.cursor
62
+ val targetLine = cursor.leftLine
63
+ val targetCol = cursor.leftColumn
64
+ val text = editor.text.toString()
65
+
66
+ var idx = 0
67
+ var curLine = 0
68
+ val len = text.length
69
+
70
+ while (idx < len && curLine < targetLine) {
71
+ if (text[idx] == '\n') {
72
+ curLine++
73
+ }
74
+ idx++
75
+ }
76
+ return (idx + targetCol).coerceAtMost(len)
77
+ } catch (e: Exception) {
78
+ e.printStackTrace()
79
+ return 0
80
+ }
81
+ }
82
+
83
+ private fun performJump(it: CodeEditor, pos: Int) {
84
+ try {
85
+ it.requestFocus()
86
+ val text = it.text.toString()
87
+ val safePos = pos.coerceIn(0, text.length)
88
+
89
+ var line = 0
90
+ var col = 0
91
+ for (i in 0 until safePos) {
92
+ if (text[i] == '\n') {
93
+ line++
94
+ col = 0
95
+ } else {
96
+ col++
97
+ }
98
+ }
99
+
100
+ it.setSelection(line, col)
101
+ it.ensureSelectionVisible()
102
+ } catch (e: Exception) {
103
+ e.printStackTrace()
104
+ }
105
+ }
106
+
107
+ fun search(text: String) {
108
+ try {
109
+ editor?.searcher?.search(text, io.github.rosemoe.sora.widget.EditorSearcher.SearchOptions(false, false))
110
+ } catch (e: Exception) {
111
+ e.printStackTrace()
112
+ }
113
+ }
114
+
115
+ fun findNext() {
116
+ try {
117
+ editor?.searcher?.gotoNext()
118
+ } catch (e: Exception) {
119
+ e.printStackTrace()
120
+ }
121
+ }
122
+
123
+ fun findPrevious() {
124
+ try {
125
+ editor?.searcher?.gotoPrevious()
126
+ } catch (e: Exception) {
127
+ e.printStackTrace()
128
+ }
129
+ }
130
+
131
+ fun replace(text: String) {
132
+ try {
133
+ editor?.searcher?.replaceThis(text)
134
+ } catch (e: Exception) {
135
+ e.printStackTrace()
136
+ }
137
+ }
138
+
139
+ fun replaceAll(text: String) {
140
+ try {
141
+ editor?.searcher?.replaceAll(text)
142
+ } catch (e: Exception) {
143
+ e.printStackTrace()
144
+ }
145
+ }
146
+
147
+ fun stopSearch() {
148
+ try {
149
+ editor?.searcher?.stopSearch()
150
+ } catch (e: Exception) {
151
+ e.printStackTrace()
152
+ }
153
+ }
154
+
155
+ fun undo() {
156
+ editor?.undo()
157
+ }
158
+
159
+ fun redo() {
160
+ editor?.redo()
161
+ }
162
+
163
+ fun canUndo(): Boolean = editor?.canUndo() ?: false
164
+ fun canRedo(): Boolean = editor?.canRedo() ?: false
165
+
166
+ fun insertText(text: String) {
167
+ editor?.insertText(text, text.length)
168
+ }
169
+ }
170
+
171
+ @Composable
172
+ fun SoraEditorView(
173
+ content: String,
174
+ onContentChange: (String) -> Unit,
175
+ onSelectionChange: (Int, Int, Int) -> Unit = { _, _, _ -> },
176
+ fontSize: Float = 18f,
177
+ showLineNumbers: Boolean = true,
178
+ wordWrap: Boolean = false,
179
+ editable: Boolean = true,
180
+ backgroundColor: String = "#FFFFFF",
181
+ modifier: Modifier = Modifier,
182
+ control: EditorControl? = null,
183
+ onSearchMatchesChange: (Int, Int) -> Unit = { _, _ -> },
184
+ onScroll: () -> Unit = {},
185
+ onTap: () -> Unit = {}
186
+ ) {
187
+ var editorInstance by remember { mutableStateOf<CodeEditor?>(null) }
188
+
189
+ // Ensure we always have the latest callbacks even if factory is not re-run
190
+ val currentOnTap by rememberUpdatedState(onTap)
191
+ val currentOnContentChange by rememberUpdatedState(onContentChange)
192
+ val currentOnSelectionChange by rememberUpdatedState(onSelectionChange)
193
+
194
+ LaunchedEffect(editorInstance, control) {
195
+ editorInstance?.let { control?.attach(it) }
196
+ }
197
+
198
+ AndroidView(
199
+ factory = { context ->
200
+ CodeEditor(context).apply {
201
+ setTextSize(fontSize)
202
+ setTypefaceText(Typeface.MONOSPACE)
203
+ isLineNumberEnabled = showLineNumbers
204
+ isWordwrap = wordWrap
205
+ setEditable(editable)
206
+ setText(content)
207
+
208
+ // Use GestureDetector for reliable tap detection
209
+ val gestureDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
210
+ override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
211
+ currentOnTap()
212
+ return true
213
+ }
214
+ })
215
+ setOnTouchListener { _, event ->
216
+ gestureDetector.onTouchEvent(event)
217
+ false // Return false so the editor still receives touch events for selection/scrolling
218
+ }
219
+
220
+ subscribeEvent(io.github.rosemoe.sora.event.SelectionChangeEvent::class.java) { _, _ ->
221
+ val cursor = this.cursor
222
+ val line = cursor.leftLine
223
+ val col = cursor.leftColumn
224
+
225
+ val textStr = text.toString()
226
+ var charPos = 0
227
+ try {
228
+ var curL = 0
229
+ var i = 0
230
+ while (i < textStr.length && curL < line) {
231
+ if (textStr[i] == '\n') curL++
232
+ i++
233
+ }
234
+ charPos = i + col
235
+ } catch (e: Exception) {}
236
+
237
+ currentOnSelectionChange(charPos, line, col)
238
+
239
+ if (searcher.hasQuery()) {
240
+ onSearchMatchesChange(searcher.currentMatchedPositionIndex + 1, searcher.matchedPositionCount)
241
+ }
242
+ }
243
+
244
+ subscribeEvent(io.github.rosemoe.sora.event.PublishSearchResultEvent::class.java) { _, _ ->
245
+ onSearchMatchesChange(searcher.currentMatchedPositionIndex + 1, searcher.matchedPositionCount)
246
+ }
247
+
248
+ subscribeEvent(io.github.rosemoe.sora.event.ScrollEvent::class.java) { _, _ ->
249
+ onScroll()
250
+ }
251
+
252
+ subscribeEvent(io.github.rosemoe.sora.event.ContentChangeEvent::class.java) { _, _ ->
253
+ val newText = text.toString()
254
+ // Don't update if nothing changed?
255
+ // But we need to tell parent. Parent will update state.
256
+ // Parent update will come back via 'content' prop.
257
+ // If we just sync blindly, we loop.
258
+ // But 'update' block handles the loop break.
259
+ currentOnContentChange(newText)
260
+ }
261
+
262
+ try {
263
+ val color = android.graphics.Color.parseColor(backgroundColor)
264
+ val r = android.graphics.Color.red(color)
265
+ val g = android.graphics.Color.green(color)
266
+ val b = android.graphics.Color.blue(color)
267
+ val luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255.0
268
+
269
+ colorScheme.setColor(EditorColorScheme.WHOLE_BACKGROUND, color)
270
+ colorScheme.setColor(EditorColorScheme.LINE_NUMBER_BACKGROUND, color)
271
+
272
+ if (luminance < 0.5) {
273
+ colorScheme.setColor(EditorColorScheme.TEXT_NORMAL, android.graphics.Color.WHITE)
274
+ colorScheme.setColor(EditorColorScheme.LINE_NUMBER, android.graphics.Color.GRAY)
275
+ } else {
276
+ colorScheme.setColor(EditorColorScheme.TEXT_NORMAL, android.graphics.Color.BLACK)
277
+ colorScheme.setColor(EditorColorScheme.LINE_NUMBER, android.graphics.Color.DKGRAY)
278
+ }
279
+ } catch (e: Exception) {}
280
+
281
+ editorInstance = this
282
+ control?.attach(this)
283
+ }
284
+ },
285
+ update = { view ->
286
+ view.setTextSize(fontSize)
287
+ view.isLineNumberEnabled = showLineNumbers
288
+ view.isWordwrap = wordWrap
289
+ view.setEditable(editable)
290
+
291
+ // Only update text if it strictly differs.
292
+ if (view.text.toString() != content) {
293
+ // Save cursor? setText resets it usually.
294
+ // If the difference is just line endings, we might be screwing up.
295
+ // But view.text should match content if we are the ones who emitted it.
296
+ view.setText(content)
297
+ }
298
+
299
+ try {
300
+ val color = android.graphics.Color.parseColor(backgroundColor)
301
+ val r = android.graphics.Color.red(color)
302
+ val g = android.graphics.Color.green(color)
303
+ val b = android.graphics.Color.blue(color)
304
+ val luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255.0
305
+
306
+ view.colorScheme.setColor(EditorColorScheme.WHOLE_BACKGROUND, color)
307
+ view.colorScheme.setColor(EditorColorScheme.LINE_NUMBER_BACKGROUND, color)
308
+
309
+ if (luminance < 0.5) {
310
+ view.colorScheme.setColor(EditorColorScheme.TEXT_NORMAL, android.graphics.Color.WHITE)
311
+ view.colorScheme.setColor(EditorColorScheme.LINE_NUMBER, android.graphics.Color.GRAY)
312
+ } else {
313
+ view.colorScheme.setColor(EditorColorScheme.TEXT_NORMAL, android.graphics.Color.BLACK)
314
+ view.colorScheme.setColor(EditorColorScheme.LINE_NUMBER, android.graphics.Color.DKGRAY)
315
+ }
316
+ } catch (e: Exception) {}
317
+ },
318
+ modifier = modifier.fillMaxSize()
319
+ )
320
+ }
321
+
322
+ @OptIn(ExperimentalMaterial3Api::class)
323
+ @Composable
324
+ fun EditorScreen(
325
+ uiState: EditorUiState,
326
+ viewModel: EditorViewModel,
327
+ onBack: () -> Unit
328
+ ) {
329
+ var showMoreMenu by remember { mutableStateOf(false) }
330
+ val editorControl = remember { EditorControl() }
331
+ val localContext = androidx.compose.ui.platform.LocalContext.current
332
+
333
+ LaunchedEffect(Unit) {
334
+ viewModel.loadSettings(localContext)
335
+ }
336
+
337
+ LaunchedEffect(uiState.isReadOnly, uiState.showToolbar) {
338
+ if (uiState.isReadOnly && uiState.showToolbar) {
339
+ kotlinx.coroutines.delay(2000)
340
+ viewModel.setShowToolbar(false)
341
+ }
342
+ }
343
+
344
+ val handleBack = {
345
+ if (uiState.showSettings) {
346
+ viewModel.setShowSettings(false)
347
+ } else if (!uiState.autoSave && uiState.isModified) {
348
+ viewModel.setShowExitConfirmation(true)
349
+ } else {
350
+ onBack()
351
+ }
352
+ }
353
+
354
+ androidx.activity.compose.BackHandler(onBack = handleBack)
355
+
356
+ if (uiState.showSettings) {
357
+ EditorSettingsScreen(
358
+ uiState = uiState,
359
+ viewModel = viewModel,
360
+ onBack = { viewModel.setShowSettings(false) },
361
+ onFontSizeChange = { viewModel.setFontSize(localContext, it) },
362
+ onToggleLineNumbers = { viewModel.toggleLineNumbers(localContext) },
363
+ onToggleWordWrap = { viewModel.toggleWordWrap(localContext) },
364
+ onBackgroundColorChange = { viewModel.setBackgroundColor(localContext, it) }
365
+ )
366
+ } else {
367
+ Box(modifier = Modifier.fillMaxSize()) {
368
+ Column(modifier = Modifier.fillMaxSize()) {
369
+ AnimatedVisibility(
370
+ visible = uiState.showToolbar || !uiState.isReadOnly,
371
+ enter = slideInVertically() + fadeIn(),
372
+ exit = slideOutVertically() + fadeOut()
373
+ ) {
374
+ TopAppBar(
375
+ title = { },
376
+ colors = TopAppBarDefaults.topAppBarColors(
377
+ containerColor = try { Color(android.graphics.Color.parseColor(uiState.uiColor)) } catch(e:Exception) { MaterialTheme.colorScheme.surface }
378
+ ),
379
+ navigationIcon = {
380
+ IconButton(onClick = {
381
+ val pos = editorControl.getCurrentCursorPosition()
382
+ viewModel.setCursorPosition(pos, uiState.cursorLine - 1, uiState.cursorColumn)
383
+ viewModel.setShowToc(true)
384
+ }) {
385
+ Icon(Icons.Default.Menu, "目录")
386
+ }
387
+ },
388
+ actions = {
389
+ IconButton(onClick = { viewModel.saveFile(localContext) }) {
390
+ Icon(Icons.Default.Save, "保存")
391
+ }
392
+ IconButton(onClick = { editorControl.undo() }, enabled = editorControl.canUndo()) {
393
+ Icon(Icons.Default.Undo, "撤销")
394
+ }
395
+ IconButton(onClick = { editorControl.redo() }, enabled = editorControl.canRedo()) {
396
+ Icon(Icons.Default.Redo, "反撤销")
397
+ }
398
+ IconButton(onClick = { showMoreMenu = true }) {
399
+ Icon(Icons.Default.MoreVert, "More")
400
+ }
401
+
402
+ DropdownMenu(
403
+ expanded = showMoreMenu,
404
+ onDismissRequest = { showMoreMenu = false },
405
+ modifier = Modifier.background(try { Color(android.graphics.Color.parseColor(uiState.menuColor)) } catch(e:Exception) { MaterialTheme.colorScheme.surface })
406
+ ) {
407
+ DropdownMenuItem(
408
+ text = { Text("返回") },
409
+ onClick = { showMoreMenu = false; handleBack() },
410
+ leadingIcon = { Icon(Icons.Default.ArrowBack, null) }
411
+ )
412
+ DropdownMenuItem(
413
+ text = { Text("搜索") },
414
+ onClick = {
415
+ viewModel.setShowSearch(!uiState.showSearch)
416
+ showMoreMenu = false
417
+ },
418
+ leadingIcon = { Icon(Icons.Default.Search, null) }
419
+ )
420
+ DropdownMenuItem(
421
+ text = { Text("重命名") },
422
+ onClick = { viewModel.setShowRenameDialog(true); showMoreMenu = false },
423
+ leadingIcon = { Icon(Icons.Default.Edit, null) }
424
+ )
425
+ DropdownMenuItem(
426
+ text = { Text("属性") },
427
+ onClick = { viewModel.setShowFileProperties(true); showMoreMenu = false },
428
+ leadingIcon = { Icon(Icons.Default.Info, null) }
429
+ )
430
+ DropdownMenuItem(
431
+ text = { Text("重做 (还原为初始)") },
432
+ onClick = {
433
+ viewModel.updateContent(localContext, uiState.originalContent)
434
+ showMoreMenu = false
435
+ },
436
+ leadingIcon = { Icon(Icons.Default.RestartAlt, null) }
437
+ )
438
+ DropdownMenuItem(
439
+ text = { Text("只读模式: ${if (uiState.isReadOnly) "ON" else "OFF"}") },
440
+ onClick = { viewModel.toggleReadOnly(); showMoreMenu = false },
441
+ leadingIcon = { Icon(if(uiState.isReadOnly) Icons.Default.Lock else Icons.Default.LockOpen, null) }
442
+ )
443
+ DropdownMenuItem(
444
+ text = { Text("编辑器设置") },
445
+ onClick = { viewModel.setShowSettings(true); showMoreMenu = false },
446
+ leadingIcon = { Icon(Icons.Default.Settings, null) }
447
+ )
448
+ }
449
+ }
450
+ )
451
+ }
452
+
453
+ if (uiState.showSearch) {
454
+ SearchPanel(
455
+ searchQuery = uiState.searchQuery,
456
+ replaceText = uiState.replaceText,
457
+ currentMatch = uiState.currentMatch,
458
+ totalMatches = uiState.totalMatches,
459
+ backgroundColor = uiState.searchColor,
460
+ onSearchQueryChange = {
461
+ viewModel.setSearchQuery(it)
462
+ if (it.isNotEmpty()) editorControl.search(it) else editorControl.stopSearch()
463
+ },
464
+ onReplaceTextChange = { viewModel.setReplaceText(it) },
465
+ onFindNext = { editorControl.findNext() },
466
+ onFindPrevious = { editorControl.findPrevious() },
467
+ onReplace = { editorControl.replace(uiState.replaceText) },
468
+ onReplaceAll = { editorControl.replaceAll(uiState.replaceText) },
469
+ onClose = { viewModel.setShowSearch(false) }
470
+ )
471
+ }
472
+
473
+ Box(modifier = Modifier.weight(1f)) {
474
+ SoraEditorView(
475
+ content = uiState.content,
476
+ onContentChange = { viewModel.updateContent(localContext, it) },
477
+ onSelectionChange = { pos, line, col -> viewModel.setCursorPosition(pos, line, col) },
478
+ fontSize = uiState.fontSize,
479
+ showLineNumbers = uiState.showLineNumbers,
480
+ wordWrap = uiState.wordWrap,
481
+ editable = !uiState.isReadOnly,
482
+ backgroundColor = uiState.backgroundColor,
483
+ control = editorControl,
484
+ onSearchMatchesChange = { current, total -> viewModel.setMatchResults(current, total) },
485
+ onScroll = { if (uiState.isReadOnly && uiState.showToolbar) viewModel.setShowToolbar(false) },
486
+ onTap = { if (uiState.isReadOnly) viewModel.toggleToolbar() }
487
+ )
488
+ }
489
+
490
+ Column {
491
+ if (uiState.showSymbolBar && !uiState.isReadOnly) {
492
+ SymbolBar(
493
+ uiColor = uiState.uiColor,
494
+ onSymbolClick = { editorControl.insertText(it) }
495
+ )
496
+ }
497
+ if (uiState.showStatusBar) {
498
+ StatusBar(
499
+ uiColor = uiState.uiColor,
500
+ fileName = uiState.fileName,
501
+ cursorLine = uiState.cursorLine,
502
+ cursorColumn = uiState.cursorColumn,
503
+ currentCursorPos = uiState.currentCursorPos
504
+ )
505
+ }
506
+ }
507
+ }
508
+
509
+ if (uiState.showToc) {
510
+ TocPanel(
511
+ content = uiState.content,
512
+ currentCursorPos = uiState.currentCursorPos,
513
+ tocMode = uiState.tocMode,
514
+ surfaceColor = uiState.tocColor,
515
+ onModeChange = { viewModel.setTocMode(it) },
516
+ onChapterClick = { editorControl.jumpTo(it); viewModel.setShowToc(false) },
517
+ onDismiss = { viewModel.setShowToc(false) }
518
+ )
519
+ }
520
+
521
+ if (uiState.showRenameDialog) {
522
+ RenameDialog(
523
+ currentName = uiState.fileName,
524
+ backgroundColor = uiState.menuColor,
525
+ onRename = { viewModel.renameFile(it); viewModel.setShowRenameDialog(false) },
526
+ onDismiss = { viewModel.setShowRenameDialog(false) }
527
+ )
528
+ }
529
+
530
+ if (uiState.showFileProperties) {
531
+ FilePropertiesDialog(
532
+ properties = viewModel.getFileDetails(),
533
+ backgroundColor = uiState.menuColor,
534
+ onDismiss = { viewModel.setShowFileProperties(false) }
535
+ )
536
+ }
537
+
538
+ if (uiState.showExitConfirmation) {
539
+ ExitConfirmationDialog(
540
+ onSave = { viewModel.saveFile(localContext); onBack() },
541
+ onDiscard = { onBack() },
542
+ onDismiss = { viewModel.setShowExitConfirmation(false) }
543
+ )
544
+ }
545
+ }
546
+ }
547
+ }
548
+
549
+ @Composable
550
+ fun SymbolBar(uiColor: String, onSymbolClick: (String) -> Unit) {
551
+ val symbols = listOf(",", ".", ";", "(", ")", "{", "}", "[", "]", "\"", "'", ":", "/", "<", ">", "=", "+", "-", "*", "&", "|", "!", "?", "#", "@", "$", "%", "^", "~", "`")
552
+ androidx.compose.foundation.lazy.LazyRow(
553
+ modifier = Modifier
554
+ .fillMaxWidth()
555
+ .height(44.dp)
556
+ .background(try { Color(android.graphics.Color.parseColor(uiColor)) } catch(e:Exception) { Color(0xFFF0F0F0) })
557
+ .border(androidx.compose.foundation.BorderStroke(0.5.dp, Color.LightGray.copy(alpha = 0.5f))),
558
+ contentPadding = PaddingValues(horizontal = 8.dp),
559
+ verticalAlignment = Alignment.CenterVertically,
560
+ horizontalArrangement = Arrangement.spacedBy(4.dp)
561
+ ) {
562
+ items(symbols.size) { index ->
563
+ Surface(
564
+ onClick = { onSymbolClick(symbols[index]) },
565
+ modifier = Modifier
566
+ .size(width = 36.dp, height = 36.dp),
567
+ shape = RoundedCornerShape(4.dp),
568
+ color = Color.White,
569
+ border = androidx.compose.foundation.BorderStroke(0.5.dp, Color.LightGray)
570
+ ) {
571
+ Box(contentAlignment = Alignment.Center) {
572
+ Text(symbols[index], fontSize = 18.sp, fontWeight = androidx.compose.ui.text.font.FontWeight.Medium)
573
+ }
574
+ }
575
+ }
576
+ }
577
+ }
578
+
579
+ @Composable
580
+ fun StatusBar(uiColor: String, fileName: String, cursorLine: Int, cursorColumn: Int, currentCursorPos: Int) {
581
+ Surface(
582
+ modifier = Modifier.fillMaxWidth().height(24.dp),
583
+ color = try { Color(android.graphics.Color.parseColor(uiColor)) } catch(e:Exception) { Color(0xFFEEEEEE) },
584
+ tonalElevation = 2.dp,
585
+ border = BorderStroke(0.5.dp, Color.LightGray)
586
+ ) {
587
+ Row(modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
588
+ Text(
589
+ text = fileName,
590
+ fontSize = 11.sp,
591
+ color = Color.DarkGray,
592
+ modifier = Modifier.weight(1f).padding(end = 8.dp),
593
+ maxLines = 1,
594
+ overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis
595
+ )
596
+ Text(
597
+ text = "第 ${cursorLine} 行, 第 ${cursorColumn} 列, 第 ${currentCursorPos} 字",
598
+ fontSize = 11.sp,
599
+ color = Color.DarkGray
600
+ )
601
+ }
602
+ }
603
+ }
604
+
605
+ @Composable
606
+ fun SearchPanel(
607
+ searchQuery: String,
608
+ replaceText: String,
609
+ currentMatch: Int,
610
+ totalMatches: Int,
611
+ backgroundColor: String,
612
+ onSearchQueryChange: (String) -> Unit,
613
+ onReplaceTextChange: (String) -> Unit,
614
+ onFindNext: () -> Unit,
615
+ onFindPrevious: () -> Unit,
616
+ onReplace: () -> Unit,
617
+ onReplaceAll: () -> Unit,
618
+ onClose: () -> Unit
619
+ ) {
620
+ Surface(modifier = Modifier.fillMaxWidth(), color = try { Color(android.graphics.Color.parseColor(backgroundColor)) } catch(e:Exception) { MaterialTheme.colorScheme.surface }, tonalElevation = 1.dp) {
621
+ Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
622
+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
623
+ OutlinedTextField(
624
+ value = searchQuery,
625
+ onValueChange = onSearchQueryChange,
626
+ placeholder = { Text("查找文本", fontSize = 14.sp) },
627
+ modifier = Modifier.weight(1f),
628
+ singleLine = true,
629
+ trailingIcon = {
630
+ if (totalMatches > 0) Text("${currentMatch.coerceAtLeast(0)}/$totalMatches", fontSize = 12.sp)
631
+ }
632
+ )
633
+ Button(onClick = onFindPrevious, contentPadding = PaddingValues(0.dp), modifier = Modifier.defaultMinSize(minWidth = 48.dp)) { Text("上个", fontSize = 12.sp) }
634
+ Button(onClick = onFindNext, contentPadding = PaddingValues(0.dp), modifier = Modifier.defaultMinSize(minWidth = 48.dp)) { Text("下个", fontSize = 12.sp) }
635
+ }
636
+ Spacer(modifier = Modifier.height(8.dp))
637
+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
638
+ OutlinedTextField(
639
+ value = replaceText,
640
+ onValueChange = onReplaceTextChange,
641
+ placeholder = { Text("替换到的文本", fontSize = 14.sp) },
642
+ modifier = Modifier.weight(1f),
643
+ singleLine = true
644
+ )
645
+ TextButton(onClick = onReplace) { Text("替换", fontSize = 13.sp) }
646
+ TextButton(onClick = onReplaceAll) { Text("全部", fontSize = 13.sp) }
647
+ }
648
+ }
649
+ }
650
+ }
651
+
652
+ data class Chapter(val index: Int, val pos: Int, val title: String)
653
+
654
+ @Composable
655
+ fun TocPanel(
656
+ content: String,
657
+ currentCursorPos: Int,
658
+ tocMode: String,
659
+ surfaceColor: String,
660
+ onModeChange: (String) -> Unit,
661
+ onChapterClick: (Int) -> Unit,
662
+ onDismiss: () -> Unit
663
+ ) {
664
+ val chapters = remember(content, tocMode) {
665
+ if (tocMode == "chars") {
666
+ val count = kotlin.math.ceil(content.length / 2000.0).toInt()
667
+ List(count) { i -> Chapter(i, i * 2000, "第 ${i + 1} 章") }
668
+ } else {
669
+ val result = mutableListOf<Chapter>()
670
+ var currentPos = 0
671
+ var lineCount = 0
672
+ var chunkStartLine = 1
673
+ for (i in content.indices) {
674
+ if (content[i] == '\n') {
675
+ lineCount++
676
+ if (lineCount % 100 == 0) {
677
+ result.add(Chapter(result.size, currentPos, "第 $chunkStartLine - $lineCount 行"))
678
+ chunkStartLine = lineCount + 1
679
+ }
680
+ }
681
+ currentPos++
682
+ }
683
+ if (result.isEmpty() || lineCount >= chunkStartLine) {
684
+ result.add(Chapter(result.size, if(result.isEmpty()) 0 else currentPos, "第 $chunkStartLine - ${lineCount + 1} 行"))
685
+ }
686
+ result
687
+ }
688
+ }
689
+
690
+ val activeIndex = chapters.indexOfLast { it.pos <= currentCursorPos }.coerceAtLeast(0)
691
+ val listState = androidx.compose.foundation.lazy.rememberLazyListState()
692
+
693
+ // Auto-hide logic for scrollbar
694
+ var showScrollbar by remember { mutableStateOf(false) }
695
+ LaunchedEffect(listState.isScrollInProgress) {
696
+ if (listState.isScrollInProgress) {
697
+ showScrollbar = true
698
+ } else {
699
+ kotlinx.coroutines.delay(3000)
700
+ showScrollbar = false
701
+ }
702
+ }
703
+
704
+ LaunchedEffect(activeIndex) {
705
+ if (activeIndex > 0) listState.scrollToItem((activeIndex - 5).coerceAtLeast(0))
706
+ }
707
+
708
+ Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.5f)).clickable(onClick = onDismiss)) {
709
+ Surface(Modifier.fillMaxHeight().fillMaxWidth(0.75f).clickable(enabled = false) { }, color = try { Color(android.graphics.Color.parseColor(surfaceColor)) } catch(e:Exception) { MaterialTheme.colorScheme.surface }, tonalElevation = 8.dp) {
710
+ Column(Modifier.fillMaxSize()) {
711
+ Row(Modifier.fillMaxWidth().padding(16.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
712
+ Text("目录", style = MaterialTheme.typography.titleLarge)
713
+ Row(Modifier.background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(8.dp)).padding(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
714
+ listOf("chars" to "按字", "lines" to "按行").forEach { (mode, label) ->
715
+ Button(
716
+ onClick = { onModeChange(mode) },
717
+ colors = ButtonDefaults.buttonColors(
718
+ containerColor = if (tocMode == mode) Color(0xFFE0E0E0) else Color.Transparent,
719
+ contentColor = if (tocMode == mode) Color.Black else Color.DarkGray
720
+ ),
721
+ modifier = Modifier.height(32.dp),
722
+ contentPadding = PaddingValues(horizontal = 12.dp, vertical = 0.dp)
723
+ ) {
724
+ Text(label, fontSize = 12.sp)
725
+ }
726
+ }
727
+ }
728
+ IconButton(onClick = onDismiss) { Icon(Icons.Default.Close, null) }
729
+ }
730
+ HorizontalDivider()
731
+ Box(modifier = Modifier.weight(1f)) {
732
+ androidx.compose.foundation.lazy.LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
733
+ items(chapters.size) { index ->
734
+ val isActive = index == activeIndex
735
+ Surface(
736
+ onClick = { onChapterClick(chapters[index].pos); onDismiss() },
737
+ modifier = Modifier.fillMaxWidth(),
738
+ color = if (isActive) MaterialTheme.colorScheme.surfaceVariant else Color.Transparent
739
+ ) {
740
+ Text(chapters[index].title, modifier = Modifier.padding(20.dp, 12.dp), fontWeight = if (isActive) androidx.compose.ui.text.font.FontWeight.Bold else null)
741
+ }
742
+ }
743
+ }
744
+
745
+ // Improved Draggable Scrollbar with Auto-hide and Enlarge size
746
+ androidx.compose.animation.AnimatedVisibility(
747
+ visible = showScrollbar,
748
+ enter = fadeIn(),
749
+ exit = fadeOut(),
750
+ modifier = Modifier.align(Alignment.CenterEnd)
751
+ ) {
752
+ if (chapters.size > 10) {
753
+ val scope = rememberCoroutineScope()
754
+ val totalItems = chapters.size
755
+
756
+ BoxWithConstraints(
757
+ modifier = Modifier
758
+ .fillMaxHeight()
759
+ .width(50.dp) // Large touch area
760
+ .pointerInput(totalItems) {
761
+ detectDragGestures(
762
+ onDragStart = { showScrollbar = true },
763
+ onDrag = { change, dragAmount ->
764
+ showScrollbar = true
765
+ change.consume()
766
+ val trackHeight = size.height.toFloat()
767
+ val visibleItems = listState.layoutInfo.visibleItemsInfo
768
+ if (visibleItems.isNotEmpty()) {
769
+ val visibleCount = visibleItems.size
770
+ val thumbHeight = trackHeight * (visibleCount.toFloat() / totalItems).coerceIn(0.1f, 1f)
771
+ val travelDistance = (trackHeight - thumbHeight).coerceAtLeast(1f)
772
+
773
+ val deltaPercent = dragAmount.y / travelDistance
774
+ val currentFirstVisible = listState.firstVisibleItemIndex
775
+ val currentPercent = currentFirstVisible.toFloat() / (totalItems - visibleCount).coerceAtLeast(1)
776
+
777
+ val newPercent = (currentPercent + deltaPercent).coerceIn(0f, 1f)
778
+ val targetIndex = (newPercent * (totalItems - visibleCount)).toInt()
779
+ scope.launch {
780
+ listState.scrollToItem(targetIndex)
781
+ }
782
+ }
783
+ },
784
+ onDragEnd = {
785
+ scope.launch {
786
+ kotlinx.coroutines.delay(3000)
787
+ showScrollbar = false
788
+ }
789
+ }
790
+ )
791
+ }
792
+ ) {
793
+ val trackHeightPx = constraints.maxHeight.toFloat()
794
+ val layoutInfo = listState.layoutInfo
795
+ val visibleItems = layoutInfo.visibleItemsInfo
796
+
797
+ if (visibleItems.isNotEmpty()) {
798
+ val firstVisible = listState.firstVisibleItemIndex
799
+ val visibleCount = visibleItems.size
800
+ val thumbHeightPercent = (visibleCount.toFloat() / totalItems).coerceIn(0.1f, 1f)
801
+ val scrollPercent = firstVisible.toFloat() / (totalItems - visibleCount).coerceAtLeast(1)
802
+
803
+ // Background track
804
+ Box(
805
+ modifier = Modifier
806
+ .align(Alignment.CenterEnd)
807
+ .fillMaxHeight()
808
+ .width(12.dp) // Double size (6dp -> 12dp)
809
+ .padding(end = 4.dp, top = 4.dp, bottom = 4.dp)
810
+ .background(Color.LightGray.copy(alpha = 0.2f), RoundedCornerShape(6.dp))
811
+ )
812
+
813
+ // Thumb
814
+ Box(
815
+ modifier = Modifier
816
+ .align(Alignment.TopEnd)
817
+ .padding(end = 4.dp)
818
+ .width(12.dp) // Double size (6dp -> 12dp)
819
+ .fillMaxHeight(thumbHeightPercent)
820
+ .graphicsLayer {
821
+ translationY = trackHeightPx * (1f - thumbHeightPercent) * scrollPercent
822
+ }
823
+ .background(Color.Gray.copy(alpha = 0.5f), RoundedCornerShape(6.dp))
824
+ )
825
+ }
826
+ }
827
+ }
828
+ }
829
+ }
830
+ }
831
+ }
832
+ }
833
+ }
834
+
835
+ @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
836
+ @Composable
837
+ fun EditorSettingsScreen(
838
+ uiState: EditorUiState,
839
+ viewModel: EditorViewModel,
840
+ onBack: () -> Unit,
841
+ onFontSizeChange: (Float) -> Unit,
842
+ onToggleLineNumbers: () -> Unit,
843
+ onToggleWordWrap: () -> Unit,
844
+ onBackgroundColorChange: (String) -> Unit
845
+ ) {
846
+ val localContext = androidx.compose.ui.platform.LocalContext.current
847
+ Scaffold(
848
+ topBar = {
849
+ TopAppBar(
850
+ title = { Text("编辑器设置") },
851
+ navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Default.ArrowBack, null) } }
852
+ )
853
+ }
854
+ ) { padding ->
855
+ Column(Modifier.fillMaxSize().padding(padding).padding(16.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(24.dp)) {
856
+ Surface(
857
+ modifier = Modifier.fillMaxWidth().height(100.dp),
858
+ color = try { Color(android.graphics.Color.parseColor(uiState.backgroundColor)) } catch(e:Exception) { Color.Gray },
859
+ shape = RoundedCornerShape(12.dp),
860
+ border = BorderStroke(1.dp, Color.LightGray)
861
+ ) {
862
+ Box(contentAlignment = Alignment.Center) {
863
+ Text("预览文本效果 Preview Text", fontSize = uiState.fontSize.sp, color = if (uiState.backgroundColor == "#000000") Color.White else Color.Black)
864
+ }
865
+ }
866
+
867
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
868
+ Text("字体大小: ${uiState.fontSize.toInt()}px")
869
+ Slider(value = uiState.fontSize, onValueChange = onFontSizeChange, valueRange = 12f..36f)
870
+ }
871
+
872
+ SettingsSwitchItem("显示行号", "在左侧显示行号", uiState.showLineNumbers) { viewModel.toggleLineNumbers(localContext) }
873
+ SettingsSwitchItem("自动换行", "自动折行显示", uiState.wordWrap) { viewModel.toggleWordWrap(localContext) }
874
+ SettingsSwitchItem("自动保存", "编辑时自动保存", uiState.autoSave) { viewModel.setAutoSave(localContext, it) }
875
+ SettingsSwitchItem("显示状态栏", "显示底部的行、列、字符数信息", uiState.showStatusBar) { viewModel.toggleStatusBar(localContext) }
876
+ SettingsSwitchItem("符号快捷键", "在底部显示常用符号栏", uiState.showSymbolBar) { viewModel.toggleSymbolBar(localContext) }
877
+
878
+
879
+ Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
880
+ Text("主题颜色自定义 (也可在下面 JSON 自由配置)", style = MaterialTheme.typography.titleMedium)
881
+ val colors = listOf("#FFFFFF" to "白", "#F5F5F5" to "灰", "#E0E0E0" to "深灰", "#FFF8DC" to "米", "#E8F5E9" to "绿", "#E3F2FD" to "蓝", "#000000" to "黑")
882
+
883
+ Column {
884
+ Text("编辑器背景 (Editor)", fontSize = 12.sp, color = Color.Gray)
885
+ FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
886
+ colors.forEach { (c, l) ->
887
+ ColorOption(c, l, uiState.backgroundColor == c) { viewModel.setBackgroundColor(localContext, c) }
888
+ }
889
+ }
890
+ }
891
+
892
+ Column {
893
+ Row(verticalAlignment = Alignment.CenterVertically) {
894
+ Text("应用 UI 颜色 (Toolbar/Bottom)", fontSize = 12.sp, color = Color.Gray, modifier = Modifier.weight(1f))
895
+ TextButton(onClick = {
896
+ viewModel.setTocColor(localContext, uiState.uiColor)
897
+ viewModel.setSearchColor(localContext, uiState.uiColor)
898
+ viewModel.setMenuColor(localContext, uiState.uiColor)
899
+ }) {
900
+ Text("同步到所有面板", fontSize = 10.sp)
901
+ }
902
+ }
903
+ FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
904
+ colors.forEach { (c, l) ->
905
+ ColorOption(c, l, uiState.uiColor == c) { viewModel.setUiColor(localContext, c) }
906
+ }
907
+ }
908
+ }
909
+
910
+ Column {
911
+ Text("更多菜单颜色 (More Menu)", fontSize = 12.sp, color = Color.Gray)
912
+ FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
913
+ colors.forEach { (c, l) ->
914
+ ColorOption(c, l, uiState.menuColor == c) { viewModel.setMenuColor(localContext, c) }
915
+ }
916
+ }
917
+ }
918
+
919
+ Column {
920
+ Text("目录面板颜色 (TOC)", fontSize = 12.sp, color = Color.Gray)
921
+ FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
922
+ colors.forEach { (c, l) ->
923
+ ColorOption(c, l, uiState.tocColor == c) { viewModel.setTocColor(localContext, c) }
924
+ }
925
+ }
926
+ }
927
+
928
+ Column {
929
+ Text("搜索面板颜色 (Search)", fontSize = 12.sp, color = Color.Gray)
930
+ FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
931
+ colors.forEach { (c, l) ->
932
+ ColorOption(c, l, uiState.searchColor == c) { viewModel.setSearchColor(localContext, c) }
933
+ }
934
+ }
935
+ }
936
+ }
937
+
938
+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
939
+ Text("配置 JSON", style = MaterialTheme.typography.titleMedium)
940
+ var jsonText by remember { mutableStateOf(viewModel.getSettingsJson()) }
941
+
942
+ LaunchedEffect(uiState) {
943
+ jsonText = viewModel.getSettingsJson()
944
+ }
945
+
946
+ OutlinedTextField(
947
+ value = jsonText,
948
+ onValueChange = { jsonText = it },
949
+ modifier = Modifier.fillMaxWidth().heightIn(min = 150.dp),
950
+ textStyle = androidx.compose.ui.text.TextStyle(fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, fontSize = 12.sp)
951
+ )
952
+ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically) {
953
+ TextButton(onClick = { viewModel.resetSettings(localContext) }) {
954
+ Text("重置所有设置", color = Color.Red)
955
+ }
956
+ Spacer(Modifier.width(8.dp))
957
+ Button(
958
+ onClick = {
959
+ viewModel.applySettingsFromJson(localContext, jsonText)
960
+ }
961
+ ) {
962
+ Text("保存 JSON 设置")
963
+ }
964
+ }
965
+ }
966
+ }
967
+ }
968
+ }
969
+
970
+ @Composable
971
+ fun SettingsSwitchItem(title: String, desc: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
972
+ Row(Modifier.fillMaxWidth().clickable { onCheckedChange(!checked) }.padding(8.dp), verticalAlignment = Alignment.CenterVertically) {
973
+ Column(Modifier.weight(1f)) {
974
+ Text(title, style = MaterialTheme.typography.titleMedium)
975
+ Text(desc, style = MaterialTheme.typography.bodySmall)
976
+ }
977
+ Switch(checked = checked, onCheckedChange = onCheckedChange)
978
+ }
979
+ }
980
+
981
+ @Composable
982
+ fun ColorOption(color: String, label: String, isSelected: Boolean, onClick: () -> Unit) {
983
+ Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.width(60.dp).clickable { onClick() }) {
984
+ Box(Modifier.size(40.dp).background(try { Color(android.graphics.Color.parseColor(color)) } catch(e:Exception) { Color.Gray }, RoundedCornerShape(20.dp)).border(if (isSelected) 2.dp else 1.dp, if (isSelected) MaterialTheme.colorScheme.primary else Color.LightGray, RoundedCornerShape(20.dp)))
985
+ Text(label, fontSize = 10.sp)
986
+ }
987
+ }
988
+
989
+ @Composable
990
+ fun RenameDialog(currentName: String, backgroundColor: String, onRename: (String) -> Unit, onDismiss: () -> Unit) {
991
+ var name by remember { mutableStateOf(currentName) }
992
+ AlertDialog(
993
+ onDismissRequest = onDismiss,
994
+ containerColor = try { Color(android.graphics.Color.parseColor(backgroundColor)) } catch(e:Exception) { MaterialTheme.colorScheme.surface },
995
+ title = { Text("重命名") },
996
+ text = { OutlinedTextField(value = name, onValueChange = { name = it }, label = { Text("新名字") }) },
997
+ confirmButton = { TextButton(onClick = { onRename(name) }) { Text("OK") } },
998
+ dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }
999
+ )
1000
+ }
1001
+
1002
+ @Composable
1003
+ fun FilePropertiesDialog(properties: Map<String, String>, backgroundColor: String, onDismiss: () -> Unit) {
1004
+ AlertDialog(
1005
+ onDismissRequest = onDismiss,
1006
+ containerColor = try { Color(android.graphics.Color.parseColor(backgroundColor)) } catch(e:Exception) { MaterialTheme.colorScheme.surface },
1007
+ title = { Text("属性") },
1008
+ text = { Column { properties.forEach { (k, v) -> Text("$k: $v") } } },
1009
+ confirmButton = { TextButton(onClick = onDismiss) { Text("Close") } }
1010
+ )
1011
+ }
1012
+
1013
+ @Composable
1014
+ fun ExitConfirmationDialog(onSave: () -> Unit, onDiscard: () -> Unit, onDismiss: () -> Unit) {
1015
+ AlertDialog(onDismissRequest = onDismiss, title = { Text("保存?") }, text = { Text("内容已修改") }, confirmButton = { TextButton(onClick = onSave) { Text("保存") } }, dismissButton = { Row { TextButton(onClick = onDiscard) { Text("不保存") }; TextButton(onClick = onDismiss) { Text("取消") } } })
1016
+ }