From c0703c35378de450ab6b8ca84e01d675d0aa4994 Mon Sep 17 00:00:00 2001 From: b-dev-mobile Date: Fri, 10 Jul 2026 13:43:01 +0000 Subject: [PATCH] =?UTF-8?q?feat(files):=20=D0=BD=D0=B0=D1=82=D0=B8=D0=B2?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D1=80=D0=B5=D0=B4=D0=B0=D0=BA=D1=82=D0=BE?= =?UTF-8?q?=D1=80=20markdown/=D1=82=D0=B5=D0=BA=D1=81=D1=82=D0=B0;=20white?= =?UTF-8?q?board=20=D1=83=D1=82=D0=BE=D1=87=D0=BD=D1=91=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .md/.markdown/.txt/.org/.log теперь открываются в НАТИВНОМ редакторе (MarkdownRepository GET/PUT по WebDAV) — мгновенно, без тяжёлого веб-Text, который «вечно грузился» (тянул весь Files UI). Экран MarkdownEditorScreen: правка в TextField, предпросмотр md с базовым рендером (заголовки/жирный/ курсив/код/списки/цитаты), сохранение по WebDAV, кнопка активна только при изменениях, back закрывает. - .whiteboard остаётся веб-вьюером (/f/): доска — Excalidraw-канвас с реалтайм-бэкендом (whiteboard.f7cloud.ru + JWT), нативно не реализуется; открывается через штатный viewer Files, как в вебе. Co-Authored-By: Claude Fable 5 --- .../f7cloud/feature/files/FilesScreen.kt | 11 + .../f7cloud/feature/files/FilesUiState.kt | 2 + .../f7cloud/feature/files/FilesViewModel.kt | 12 + .../feature/files/MarkdownEditorScreen.kt | 244 ++++++++++++++++++ .../feature/files/MarkdownRepository.kt | 46 ++++ .../f7cloud/feature/files/WebOpenableFiles.kt | 25 +- 6 files changed, 333 insertions(+), 7 deletions(-) create mode 100644 feature/files/src/main/java/ru/forbion/f7cloud/feature/files/MarkdownEditorScreen.kt create mode 100644 feature/files/src/main/java/ru/forbion/f7cloud/feature/files/MarkdownRepository.kt diff --git a/feature/files/src/main/java/ru/forbion/f7cloud/feature/files/FilesScreen.kt b/feature/files/src/main/java/ru/forbion/f7cloud/feature/files/FilesScreen.kt index 3c5a6ab..fbe476a 100644 --- a/feature/files/src/main/java/ru/forbion/f7cloud/feature/files/FilesScreen.kt +++ b/feature/files/src/main/java/ru/forbion/f7cloud/feature/files/FilesScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items as gridItems +import androidx.activity.compose.BackHandler import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.pulltorefresh.PullToRefreshBox @@ -477,4 +478,14 @@ fun FilesScreen( ) } } + + // Нативный редактор markdown/текста поверх всего (быстрый, вместо тяжёлого веб-Text) + state.markdownDoc?.let { doc -> + BackHandler { vm.closeMarkdown() } + MarkdownEditorScreen( + session = session, + doc = doc, + onClose = { vm.closeMarkdown() }, + ) + } } diff --git a/feature/files/src/main/java/ru/forbion/f7cloud/feature/files/FilesUiState.kt b/feature/files/src/main/java/ru/forbion/f7cloud/feature/files/FilesUiState.kt index 9a0e14b..75db7d1 100644 --- a/feature/files/src/main/java/ru/forbion/f7cloud/feature/files/FilesUiState.kt +++ b/feature/files/src/main/java/ru/forbion/f7cloud/feature/files/FilesUiState.kt @@ -20,6 +20,8 @@ data class FilesUiState( val openingFile: String? = null, val editorLaunch: OfficeEditorLaunch? = null, val openAction: FileOpenAction? = null, + // Нативный markdown-редактор: открытый текстовый файл (путь + имя). + val markdownDoc: MarkdownDoc? = null, val searchQuery: String = "", val searchResults: List = emptyList(), val searchActive: Boolean = false, diff --git a/feature/files/src/main/java/ru/forbion/f7cloud/feature/files/FilesViewModel.kt b/feature/files/src/main/java/ru/forbion/f7cloud/feature/files/FilesViewModel.kt index 514434f..6d61ddd 100644 --- a/feature/files/src/main/java/ru/forbion/f7cloud/feature/files/FilesViewModel.kt +++ b/feature/files/src/main/java/ru/forbion/f7cloud/feature/files/FilesViewModel.kt @@ -487,6 +487,7 @@ class FilesViewModel( if (item.isDirectory) return when { OfficeFiles.isOfficeFile(item.name) -> openOfficeFile(session, item) + MarkdownFiles.isMarkdown(item.name) -> openMarkdown(item) WebOpenableFiles.isWebOpenable(item.name) -> openWebFile(session, item) ArchiveFiles.isArchive(item.name) -> openArchiveFile(session, item) OpenableFiles.isImage(item.name) -> openImageFile(session, item) @@ -496,6 +497,17 @@ class FilesViewModel( } } + /** Markdown/текст — нативный редактор (мгновенно, WebDAV). */ + private fun openMarkdown(item: FileItem) { + _state.value = _state.value.copy( + markdownDoc = MarkdownDoc(relativePath = item.relativePath, name = item.name), + ) + } + + fun closeMarkdown() { + _state.value = _state.value.copy(markdownDoc = null) + } + /** Markdown/текст/доска — открываем каноническую ссылку /f/ в авторизованном WebView. */ private fun openWebFile(session: AuthSession, item: FileItem) { val fileId = item.fileId ?: run { diff --git a/feature/files/src/main/java/ru/forbion/f7cloud/feature/files/MarkdownEditorScreen.kt b/feature/files/src/main/java/ru/forbion/f7cloud/feature/files/MarkdownEditorScreen.kt new file mode 100644 index 0000000..0971205 --- /dev/null +++ b/feature/files/src/main/java/ru/forbion/f7cloud/feature/files/MarkdownEditorScreen.kt @@ -0,0 +1,244 @@ +package ru.forbion.f7cloud.feature.files + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import ru.forbion.f7cloud.core.auth.AuthSession +import ru.forbion.f7cloud.core.designsystem.F7Colors +import ru.forbion.f7cloud.core.designsystem.f7SafeTopInsets + +/** + * Нативный редактор markdown/текста: качает содержимое по WebDAV (мгновенно), правка в + * TextField, предпросмотр с базовым рендером, сохранение по WebDAV PUT. Заменяет тяжёлый + * веб-редактор Text (который «вечно грузился», т.к. тянул весь Files UI). + */ +@Composable +fun MarkdownEditorScreen( + session: AuthSession, + doc: MarkdownDoc, + onClose: () -> Unit, + repository: MarkdownRepository = remember { MarkdownRepository() }, +) { + var text by remember(doc.relativePath) { mutableStateOf("") } + var loading by remember(doc.relativePath) { mutableStateOf(true) } + var saving by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + var preview by remember { mutableStateOf(false) } + var dirty by remember(doc.relativePath) { mutableStateOf(false) } + val scope = rememberCoroutineScope() + val isMd = doc.name.substringAfterLast('.', "").lowercase() in setOf("md", "markdown") + + LaunchedEffect(doc.relativePath) { + loading = true + error = null + runCatching { withContext(Dispatchers.IO) { repository.load(session, doc.relativePath) } } + .onSuccess { text = it; loading = false } + .onFailure { error = it.message ?: "Ошибка загрузки"; loading = false } + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(F7Colors.Background) + .f7SafeTopInsets(), + ) { + // Шапка: назад, имя, предпросмотр (для md), сохранить + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Назад", + tint = F7Colors.TextPrimary, + modifier = Modifier.size(24.dp).clickable(onClick = onClose), + ) + Text( + doc.name, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = F7Colors.TextPrimary, + maxLines = 1, + ) + if (isMd) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(100.dp)) + .background(if (preview) F7Colors.PrimaryLight else F7Colors.SurfaceMuted) + .clickable { preview = !preview } + .padding(horizontal = 12.dp, vertical = 6.dp), + ) { + Text( + if (preview) "Правка" else "Просмотр", + style = MaterialTheme.typography.bodyMedium, + color = F7Colors.TextPrimary, + ) + } + } + Box( + modifier = Modifier + .clip(RoundedCornerShape(100.dp)) + .background(if (dirty && !saving) F7Colors.Primary else F7Colors.SurfaceMuted) + .clickable(enabled = dirty && !saving) { + saving = true + error = null + scope.launch { + runCatching { + withContext(Dispatchers.IO) { repository.save(session, doc.relativePath, text) } + }.onSuccess { saving = false; dirty = false } + .onFailure { error = it.message ?: "Ошибка сохранения"; saving = false } + } + } + .padding(horizontal = 14.dp, vertical = 6.dp), + ) { + Text( + if (saving) "…" else "Сохранить", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = if (dirty && !saving) androidx.compose.ui.graphics.Color.White else F7Colors.TextSecondary, + ) + } + } + error?.let { + Text(it, color = F7Colors.Error, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) + } + + when { + loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(color = F7Colors.Primary) + } + preview && isMd -> { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + ) { + Text(renderMarkdown(text), style = MaterialTheme.typography.bodyLarge, color = F7Colors.TextPrimary) + } + } + else -> { + BasicTextField( + value = text, + onValueChange = { text = it; dirty = true }, + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + textStyle = TextStyle( + fontSize = 15.sp, + lineHeight = 22.sp, + color = F7Colors.TextPrimary, + fontFamily = if (isMd) FontFamily.Monospace else FontFamily.Default, + ), + cursorBrush = SolidColor(F7Colors.Primary), + ) + } + } + } +} + +/** Лёгкий рендер markdown → AnnotatedString: заголовки, жирный/курсив, списки, код, цитаты. */ +private fun renderMarkdown(src: String): AnnotatedString = buildAnnotatedString { + src.lineSequence().forEachIndexed { index, raw -> + if (index > 0) append("\n") + val line = raw.trimEnd() + when { + line.startsWith("### ") -> withStyle(SpanStyle(fontSize = 16.sp, fontWeight = FontWeight.Bold)) { + append(line.removePrefix("### ")) + } + line.startsWith("## ") -> withStyle(SpanStyle(fontSize = 18.sp, fontWeight = FontWeight.Bold)) { + append(line.removePrefix("## ")) + } + line.startsWith("# ") -> withStyle(SpanStyle(fontSize = 22.sp, fontWeight = FontWeight.Bold)) { + append(line.removePrefix("# ")) + } + line.startsWith("> ") -> withStyle( + SpanStyle(fontStyle = FontStyle.Italic, color = androidx.compose.ui.graphics.Color(0xFF808080)), + ) { append(line.removePrefix("> ")) } + line.startsWith("- ") || line.startsWith("* ") -> { + append("• ") + appendInline(line.drop(2)) + } + line.matches(Regex("^\\d+\\. .*")) -> appendInline(line) + line.startsWith("```") -> withStyle(SpanStyle(fontFamily = FontFamily.Monospace)) { append(line) } + else -> appendInline(line) + } + } +} + +/** Инлайн-разметка: **жирный**, *курсив*, `код`. */ +private fun androidx.compose.ui.text.AnnotatedString.Builder.appendInline(text: String) { + var i = 0 + while (i < text.length) { + when { + text.startsWith("**", i) -> { + val end = text.indexOf("**", i + 2) + if (end > 0) { + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(text.substring(i + 2, end)) } + i = end + 2 + } else { append(text[i]); i++ } + } + text[i] == '*' -> { + val end = text.indexOf('*', i + 1) + if (end > 0) { + withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { append(text.substring(i + 1, end)) } + i = end + 1 + } else { append(text[i]); i++ } + } + text[i] == '`' -> { + val end = text.indexOf('`', i + 1) + if (end > 0) { + withStyle(SpanStyle(fontFamily = FontFamily.Monospace)) { append(text.substring(i + 1, end)) } + i = end + 1 + } else { append(text[i]); i++ } + } + else -> { append(text[i]); i++ } + } + } +} diff --git a/feature/files/src/main/java/ru/forbion/f7cloud/feature/files/MarkdownRepository.kt b/feature/files/src/main/java/ru/forbion/f7cloud/feature/files/MarkdownRepository.kt new file mode 100644 index 0000000..187132c --- /dev/null +++ b/feature/files/src/main/java/ru/forbion/f7cloud/feature/files/MarkdownRepository.kt @@ -0,0 +1,46 @@ +package ru.forbion.f7cloud.feature.files + +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import ru.forbion.f7cloud.core.auth.AuthSession +import ru.forbion.f7cloud.core.auth.OcsUserResolver +import ru.forbion.f7cloud.core.network.NetworkFactory +import ru.forbion.f7cloud.core.network.davFileUrl + +/** + * Нативная работа с текстовыми/markdown-файлами по WebDAV — быстро, без тяжёлого веб-редактора + * (веб Text грузил весь Files UI). Просто GET текста и PUT при сохранении. + */ +class MarkdownRepository { + private fun client(session: AuthSession) = + NetworkFactory.newAuthedClient(session.username, session.appPassword, session.trustAllCerts) + + private fun url(session: AuthSession, relativePath: String): String { + val userId = session.davUserId ?: OcsUserResolver.resolveDavUserId(session) + return davFileUrl(session.serverUrl, userId, relativePath) + } + + fun load(session: AuthSession, relativePath: String): String { + val request = Request.Builder().url(url(session, relativePath)).header("Accept", "*/*").get().build() + client(session).newCall(request).execute().use { response -> + if (!response.isSuccessful || response.body == null) { + error("Не удалось загрузить файл (HTTP ${response.code})") + } + return response.body!!.string() + } + } + + fun save(session: AuthSession, relativePath: String, content: String) { + val media = "text/markdown; charset=utf-8".toMediaType() + val request = Request.Builder() + .url(url(session, relativePath)) + .put(content.toRequestBody(media)) + .build() + client(session).newCall(request).execute().use { response -> + if (response.code !in 200..299 && response.code != 204) { + error("Не удалось сохранить (HTTP ${response.code})") + } + } + } +} diff --git a/feature/files/src/main/java/ru/forbion/f7cloud/feature/files/WebOpenableFiles.kt b/feature/files/src/main/java/ru/forbion/f7cloud/feature/files/WebOpenableFiles.kt index 776be13..823c50b 100644 --- a/feature/files/src/main/java/ru/forbion/f7cloud/feature/files/WebOpenableFiles.kt +++ b/feature/files/src/main/java/ru/forbion/f7cloud/feature/files/WebOpenableFiles.kt @@ -1,16 +1,27 @@ package ru.forbion.f7cloud.feature.files /** - * Файлы, которые открываются во ВЕБ-вьюере Nextcloud по канонической ссылке - * `/index.php/f/` (сервер сам направляет в нужное приложение): - * markdown/текст → Text, доска → Whiteboard. В отличие от office (Collabora direct-edit). + * Файлы, открываемые во ВЕБ-вьюере Nextcloud по ссылке `/index.php/f/` — сервер + * направляет в нужное приложение. Сейчас это только доска (Whiteboard, Excalidraw-канвас + * с реалтайм-бэкендом — нативно не реализуется). Markdown/текст — нативно, см. [MarkdownFiles]. */ object WebOpenableFiles { - private val EXTENSIONS = setOf( - "md", "markdown", "txt", "text", "org", - "whiteboard", - ) + private val EXTENSIONS = setOf("whiteboard") fun isWebOpenable(name: String): Boolean = name.substringAfterLast('.', "").lowercase() in EXTENSIONS } + +/** Текстовые/markdown-файлы — открываются в НАТИВНОМ редакторе (быстро, WebDAV). */ +object MarkdownFiles { + private val EXTENSIONS = setOf("md", "markdown", "txt", "text", "org", "log") + + fun isMarkdown(name: String): Boolean = + name.substringAfterLast('.', "").lowercase() in EXTENSIONS +} + +/** Открытый в нативном редакторе текстовый файл. */ +data class MarkdownDoc( + val relativePath: String, + val name: String, +)