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,471 @@
1
+ package com.github.soraeditor.capacitor
2
+
3
+ import android.content.Context
4
+ import androidx.lifecycle.ViewModel
5
+ import androidx.lifecycle.viewModelScope
6
+ import kotlinx.coroutines.flow.MutableStateFlow
7
+ import kotlinx.coroutines.flow.StateFlow
8
+ import kotlinx.coroutines.flow.asStateFlow
9
+ import kotlinx.coroutines.flow.update
10
+ import kotlinx.coroutines.launch
11
+ import java.io.File
12
+ import org.json.JSONObject
13
+
14
+ data class EditorUiState(
15
+ val content: String = "",
16
+ val filePath: String = "",
17
+ val fileName: String = "",
18
+ val isModified: Boolean = false,
19
+ val fontSize: Float = 18f,
20
+ val showLineNumbers: Boolean = true,
21
+ val wordWrap: Boolean = false,
22
+ val isReadOnly: Boolean = false,
23
+ val showToolbar: Boolean = true,
24
+ val showSearch: Boolean = false,
25
+ val searchQuery: String = "",
26
+ val replaceText: String = "",
27
+ val currentMatch: Int = 0,
28
+ val totalMatches: Int = 0,
29
+ val showToc: Boolean = false,
30
+ val tocMode: String = "chars", // "chars" or "lines"
31
+ val showSettings: Boolean = false,
32
+ val backgroundColor: String = "#FFFFFF",
33
+ val isDarkTheme: Boolean = false,
34
+ val currentCursorPos: Int = 0,
35
+ val autoSave: Boolean = true,
36
+ val showFileProperties: Boolean = false,
37
+ val showRenameDialog: Boolean = false,
38
+ val showExitConfirmation: Boolean = false,
39
+ val cursorLine: Int = 1,
40
+ val cursorColumn: Int = 0,
41
+ val originalContent: String = "",
42
+ val showStatusBar: Boolean = true,
43
+ val showSymbolBar: Boolean = true,
44
+ val uiColor: String = "#F5F5F5",
45
+ val tocColor: String = "#FFFFFF",
46
+ val searchColor: String = "#F5F5F5",
47
+ val menuColor: String = "#FFFFFF"
48
+ )
49
+
50
+ class EditorViewModel : ViewModel() {
51
+ private val _uiState = MutableStateFlow(EditorUiState())
52
+ val uiState: StateFlow<EditorUiState> = _uiState.asStateFlow()
53
+
54
+ fun setCursorPosition(pos: Int, line: Int, col: Int) {
55
+ _uiState.update { it.copy(
56
+ currentCursorPos = pos,
57
+ cursorLine = line + 1, // 1-indexed for display
58
+ cursorColumn = col
59
+ ) }
60
+ }
61
+
62
+ fun loadFile(context: Context, filePath: String) {
63
+ viewModelScope.launch {
64
+ try {
65
+ // Convert URI to actual path if needed
66
+ var actualPath = if (filePath.startsWith("file://")) {
67
+ filePath.substring(7) // Remove "file://" prefix
68
+ } else {
69
+ filePath
70
+ }
71
+
72
+ // Decode URI-encoded characters (like %E4%BD%9C%E8%80%85 for Chinese)
73
+ actualPath = java.net.URLDecoder.decode(actualPath, "UTF-8")
74
+
75
+ android.util.Log.d("EditorViewModel", "Loading file from: $actualPath")
76
+ val file = File(actualPath)
77
+
78
+ if (file.exists()) {
79
+ val content = file.readText()
80
+ android.util.Log.d("EditorViewModel", "File loaded successfully, size: ${content.length}")
81
+ _uiState.value = _uiState.value.copy(
82
+ content = content,
83
+ filePath = actualPath,
84
+ fileName = file.name,
85
+ originalContent = content,
86
+ isModified = false
87
+ )
88
+ } else {
89
+ android.util.Log.e("EditorViewModel", "File does not exist: $actualPath")
90
+ }
91
+ } catch (e: Exception) {
92
+ android.util.Log.e("EditorViewModel", "Error loading file", e)
93
+ e.printStackTrace()
94
+ }
95
+ }
96
+ }
97
+
98
+ private var saveJob: kotlinx.coroutines.Job? = null
99
+
100
+ fun updateContent(context: Context, newContent: String) {
101
+ val normalizedNew = newContent.replace("\r\n", "\n")
102
+ val normalizedCurrent = _uiState.value.content.replace("\r\n", "\n")
103
+
104
+ // If content hasn't changed (ignoring line endings), don't update
105
+ if (normalizedNew == normalizedCurrent && _uiState.value.content.isNotEmpty()) return
106
+
107
+ // Check for trivial change (trailing whitespace/newline only) when not modified yet
108
+ // This prevents updating the timestamp just because the editor added a newline
109
+ val isTrivialChange = !_uiState.value.isModified &&
110
+ normalizedNew.trimEnd() == normalizedCurrent.trimEnd()
111
+
112
+ if (isTrivialChange) {
113
+ // Update content so state matches editor, but don't mark modified or save
114
+ _uiState.update { it.copy(content = newContent) }
115
+ return
116
+ }
117
+
118
+ _uiState.update { it.copy(
119
+ content = newContent,
120
+ isModified = true
121
+ ) }
122
+
123
+ if (_uiState.value.autoSave) {
124
+ queueAutoSave(context)
125
+ }
126
+ }
127
+
128
+ private fun queueAutoSave(context: Context) {
129
+ saveJob?.cancel()
130
+ saveJob = viewModelScope.launch {
131
+ kotlinx.coroutines.delay(1000) // Debounce 1s
132
+ if (_uiState.value.isModified) {
133
+ saveFile(context)
134
+ }
135
+ }
136
+ }
137
+
138
+ fun saveFile(context: Context): Boolean {
139
+ return try {
140
+ val contentToSave = _uiState.value.content
141
+ val path = _uiState.value.filePath
142
+
143
+ android.util.Log.d("EditorViewModel", "Saving file to: $path, content length: ${contentToSave.length}")
144
+
145
+ val file = File(path)
146
+ file.writeText(contentToSave)
147
+
148
+ _uiState.update { state ->
149
+ // Only clear isModified if the content we just saved is still the current content
150
+ if (state.content == contentToSave) {
151
+ state.copy(isModified = false)
152
+ } else {
153
+ state
154
+ }
155
+ }
156
+ true
157
+ } catch (e: Exception) {
158
+ android.util.Log.e("EditorViewModel", "Failed to save file", e)
159
+ e.printStackTrace()
160
+ false
161
+ }
162
+ }
163
+
164
+ fun saveOnExit(context: Context) {
165
+ val state = _uiState.value
166
+ // Cancel pending auto-save and save immediately if needed
167
+ saveJob?.cancel()
168
+
169
+ if (state.isModified || state.autoSave) {
170
+ saveFile(context)
171
+ }
172
+
173
+ // Logic to rename based on first line, ONLY on exit
174
+ val fileName = state.fileName
175
+ // Heuristic: If filename is just numbers (timestamp) or starts with "Untitled", consider it "New"
176
+ val isDefaultName = fileName.matches(Regex("^\\d+(\\.txt)?$")) ||
177
+ fileName.startsWith("Untitled") ||
178
+ fileName.startsWith("NewFile")
179
+
180
+ if (isDefaultName) {
181
+ val content = state.content
182
+ val firstLine = content.lineSequence().firstOrNull()?.trim() ?: ""
183
+ // Sanitize title: remove invalid chars, limit length
184
+ val validTitle = firstLine.replace(Regex("[\\\\/:*?\"<>|]"), "").take(20).trim()
185
+
186
+ if (validTitle.isNotEmpty() && validTitle != fileName.removeSuffix(".txt")) {
187
+ val newName = "$validTitle.txt"
188
+ android.util.Log.d("EditorViewModel", "Auto-renaming on exit: $fileName -> $newName")
189
+ renameFile(newName)
190
+ }
191
+ }
192
+ }
193
+
194
+ fun setFontSize(context: Context, size: Float) {
195
+ _uiState.value = _uiState.value.copy(fontSize = size)
196
+ saveSettings(context)
197
+ }
198
+
199
+ fun toggleLineNumbers(context: Context) {
200
+ _uiState.update { it.copy(showLineNumbers = !it.showLineNumbers) }
201
+ saveSettings(context)
202
+ }
203
+
204
+ fun toggleWordWrap(context: Context) {
205
+ _uiState.update { it.copy(wordWrap = !it.wordWrap) }
206
+ saveSettings(context)
207
+ }
208
+
209
+ fun toggleReadOnly() {
210
+ _uiState.update { state ->
211
+ val nextReadOnly = !state.isReadOnly
212
+ state.copy(
213
+ isReadOnly = nextReadOnly,
214
+ showToolbar = if (nextReadOnly) false else true
215
+ )
216
+ }
217
+ }
218
+
219
+ fun toggleToolbar() {
220
+ _uiState.update { it.copy(showToolbar = !it.showToolbar) }
221
+ }
222
+
223
+ fun setShowToolbar(show: Boolean) {
224
+ _uiState.update { it.copy(showToolbar = show) }
225
+ }
226
+
227
+ fun setShowSearch(show: Boolean) {
228
+ _uiState.update { it.copy(showSearch = show) }
229
+ }
230
+
231
+ fun setSearchQuery(query: String) {
232
+ _uiState.update { it.copy(searchQuery = query) }
233
+ }
234
+
235
+ fun setReplaceText(text: String) {
236
+ _uiState.update { it.copy(replaceText = text) }
237
+ }
238
+
239
+ fun setShowToc(show: Boolean) {
240
+ _uiState.update { it.copy(showToc = show) }
241
+ }
242
+
243
+ fun setTocMode(mode: String) {
244
+ _uiState.update { it.copy(tocMode = mode) }
245
+ }
246
+
247
+ fun setShowSettings(show: Boolean) {
248
+ _uiState.update { it.copy(showSettings = show) }
249
+ }
250
+
251
+ fun setBackgroundColor(context: Context, color: String) {
252
+ _uiState.update { it.copy(backgroundColor = color) }
253
+ saveSettings(context)
254
+ }
255
+
256
+ fun setDarkTheme(isDark: Boolean) {
257
+ _uiState.update { it.copy(isDarkTheme = isDark) }
258
+ }
259
+ fun setMatchResults(current: Int, total: Int) {
260
+ _uiState.update { it.copy(currentMatch = current, totalMatches = total) }
261
+ }
262
+
263
+ fun setAutoSave(context: Context, enabled: Boolean) {
264
+ _uiState.update { it.copy(autoSave = enabled) }
265
+ saveSettings(context)
266
+ }
267
+
268
+ fun toggleStatusBar(context: Context) {
269
+ _uiState.update { it.copy(showStatusBar = !it.showStatusBar) }
270
+ saveSettings(context)
271
+ }
272
+
273
+ fun toggleSymbolBar(context: Context) {
274
+ _uiState.update { it.copy(showSymbolBar = !it.showSymbolBar) }
275
+ saveSettings(context)
276
+ }
277
+
278
+ fun setShowStatusBar(context: Context, show: Boolean) {
279
+ _uiState.update { it.copy(showStatusBar = show) }
280
+ saveSettings(context)
281
+ }
282
+
283
+ fun setShowSymbolBar(context: Context, show: Boolean) {
284
+ _uiState.update { it.copy(showSymbolBar = show) }
285
+ saveSettings(context)
286
+ }
287
+
288
+ fun setShowFileProperties(show: Boolean) {
289
+ _uiState.update { it.copy(showFileProperties = show) }
290
+ }
291
+
292
+ fun setShowRenameDialog(show: Boolean) {
293
+ _uiState.update { it.copy(showRenameDialog = show) }
294
+ }
295
+
296
+ fun setShowExitConfirmation(show: Boolean) {
297
+ _uiState.update { it.copy(showExitConfirmation = show) }
298
+ }
299
+
300
+ fun setUiColor(context: Context, color: String) {
301
+ _uiState.update { it.copy(uiColor = color) }
302
+ saveSettings(context)
303
+ }
304
+
305
+ fun setTocColor(context: Context, color: String) {
306
+ _uiState.update { it.copy(tocColor = color) }
307
+ saveSettings(context)
308
+ }
309
+
310
+ fun setSearchColor(context: Context, color: String) {
311
+ _uiState.update { it.copy(searchColor = color) }
312
+ saveSettings(context)
313
+ }
314
+
315
+ fun setMenuColor(context: Context, color: String) {
316
+ _uiState.update { it.copy(menuColor = color) }
317
+ saveSettings(context)
318
+ }
319
+
320
+ fun renameFile(newName: String): Boolean {
321
+ val currentFile = File(_uiState.value.filePath)
322
+ val parent = currentFile.parentFile
323
+ val newFile = File(parent, newName)
324
+ return if (currentFile.renameTo(newFile)) {
325
+ _uiState.value = _uiState.value.copy(
326
+ filePath = newFile.absolutePath,
327
+ fileName = newFile.name
328
+ )
329
+ true
330
+ } else {
331
+ false
332
+ }
333
+ }
334
+
335
+ fun moveToRecycleBin(): Boolean {
336
+ return try {
337
+ val file = File(_uiState.value.filePath)
338
+ if (!file.exists()) return false
339
+
340
+ // Assume the notes root is the parent of the first folder that doesn't start with '.'
341
+ // Or more simply, let's just create a .recycle folder in the same directory as the file for now,
342
+ // or go up until we find a reasonable root.
343
+ // Let's go with a sibling ".recycle" folder in the same directory.
344
+ val parent = file.parentFile ?: return false
345
+ val recycleDir = File(parent, ".recycle")
346
+ if (!recycleDir.exists()) {
347
+ recycleDir.mkdirs()
348
+ }
349
+
350
+ val targetFile = File(recycleDir, file.name)
351
+ // If target exists, append timestamp
352
+ val finalTarget = if (targetFile.exists()) {
353
+ File(recycleDir, "${System.currentTimeMillis()}_${file.name}")
354
+ } else {
355
+ targetFile
356
+ }
357
+
358
+ file.renameTo(finalTarget)
359
+ } catch (e: Exception) {
360
+ e.printStackTrace()
361
+ false
362
+ }
363
+ }
364
+
365
+ fun resetSettings(context: Context) {
366
+ _uiState.update { it.copy(
367
+ fontSize = 18f,
368
+ showLineNumbers = true,
369
+ wordWrap = false,
370
+ backgroundColor = "#FFFFFF",
371
+ autoSave = true,
372
+ showStatusBar = true,
373
+ showSymbolBar = true,
374
+ uiColor = "#F5F5F5",
375
+ tocColor = "#FFFFFF",
376
+ searchColor = "#F5F5F5",
377
+ menuColor = "#FFFFFF"
378
+ ) }
379
+ saveSettings(context)
380
+ }
381
+
382
+ fun getFileDetails(): Map<String, String> {
383
+ val file = File(_uiState.value.filePath)
384
+ val details = mutableMapOf<String, String>()
385
+ details["文件名"] = file.name
386
+ details["路径"] = file.absolutePath
387
+ details["大小"] = "${file.length()} 字节"
388
+ details["最后修改"] = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.getDefault()).format(java.util.Date(file.lastModified()))
389
+ return details
390
+ }
391
+
392
+ private fun saveSettings(context: Context) {
393
+ val prefs = context.getSharedPreferences("editor_settings", Context.MODE_PRIVATE)
394
+ val json = JSONObject().apply {
395
+ put("fontSize", _uiState.value.fontSize.toDouble())
396
+ put("showLineNumbers", _uiState.value.showLineNumbers)
397
+ put("wordWrap", _uiState.value.wordWrap)
398
+ put("backgroundColor", _uiState.value.backgroundColor)
399
+ put("autoSave", _uiState.value.autoSave)
400
+ put("showStatusBar", _uiState.value.showStatusBar)
401
+ put("showSymbolBar", _uiState.value.showSymbolBar)
402
+ put("uiColor", _uiState.value.uiColor)
403
+ put("tocColor", _uiState.value.tocColor)
404
+ put("searchColor", _uiState.value.searchColor)
405
+ put("menuColor", _uiState.value.menuColor)
406
+ }
407
+ prefs.edit().putString("settings_json", json.toString()).apply()
408
+ }
409
+
410
+ fun loadSettings(context: Context) {
411
+ val prefs = context.getSharedPreferences("editor_settings", Context.MODE_PRIVATE)
412
+ val jsonStr = prefs.getString("settings_json", null) ?: return
413
+ try {
414
+ val json = JSONObject(jsonStr)
415
+ _uiState.value = _uiState.value.copy(
416
+ fontSize = json.optDouble("fontSize", 18.0).toFloat(),
417
+ showLineNumbers = json.optBoolean("showLineNumbers", true),
418
+ wordWrap = json.optBoolean("wordWrap", false),
419
+ backgroundColor = json.optString("backgroundColor", "#FFFFFF"),
420
+ autoSave = json.optBoolean("autoSave", true),
421
+ showStatusBar = json.optBoolean("showStatusBar", true),
422
+ showSymbolBar = json.optBoolean("showSymbolBar", true),
423
+ uiColor = json.optString("uiColor", "#F5F5F5"),
424
+ tocColor = json.optString("tocColor", "#FFFFFF"),
425
+ searchColor = json.optString("searchColor", "#F5F5F5"),
426
+ menuColor = json.optString("menuColor", "#FFFFFF")
427
+ )
428
+ } catch (e: Exception) {
429
+ e.printStackTrace()
430
+ }
431
+ }
432
+
433
+ fun applySettingsFromJson(context: Context, jsonStr: String): Boolean {
434
+ return try {
435
+ val json = JSONObject(jsonStr)
436
+ _uiState.value = _uiState.value.copy(
437
+ fontSize = json.optDouble("fontSize", _uiState.value.fontSize.toDouble()).toFloat(),
438
+ showLineNumbers = json.optBoolean("showLineNumbers", _uiState.value.showLineNumbers),
439
+ wordWrap = json.optBoolean("wordWrap", _uiState.value.wordWrap),
440
+ backgroundColor = json.optString("backgroundColor", _uiState.value.backgroundColor),
441
+ autoSave = json.optBoolean("autoSave", _uiState.value.autoSave),
442
+ showStatusBar = json.optBoolean("showStatusBar", _uiState.value.showStatusBar),
443
+ showSymbolBar = json.optBoolean("showSymbolBar", _uiState.value.showSymbolBar),
444
+ uiColor = json.optString("uiColor", _uiState.value.uiColor),
445
+ tocColor = json.optString("tocColor", _uiState.value.tocColor),
446
+ searchColor = json.optString("searchColor", _uiState.value.searchColor),
447
+ menuColor = json.optString("menuColor", _uiState.value.menuColor)
448
+ )
449
+ saveSettings(context)
450
+ true
451
+ } catch (e: Exception) {
452
+ e.printStackTrace()
453
+ false
454
+ }
455
+ }
456
+
457
+ fun getSettingsJson(): String {
458
+ return JSONObject().apply {
459
+ put("fontSize", _uiState.value.fontSize.toDouble())
460
+ put("showLineNumbers", _uiState.value.showLineNumbers)
461
+ put("wordWrap", _uiState.value.wordWrap)
462
+ put("backgroundColor", _uiState.value.backgroundColor)
463
+ put("autoSave", _uiState.value.autoSave)
464
+ put("showStatusBar", _uiState.value.showStatusBar)
465
+ put("showSymbolBar", _uiState.value.showSymbolBar)
466
+ put("uiColor", _uiState.value.uiColor)
467
+ put("tocColor", _uiState.value.tocColor)
468
+ put("searchColor", _uiState.value.searchColor)
469
+ } .toString(4)
470
+ }
471
+ }
@@ -0,0 +1,33 @@
1
+ package com.github.soraeditor.capacitor
2
+
3
+ import android.content.Intent
4
+ import com.getcapacitor.JSObject
5
+ import com.getcapacitor.Plugin
6
+ import com.getcapacitor.PluginCall
7
+ import com.getcapacitor.PluginMethod
8
+ import com.getcapacitor.annotation.CapacitorPlugin
9
+
10
+ @CapacitorPlugin(name = "SoraEditor")
11
+ class SoraEditorPlugin : Plugin() {
12
+
13
+ @PluginMethod
14
+ fun openEditor(call: PluginCall) {
15
+ val filePath = call.getString("filePath") ?: ""
16
+
17
+ android.util.Log.d("SoraEditorPlugin", "openEditor called with filePath: $filePath")
18
+
19
+ if (filePath.isEmpty()) {
20
+ android.util.Log.e("SoraEditorPlugin", "File path is empty")
21
+ call.reject("File path is required")
22
+ return
23
+ }
24
+
25
+ activity.runOnUiThread {
26
+ val intent = Intent(context, EditorActivity::class.java)
27
+ intent.putExtra("FILE_PATH", filePath)
28
+ android.util.Log.d("SoraEditorPlugin", "Starting EditorActivity")
29
+ activity.startActivity(intent)
30
+ call.resolve()
31
+ }
32
+ }
33
+ }