diff --git a/CHANGELOG.md b/CHANGELOG.md index 75be9c9fbb0f..620ef0069db3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,13 @@ takes 500 characters, so not everything here reaches the store. ## Unreleased +- Editing reaches PowerPoint files, spreadsheet cells and plain text files, has undo and + redo, and no longer loads the document again, so the page stays where it was. +- Pro also formats text, starts and joins paragraphs, and marks up PDFs: highlight, + underline, strike out, squiggly underline, and drawing. +- Leaving the edit mode with unsaved changes asks whether to save or discard them. +- Word files show a text's shading, and text that is both underlined and struck through + shows both lines. - A spreadsheet too big to show in full says so, and names how many of its rows and columns are on screen. It used to stop without a word. - How much of a sheet is shown follows the device's memory now, rather than one diff --git a/CLAUDE.md b/CLAUDE.md index 0281c8ea3e1d..717ec209fb62 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -115,13 +115,20 @@ and `src/review`, with a no-op of the same shape in `src/noAds` and `src/noRevie nothing proprietary and stays in `src/main`. A method added to one copy has to be added to the other, which `assembleDebug` catches - it builds all three. -Code that has to *ask* reads `Features`, never the flavor name: `Features.withAds` is the -one question anything asks today, and `LINKS_ADS` behind it sits in `src/ads` and -`src/noAds` next to the classes it stands for, so the flag cannot end up in a build whose -code says otherwise. Do not add a `BuildConfig.FLAVOR` comparison back - it was what made -`BillingManager` miss foss - and do not name a flag after a behaviour it only implies. The -resource bool `DISABLE_TRACKING` was both mistakes at once: there is no tracking to -disable, `AnalyticsManager` and `CrashManager` write to logcat and nowhere else. +Code that has to *ask* reads `Features`, never the flavor name. `Features.withAds` comes +from `LINKS_ADS`, which sits in `src/ads` and `src/noAds` next to the classes it stands for, +so the flag cannot end up in a build whose code says otherwise. `Features.advancedEditing`, +from `ADVANCED_EDITING` in the same two files, is what pro is sold on: new and joined +paragraphs, formatting, and marks on a pdf. Every other edit the core takes - inside one +paragraph, a sheet cell, a plain text file - is in every build. `Features.offersEditing` is the +one list of it; the Edit button still stands on the core's answer, so over a pdf in lite it +offers pro instead. `OpenDocument.ios` draws the same line with the same flag, beside its own +`LINKS_ADS`. + +Do not add a `BuildConfig.FLAVOR` comparison back - it was what made `BillingManager` miss +foss - and do not name a flag after a behaviour it only implies. The resource bool +`DISABLE_TRACKING` was both mistakes at once: there is no tracking to disable, +`AnalyticsManager` and `CrashManager` write to logcat and nowhere else. Those two take no switch at all, which is why `DocumentLoader` just constructs them. Ads and billing are what `MainActivity.initializeManagers` gates, on `Features.withAds` *and* @@ -296,19 +303,34 @@ deck opened in portrait keeps a portrait-sized slide in a landscape screen. `ini ### Editability comes from the core, never from a mime type -`Document.isEditable()`/`isSavable()` decides whether `DocumentFragment` offers the Edit -button, carried on `LoadedDocument.isEditable`. `CoreLoader.host()` only holds a document -open when the core says yes, so having one *is* the answer. Do not reintroduce a list of -editable formats in the UI. +`CoreLoader.editingOf` asks the opened file what the user can change, and the answer rides on +`LoadedDocument.editing` as an `EditingKind`: `DOCUMENT` for a text document or a +presentation, `SHEET`, `TEXT` for a plain file, `ANNOTATION` for a pdf, `NONE`. It is the +file's own answer - `Document.isEditable()`/`isSavable()`, `TextFile.isSavable()`, +`PdfFile.isAnnotatable()` - so a decrypted document or a repaired pdf says no. Do not +reintroduce a list of editable formats in the UI. `DecodedFile.capabilities()` is asked first, as a shortcut: opening a document costs a second parse, so a format declaring no `edit`/`save` is never opened to be told no. It is an -upper bound - the document still answers. +upper bound - the file still answers. Decryption is the same shape. `capabilities().decrypt` says whether a password is worth asking for, and `CoreLoader.host` refuses an encrypted `.doc`, `.ppt` or `.xls` on it rather than raising a dialog no password can close. The app must not learn that list for itself. +**The editor is in the page, and it is always there.** A document the core can write back is +rendered with `HtmlConfig.editable`, and the edit button only calls `odr.editing.enable()` - +no second render, so the reader stays where they were. The page owns the operation log, undo +and the refusals; `editing-bridge.js` (injected by `PageView` on every page load) forwards its +callbacks. Lite narrows `HtmlConfig.editingScope` to `PARAGRAPH`, and the page refuses the +rest with `outOfScope`, which `DocumentFragment` answers with the offer of pro, once an edit. +A pdf needs no scaffolding: every pdf page carries `odr.annotation`. + +**Nothing is held open between the render and the save.** `CoreLoader.writeEdits` opens the +cached copy again and applies the page's payload with the call its kind takes - +`Document.edit` and `save`, `TextFile.edit` and `save`, `PdfFile.annotate`. An edit that throws +halfway leaves the document it was applied to half changed, so a retry must not start from it. + ### Storage access The app declares **no storage permission**, only `INTERNET`, and has to stay that way: diff --git a/README.md b/README.md index 857172ca4a95..142d19d1d2f1 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,16 @@ a sideload carrying an older one neither updates nor complains: install the new uninstall the old one. Nothing carries over - a recent documents list whose uri permissions die with the old package anyway. +## Editions + +Play carries two apps: OpenDocument Reader, free with ads, and OpenDocument Reader Pro, +paid. Both open everything, and both edit: the text of a document inside one paragraph, the +cells of a spreadsheet, and plain text files. Pro also starts and joins paragraphs, formats +text, and marks up PDFs. + +The F-Droid build and the apk on the release page are Pro without Play's review sheet: no +ads, and every edit. + ## Translations The app speaks nineteen languages and the Play listing fifteen, and both are written diff --git a/app/src/ads/java/app/opendocument/droid/nonfree/Linked.kt b/app/src/ads/java/app/opendocument/droid/nonfree/Linked.kt index 3c96b4c2c084..876a0c90ddf1 100644 --- a/app/src/ads/java/app/opendocument/droid/nonfree/Linked.kt +++ b/app/src/ads/java/app/opendocument/droid/nonfree/Linked.kt @@ -2,3 +2,6 @@ package app.opendocument.droid.nonfree /** Read through [Features]. */ internal const val LINKS_ADS = true + +/** Read through [Features]. */ +internal const val ADVANCED_EDITING = false diff --git a/app/src/androidTest/java/app/opendocument/droid/background/DocumentParcelTest.kt b/app/src/androidTest/java/app/opendocument/droid/background/DocumentParcelTest.kt index 0e996f45f478..434f5337979f 100644 --- a/app/src/androidTest/java/app/opendocument/droid/background/DocumentParcelTest.kt +++ b/app/src/androidTest/java/app/opendocument/droid/background/DocumentParcelTest.kt @@ -106,7 +106,7 @@ class DocumentParcelTest { ), // the middle sheet is the only one the budget cut listOf(null, SheetCut(80000, 12, 8333, 12), null), - isEditable = true, + editing = EditingKind.DOCUMENT, readsAsDocument = true, ) @@ -116,7 +116,7 @@ class DocumentParcelTest { assertEquals("budget.ods", restored.file.filename) assertEquals(listOf("hey", "ho", "Sheet3"), restored.partTitles) assertEquals(document.partUris, restored.partUris) - assertTrue(restored.isEditable) + assertEquals(EditingKind.DOCUMENT, restored.editing) assertTrue(restored.readsAsDocument) assertNull(restored.partCuts[0]) @@ -147,7 +147,7 @@ class DocumentParcelTest { listOf(null), listOf(Uri.parse("http://localhost:29665/file/odr/document.html")), listOf(null), - isEditable = false, + editing = EditingKind.NONE, readsAsDocument = true, ), LoadedDocument.CREATOR, @@ -156,7 +156,7 @@ class DocumentParcelTest { assertEquals(1, restored.partTitles.size) assertNull(restored.partTitles[0]) assertNull(restored.partCuts[0]) - assertEquals(false, restored.isEditable) + assertEquals(EditingKind.NONE, restored.editing) assertTrue(restored.readsAsDocument) } diff --git a/app/src/androidTest/java/app/opendocument/droid/test/CoreTest.kt b/app/src/androidTest/java/app/opendocument/droid/test/CoreTest.kt index ee7e1b2a6ae4..14ddc8e8cfb5 100644 --- a/app/src/androidTest/java/app/opendocument/droid/test/CoreTest.kt +++ b/app/src/androidTest/java/app/opendocument/droid/test/CoreTest.kt @@ -6,6 +6,7 @@ import androidx.test.platform.app.InstrumentationRegistry import app.opendocument.core.FileType import app.opendocument.core.OdrException import app.opendocument.droid.background.CoreLoader +import app.opendocument.droid.background.EditingKind import app.opendocument.droid.background.SpreadsheetBudget import app.opendocument.droid.nonfree.CrashManager import java.io.File @@ -25,44 +26,100 @@ class CoreTest { get() = checkNotNull(sharedLoader) { "the core loader was not started" } @Test - fun test() { - val views = - coreLoader.host( - prefix = "test", - inputPath = testFile.absolutePath, - cachePath = File(cacheDir(), "core_cache").path, - editable = true, - keepDocument = true, - ) - Assert.assertFalse("hosting the ODT file should produce a view", views.isEmpty()) + fun testOdtEdit() { + assertEditRoundTrips("odt-edit", testFile) + } + + @Test + fun testDocxEdit() { + assertEditRoundTrips("docx-edit", docxTestFile) + } - val htmlDiff = - "{\"modifiedText\":{\"/child:1/child:0\":\"This is a simple testoooo document to" + - " demonstrate the DocumentLoader example!\",\"/child:3/child:0\":\"This is a" + - " simple testaaaa document to demonstrate the DocumentLoader example!\"}}" + @Test + fun testPptxEdit() { + assertEditRoundTrips("pptx-edit", pptxTestFile) + } - val result = coreLoader.edit(htmlDiff, File(cacheDir(), "result").path) + /** Writes one run of [file] as the page's editor does, and reads the saved file back. */ + private fun assertEditRoundTrips(prefix: String, file: File) { + val html = + URL(coreLoader.host(prefix, file.absolutePath, askEditing = true)[0].url).readText() + + val id = + checkNotNull(RUN_ID.find(html)) { "the editable render of ${file.name} names no run" } + .groupValues[1] + + val payload = """{"version":2,"ops":[{"op":"setText","id":$id,"text":"$EDITED"}]}""" + + val result = + coreLoader.writeEdits( + file.absolutePath, + null, + null, + coreLoader.editing, + payload, + File(cacheDir(), "$prefix-result").path, + ) Assert.assertTrue("the edited document should have been saved", result.isFile) + + val saved = URL(coreLoader.host("$prefix-saved", result.absolutePath)[0].url).readText() + Assert.assertTrue("the saved ${file.name} should carry the edit", saved.contains(EDITED)) + + result.delete() } + /** A pdf takes marks, which the core appends to a copy of it as annotations. */ @Test - fun testDocxEdit() { - val views = - coreLoader.host( - prefix = "docx-edit", - inputPath = docxTestFile.absolutePath, - cachePath = File(cacheDir(), "core_cache").path, - editable = true, - keepDocument = true, + fun testPdfAnnotation() { + val payload = + """{"version":1,"annotations":[{"page":0,"type":"highlight",""" + + """"quads":[[72,720,200,720,72,700,200,700]],"color":[1,0.9,0.2]}]}""" + + val result = + coreLoader.writeEdits( + pdfTestFile.absolutePath, + null, + null, + EditingKind.ANNOTATION, + payload, + File(cacheDir(), "pdf-annotate-result").path, ) - Assert.assertFalse("hosting the DOCX file should produce a view", views.isEmpty()) - val htmlDiff = - "{\"modifiedText\":{\"/child:16/child:0/child:0\":\"Outasdfsdafdline\",\"/child:24/child:0/child:0\":\"Colorasdfasdfasdfed" + - " Line\",\"/child:6/child:0/child:0\":\"Text hello world!\"}}" + Assert.assertTrue( + "the annotated pdf should hold more than the original", + result.length() > pdfTestFile.length(), + ) + Assert.assertTrue( + "the annotation should have been appended", + String(result.readBytes(), Charsets.ISO_8859_1).contains("/Highlight"), + ) - val result = coreLoader.edit(htmlDiff, File(cacheDir(), "result_docx").path) - Assert.assertTrue("the edited document should have been saved", result.isFile) + result.delete() + } + + /** A plain text file is edited whole, and saved as utf-8. */ + @Test + fun testTextEdit() { + val text = File(cacheDir(), "plain.txt") + text.writeText("before\n") + extracted += text + + coreLoader.host("text-edit", text.absolutePath, askEditing = true) + Assert.assertEquals(EditingKind.TEXT, coreLoader.editing) + + val result = + coreLoader.writeEdits( + text.absolutePath, + null, + null, + EditingKind.TEXT, + """{"version":2,"ops":[{"op":"setContent","text":"$EDITED"}]}""", + File(cacheDir(), "text-edit-result").path, + ) + + Assert.assertEquals(EDITED, result.readText()) + + result.delete() } /** @@ -75,7 +132,6 @@ class CoreTest { coreLoader.host( prefix = "pptx-test", inputPath = pptxTestFile.absolutePath, - cachePath = File(cacheDir(), "pptx_cache").path, ) Assert.assertFalse("hosting the PPTX file should produce a view", views.isEmpty()) } @@ -86,7 +142,6 @@ class CoreTest { coreLoader.host( prefix = "doc-test", inputPath = docTestFile.absolutePath, - cachePath = File(cacheDir(), "doc_cache").path, ) Assert.assertFalse("hosting the DOC file should produce a view", views.isEmpty()) } @@ -97,7 +152,6 @@ class CoreTest { coreLoader.host( prefix = "ppt-test", inputPath = pptTestFile.absolutePath, - cachePath = File(cacheDir(), "ppt_cache").path, ) Assert.assertFalse("hosting the PPT file should produce a view", views.isEmpty()) } @@ -108,42 +162,35 @@ class CoreTest { coreLoader.host( prefix = "xls-test", inputPath = xlsTestFile.absolutePath, - cachePath = File(cacheDir(), "xls_cache").path, ) Assert.assertFalse("hosting the XLS file should produce a view", views.isEmpty()) } /** - * Which of the formats the core renders it can also write back again - the answer - * `DocumentFragment` puts the Edit button up by. + * What the core lets the user change in each of the formats it renders - the answer + * `DocumentFragment` puts the Edit button up by, and picks the tools with. */ @Test fun testEditableFormats() { - assertEditable("odt-editable", testFile, true) - assertEditable("docx-editable", docxTestFile, true) - - // the core declares these read only: the three legacy binary formats, ooxml presentations - // and every spreadsheet - the last being issue #442, which the core has its own TODO for - assertEditable("doc-editable", docTestFile, false) - assertEditable("ppt-editable", pptTestFile, false) - assertEditable("xls-editable", xlsTestFile, false) - assertEditable("pptx-editable", pptxTestFile, false) - assertEditable("ods-editable", spreadsheetTestFile, false) + assertEditing("odt-editable", testFile, EditingKind.DOCUMENT) + assertEditing("docx-editable", docxTestFile, EditingKind.DOCUMENT) + assertEditing("pptx-editable", pptxTestFile, EditingKind.DOCUMENT) + assertEditing("ods-editable", spreadsheetTestFile, EditingKind.SHEET) + assertEditing("pdf-editable", pdfTestFile, EditingKind.ANNOTATION) + + // the core declares the three legacy binary formats read only + assertEditing("doc-editable", docTestFile, EditingKind.NONE) + assertEditing("ppt-editable", pptTestFile, EditingKind.NONE) + assertEditing("xls-editable", xlsTestFile, EditingKind.NONE) } - private fun assertEditable(prefix: String, file: File, expected: Boolean) { - coreLoader.host( - prefix = prefix, - inputPath = file.absolutePath, - cachePath = File(cacheDir(), prefix).path, - editable = true, - keepDocument = true, - ) + private fun assertEditing(prefix: String, file: File, expected: EditingKind) { + coreLoader.host(prefix = prefix, inputPath = file.absolutePath, askEditing = true) Assert.assertEquals( - "the core should report ${file.name} as ${if (expected) "editable" else "read only"}", + "what the core lets the user change in ${file.name}", expected, - coreLoader.isDocumentEditable, + coreLoader.editing, ) } @@ -153,7 +200,6 @@ class CoreTest { coreLoader.host( prefix = "password-test-no-pw", inputPath = passwordTestFile.absolutePath, - cachePath = File(cacheDir(), "core_cache").path, ) } } @@ -164,7 +210,6 @@ class CoreTest { coreLoader.host( prefix = "password-test-wrong-pw", inputPath = passwordTestFile.absolutePath, - cachePath = File(cacheDir(), "core_cache").path, password = "wrongpassword", ) } @@ -176,7 +221,6 @@ class CoreTest { coreLoader.host( prefix = "password-test-correct-pw", inputPath = passwordTestFile.absolutePath, - cachePath = File(cacheDir(), "core_cache").path, password = "passwort", ) Assert.assertFalse("the decrypted document should produce a view", views.isEmpty()) @@ -192,15 +236,14 @@ class CoreTest { coreLoader.host( prefix = "password-test-editable", inputPath = passwordTestFile.absolutePath, - cachePath = File(cacheDir(), "password_editable").path, password = "passwort", - editable = true, - keepDocument = true, + askEditing = true, ) - Assert.assertFalse( + Assert.assertEquals( "a decrypted document should not be editable", - coreLoader.isDocumentEditable, + EditingKind.NONE, + coreLoader.editing, ) } @@ -214,7 +257,6 @@ class CoreTest { coreLoader.host( prefix = "encrypted-doc", inputPath = encryptedDocTestFile.absolutePath, - cachePath = File(cacheDir(), "encrypted_doc_cache").path, ) } @@ -223,7 +265,6 @@ class CoreTest { coreLoader.host( prefix = "encrypted-doc-pw", inputPath = encryptedDocTestFile.absolutePath, - cachePath = File(cacheDir(), "encrypted_doc_cache").path, password = "passwort", ) } @@ -236,7 +277,6 @@ class CoreTest { coreLoader.host( prefix = "encrypted-odt-prompts", inputPath = passwordTestFile.absolutePath, - cachePath = File(cacheDir(), "core_cache").path, ) } } @@ -267,7 +307,6 @@ class CoreTest { coreLoader.host( prefix = "odt-called-pdf", inputPath = testFile.absolutePath, - cachePath = File(cacheDir(), "odt_called_pdf").path, declaredType = FileType.PORTABLE_DOCUMENT_FORMAT, ) @@ -280,7 +319,6 @@ class CoreTest { coreLoader.host( prefix = prefix, inputPath = file.absolutePath, - cachePath = File(cacheDir(), prefix).path, declaredType = declaredType, ) @@ -304,7 +342,6 @@ class CoreTest { coreLoader.host( prefix = "big-sheet", inputPath = generateCsv(rows, columns).absolutePath, - cachePath = File(cacheDir(), "big_sheet_cache").path, ) val cut = @@ -323,7 +360,6 @@ class CoreTest { coreLoader.host( prefix = "whole-sheet", inputPath = spreadsheetTestFile.absolutePath, - cachePath = File(cacheDir(), "whole_sheet_cache").path, ) views.forEach { Assert.assertNull("nothing was cut from " + it.name, it.sheetCut) } @@ -335,7 +371,6 @@ class CoreTest { coreLoader.host( prefix = "spreadsheet-test", inputPath = spreadsheetTestFile.absolutePath, - cachePath = File(cacheDir(), "spreadsheet_cache").path, ) Assert.assertEquals("ODS file should contain 3 sheets", 3, views.size) @@ -363,6 +398,11 @@ class CoreTest { private lateinit var encryptedDocTestFile: File private lateinit var pdfTestFile: File + /** The address the editable render puts on a run of text. */ + private val RUN_ID = Regex("""]*data-odr-id="(\d+)"""") + + private const val EDITED = "Edited by CoreTest" + /** What a document saved straight out of a browser carries in front of itself. */ private const val HTTP_PREAMBLE = "HTTP/1.0 200 OK\r\n" + diff --git a/app/src/androidTest/java/app/opendocument/droid/test/MainActivityTests.kt b/app/src/androidTest/java/app/opendocument/droid/test/MainActivityTests.kt index 433121d79109..69f1959719fb 100644 --- a/app/src/androidTest/java/app/opendocument/droid/test/MainActivityTests.kt +++ b/app/src/androidTest/java/app/opendocument/droid/test/MainActivityTests.kt @@ -35,6 +35,7 @@ import androidx.test.runner.lifecycle.Stage import app.opendocument.droid.R import app.opendocument.droid.background.PaginationSetting import app.opendocument.droid.background.ReviewInvitation +import app.opendocument.droid.nonfree.Features import app.opendocument.droid.ui.EditActionModeCallback import app.opendocument.droid.ui.OpenFileIdling import app.opendocument.droid.ui.activity.DocumentFragment @@ -150,12 +151,60 @@ class MainActivityTests { // next onView will be blocked until the idling resource is idle, which now covers // the load itself and not just the picker round trip. the buttons being up is what - // says the pdf opened - Edit is not, because the core does not write pdf back + // says the pdf opened waitForDocumentActions() - // no unfolding first: every unfolding row is in the hierarchy whether the column is - // open or not, and doesNotExist walks all of it + // a pdf is marked up, not edited. no unfolding first: every unfolding row is in the + // hierarchy whether the column is open or not, and doesNotExist walks all of it onView(withContentDescription(R.string.menu_edit)).check(doesNotExist()) + + // the button is there in every edition: pro marks, lite says what pro would do + onView(withContentDescription(R.string.menu_annotate)).perform(click()) + + if (Features.advancedEditing) { + onView(withContentDescription(R.string.tool_mark_draw)).check(matches(isDisplayed())) + } else { + awaitViewWithText(R.string.pro_offer_title) + onView(withText(R.string.pro_offer_title)).check(matches(isDisplayed())) + } + } + + /** A sheet takes cell edits in every edition. */ + @Test + fun aSheetIsEditedInEveryEdition() { + respondToOpenDocumentWith(requireTestFile("spreadsheet-test.ods")) + + openDocumentThroughPicker() + waitForDocumentActions() + + onView(withContentDescription(R.string.menu_edit)).perform(click()) + + val activity = mainActivityActivityTestRule.activity + val pageView = requireNotNull(waitForDocumentFragment(activity, 10000)?.pageView) + + Assert.assertTrue( + "the sheet should turn editable", + waitFor(EDIT_MODE_TIMEOUT_MS) { pageAnswers(pageView, "odr.editing.isEnabled()") }, + ) + onView(withText(R.string.pro_offer_title)).check(doesNotExist()) + } + + /** Lite edits a text document inside one paragraph, and the page itself holds it to that. */ + @Test + fun theEditionDecidesHowFarAnEditReaches() { + val activity = mainActivityActivityTestRule.activity + val documentFragment = loadDocument(activity, requireTestFile("test.odt")) + val pageView = requireNotNull(documentFragment.pageView) + + val expected = if (Features.advancedEditing) "document" else "paragraph" + + Assert.assertTrue( + "the page should state the scope $expected", + waitFor(EDIT_MODE_TIMEOUT_MS) { + evaluateJavascript(pageView, "window.odr && odr.editing.scope()") + ?.replace("\"", "") == expected + }, + ) } @Test @@ -779,6 +828,10 @@ class MainActivityTests { return result.get() } + /** Whether [expression] is true in the page; false too where the page did not answer. */ + private fun pageAnswers(pageView: PageView, expression: String): Boolean = + evaluateJavascript(pageView, "!!(window.odr && $expression)")?.replace("\"", "") == "true" + private fun requireTestFile(name: String): File = checkNotNull(testFiles[name]) { "test file was not extracted: $name" } @@ -833,6 +886,7 @@ class MainActivityTests { "password-test.odt", "style-various-1.docx", "corrupt.odt", + "spreadsheet-test.ods", )) { val targetFile = File(testDocumentsDir, filename) copy(testAssetManager.open(filename), targetFile) diff --git a/app/src/main/assets/editing-bridge.js b/app/src/main/assets/editing-bridge.js new file mode 100644 index 000000000000..8dde6a001446 --- /dev/null +++ b/app/src/main/assets/editing-bridge.js @@ -0,0 +1,35 @@ +// Injected by PageView into every page the core serves, after the page's own scripts. It points +// the page's editing callbacks at the app's bridge; what they mean is the page's. +(function () { + "use strict"; + + var odr = window.odr; + var bridge = window.paragraphListener; + + // a page with no scripts of the core's, or a page the bridge is not attached to + if (!odr || !bridge) { + return; + } + + odr.onEditChange = function (event) { + bridge.editChanged(!!event.dirty, !!event.canUndo, !!event.canRedo); + }; + odr.onEditRefused = function (event) { + bridge.editRefused(String(event.reason || "")); + }; + odr.onSelectionChange = function (style) { + bridge.selectionChanged(JSON.stringify(style || {})); + }; + odr.onCellsStale = function (detail) { + bridge.cellsStale(detail && detail.cells ? detail.cells.length : 0); + }; + odr.onAnnotationChange = function (event) { + bridge.marksChanged(event.count); + }; + + if (odr.annotation) { + // an armed tool marks a selection as it is made, which is what a touch screen needs: with a + // selection standing, the selection's own toolbar is over the page + odr.annotation.setOptions({ markOnSelection: true }); + } +})(); diff --git a/app/src/main/java/app/opendocument/droid/background/CoreLoader.kt b/app/src/main/java/app/opendocument/droid/background/CoreLoader.kt index ca2d74ae83f5..52dc8ede0d28 100644 --- a/app/src/main/java/app/opendocument/droid/background/CoreLoader.kt +++ b/app/src/main/java/app/opendocument/droid/background/CoreLoader.kt @@ -4,47 +4,44 @@ import android.content.Context import android.net.Uri import android.system.Os import android.util.Log -import app.opendocument.core.DecodePreference +import app.opendocument.core.DecodeOptions import app.opendocument.core.DecodedFile -import app.opendocument.core.Document import app.opendocument.core.DocumentType import app.opendocument.core.FileCategory import app.opendocument.core.FileType import app.opendocument.core.Html import app.opendocument.core.HtmlColorScheme import app.opendocument.core.HtmlConfig +import app.opendocument.core.HtmlEditingScope import app.opendocument.core.HtmlView import app.opendocument.core.HttpServer import app.opendocument.core.Odr import app.opendocument.core.OdrException import app.opendocument.core.TableDimensions +import app.opendocument.core.TextEncoding +import app.opendocument.core.TextFile import app.opendocument.droid.nonfree.CrashManager +import app.opendocument.droid.nonfree.Features import java.io.File import java.io.IOException /** * Loads documents through odrcore and publishes them on a local http server. * - * Owns the process wide core state: the one-time initialization, the single http server and the - * currently open [Document] that [retranslate] edits. + * Owns the process wide core state: the one-time initialization and the single http server. */ class CoreLoader(private val context: Context) { private lateinit var crashManager: CrashManager - private var document: Document? = null - private var lastInputPath: String? = null private var lastDocumentType: DocumentType = DocumentType.UNKNOWN /** Counts the renders, so each one publishes under a prefix of its own - see [render]. */ private var renderCount = 0 - /** - * Whether the document [host] last opened is one [edit] can do something with - the core's own - * answer, since [host] only keeps a document that reports itself editable and savable. - */ - val isDocumentEditable: Boolean - get() = document != null + /** What the user can change in the document [host] last opened with `askEditing`. */ + var editing: EditingKind = EditingKind.NONE + private set /** * Whether the core reads what [host] last opened as a document rather than only showing it - @@ -76,21 +73,14 @@ class CoreLoader(private val context: Context) { checkNotNull(FileCache.getCacheFile(context, file.cacheUri)) { "not a cached file: " + file.cacheUri } - val cacheDirectory = FileCache.getCacheDirectory(cachedFile) - - val coreCacheDirectory = File(cacheDirectory, "core_cache") - - lastInputPath = cachedFile.path val views = host( prefix = "odr" + renderCount++, inputPath = cachedFile.path, - cachePath = coreCacheDirectory.path, password = request.password, - editable = request.editable, paging = PaginationSetting.isEnabled(context), - keepDocument = true, + askEditing = true, declaredType = declaredType(file), ) @@ -100,7 +90,7 @@ class CoreLoader(private val context: Context) { views.map { it.name }, views.map { Uri.parse(it.url) }, views.map { it.sheetCut }, - isDocumentEditable, + editing, readsAsDocument, ) } @@ -109,17 +99,15 @@ class CoreLoader(private val context: Context) { * Opens [inputPath], translates it to html and publishes it on the shared http server under * [prefix], replacing whatever was published before. * - * [keepDocument] retains the decoded document for [retranslate]; [declaredType] is what the - * document is called - see [openFile]. + * [askEditing] sets [editing], and renders an editable document with its editor. [declaredType] + * is what the document is called - see [openFile]. */ fun host( prefix: String, inputPath: String, - cachePath: String, password: String? = null, - editable: Boolean = false, paging: Boolean = false, - keepDocument: Boolean = false, + askEditing: Boolean = false, declaredType: FileType? = null, ): List { val server = checkNotNull(sharedServer) { "core server is not running" } @@ -128,20 +116,7 @@ class CoreLoader(private val context: Context) { server.clear() - var file = openFile(inputPath, declaredType) - - if (file.passwordEncrypted()) { - // the core's answer, not a list of ours: a legacy .doc, .ppt or .xls has no way in - // whatever the password, so the prompt would be a dialog that can never close - if (!file.capabilities().decrypt) { - throw UndecryptableFile(inputPath) - } - - if (password == null) { - throw OdrException.FileEncrypted(inputPath) - } - file = file.decrypt(password) - } + val file = openDecrypted(inputPath, password, declaredType) Log.i(TAG, "type=" + Odr.fileTypeToString(file.fileType())) @@ -150,37 +125,24 @@ class CoreLoader(private val context: Context) { // the core opens text it cannot name a charset for and only fails once a page is // rendered - on the server thread, long after this reported success. so ask now - if (file.isTextFile && file.asTextFile().charset() == null) { + if (!hasKnownEncoding(file)) { throw OdrException.UnsupportedFileType("no charset could be detected: $inputPath") } - if (keepDocument) { - closeDocument() - - // an upper bound the core answers without decoding, so a format that declares no - // editing is not opened just to be told no - val capabilities = file.capabilities() - - if (file.isDocumentFile && capabilities.edit && capabilities.save) { - // TODO this will cause a second load - val document = file.asDocumentFile().document() - - // the document itself is the precise answer, and a read only one held open buys - // that second parse and nothing else - if (document.isEditable && document.isSavable) { - this.document = document - } else { - document.close() - } - } - } + editing = if (askEditing) editingOf(file) else EditingKind.NONE val htmlConfig = HtmlConfig() htmlConfig.embedImages = false htmlConfig.embedShippedResources = true htmlConfig.relativeResourcePaths = false htmlConfig.textDocumentMargin = paging - htmlConfig.editable = editable + + // the mode starts off; a pdf page carries odr.annotation without it + htmlConfig.editable = editing != EditingKind.ANNOTATION && Features.offersEditing(editing) + + // lite: the page refuses the rest with outOfScope, and DocumentFragment offers pro + htmlConfig.editingScope = + if (Features.advancedEditing) HtmlEditingScope.DOCUMENT else HtmlEditingScope.PARAGRAPH // both schemes, each behind prefers-color-scheme, rather than the one it is being read in // now: this is decided while translating, and darkening is turned on and off over the open @@ -193,11 +155,7 @@ class CoreLoader(private val context: Context) { htmlConfig.spreadsheetCellLimit = SpreadsheetBudget.cells(context) htmlConfig.spreadsheetLimitByContent = true - val cacheDirectory = File(cachePath) - cacheDirectory.deleteRecursively() - cacheDirectory.mkdirs() - - val service = Html.translate(file, cachePath, htmlConfig) + val service = Html.translate(file, htmlConfig) server.connectService(service, prefix) return selectViews(file, service.listViews()).map { view -> @@ -253,7 +211,7 @@ class CoreLoader(private val context: Context) { if ( declaredType == null || declaredType == detected.fileType() || - !detected.isTextFile || + textOf(detected) == null || !nameOutranksText(declaredType) ) { return detected @@ -276,25 +234,59 @@ class CoreLoader(private val context: Context) { /** [inputPath] opened as [type], or null where it is not one after all. */ private fun openAs(inputPath: String, type: FileType): DecodedFile? = try { - Odr.open(inputPath, DecodePreference().apply { asFileType = type }) + Odr.open(inputPath, DecodeOptions().apply { asFileType = type }) } catch (e: Throwable) { Log.i(TAG, "not a " + Odr.fileTypeToString(type)) null } - /** The document with [htmlDiff] applied, written to a file of ours. Null if that failed. */ - fun retranslate(request: DocumentRequest, file: IdentifiedFile, htmlDiff: String): File? { - try { - if (document == null) { - // nothing is held open after a rebuild, so open it again before editing it - render(request, file) - } + /** [openFile], and decrypted with [password] where the file is encrypted. */ + private fun openDecrypted( + inputPath: String, + password: String?, + declaredType: FileType?, + ): DecodedFile { + val file = openFile(inputPath, declaredType) + + if (!file.passwordEncrypted()) { + return file + } + + // the core's answer, not a list of ours: a legacy .doc, .ppt or .xls has no way in + // whatever the password, so the prompt would be a dialog that can never close + if (!file.capabilities().decrypt) { + throw UndecryptableFile(inputPath) + } + + if (password == null) { + throw OdrException.FileEncrypted(inputPath) + } + + return file.decrypt(password) + } - val inputFile = File(checkNotNull(lastInputPath)) - val inputCacheDirectory = FileCache.getCacheDirectory(inputFile) + /** The document with [payload] from the page applied, written to a file of ours, or null. */ + fun writeEdits( + request: DocumentRequest, + file: IdentifiedFile, + kind: EditingKind, + payload: String, + ): File? { + try { + val cachedFile = + checkNotNull(FileCache.getCacheFile(context, file.cacheUri)) { + "not a cached file: " + file.cacheUri + } - return edit(htmlDiff, File(inputCacheDirectory, "retranslate").path) + return writeEdits( + cachedFile.path, + request.password, + declaredType(file), + kind, + payload, + File(FileCache.getCacheDirectory(cachedFile), "edited").path, + ) } catch (e: Throwable) { crashManager.log(e) @@ -303,23 +295,78 @@ class CoreLoader(private val context: Context) { } /** - * Applies [htmlDiff] to the document currently held open by [host] and saves it next to - * [outputPathPrefix], with the extension that matches the document's own file type. + * Opens [inputPath] again, applies [payload] with the call [kind] takes and writes the result + * to [outputPathPrefix] plus the file type's extension. Never a document held open since the + * render: a failed edit can leave it half changed. + */ + fun writeEdits( + inputPath: String, + password: String?, + declaredType: FileType?, + kind: EditingKind, + payload: String, + outputPathPrefix: String, + ): File { + openDecrypted(inputPath, password, declaredType).use { file -> + // not Odr.fileTypeToString, which gives names like "ooxml_encrypted" + val extension = Odr.fileExtensionByFileType(file.fileType()) + val outputFile = File("$outputPathPrefix.$extension") + + when (kind) { + EditingKind.NONE -> throw IOException("cannot be written back: $inputPath") + EditingKind.ANNOTATION -> outputFile.writeBytes(file.asPdfFile().annotate(payload)) + EditingKind.TEXT -> + file.asTextFile().let { textFile -> + textFile.edit(payload) + textFile.save(outputFile.path) + } + EditingKind.DOCUMENT, + EditingKind.SHEET -> + file.asDocumentFile().document().use { document -> + document.edit(payload) + document.save(outputFile.path) + } + } + + return outputFile + } + } + + /** + * What the user can change in [file]. The capabilities are asked first, because they need no + * decode; the file itself has the final answer. */ - fun edit(htmlDiff: String, outputPathPrefix: String): File { - val document = checkNotNull(document) { "no editable document is open" } + private fun editingOf(file: DecodedFile): EditingKind { + val capabilities = file.capabilities() + + if (file.isPdfFile) { + return if (capabilities.annotate && file.asPdfFile().isAnnotatable) { + EditingKind.ANNOTATION + } else { + EditingKind.NONE + } + } - // the file type's extension, not [Odr.fileTypeToString], which is its name - and a name - // like "ooxml_encrypted" is not something a file can be called - val extension = Odr.fileExtensionByFileType(document.fileType()) - val outputFile = File("$outputPathPrefix.$extension") + if (!capabilities.edit || !capabilities.save) { + return EditingKind.NONE + } - Log.d(TAG, "HTML diff: $htmlDiff") + if (file.isTextFile) { + return if (file.asTextFile().isSavable) EditingKind.TEXT else EditingKind.NONE + } - Html.edit(document, htmlDiff) - document.save(outputFile.path) + if (!file.isDocumentFile) { + return EditingKind.NONE + } - return outputFile + file.asDocumentFile().document().use { document -> + if (!document.isEditable || !document.isSavable) { + return EditingKind.NONE + } + + return if (document.documentType() == DocumentType.SPREADSHEET) EditingKind.SHEET + else EditingKind.DOCUMENT + } } /** @@ -330,13 +377,6 @@ class CoreLoader(private val context: Context) { */ fun close() { sharedServer?.clear() - - closeDocument() - } - - private fun closeDocument() { - document?.close() - document = null } /** @@ -457,6 +497,22 @@ class CoreLoader(private val context: Context) { coreInitialized = true } + /** + * The text [file] is read as, or null where it is not text. A csv and a markdown file hold + * one rather than being one, and are text for every question asked here. + */ + fun textOf(file: DecodedFile): TextFile? = + when { + file.isTextFile -> file.asTextFile() + file.isCsvFile -> file.asCsvFile().textFile() + file.isMarkdownFile -> file.asMarkdownFile().textFile() + else -> null + } + + /** False only for text whose encoding the core cannot name. */ + fun hasKnownEncoding(file: DecodedFile): Boolean = + textOf(file)?.let { it.encoding() != TextEncoding.UNKNOWN } ?: true + /** * Spreadsheets show one tab per sheet; every other format only shows the full "document" * view without tabs (if the service provides one - e.g. plain text and image files only diff --git a/app/src/main/java/app/opendocument/droid/background/DocumentLoader.kt b/app/src/main/java/app/opendocument/droid/background/DocumentLoader.kt index 17e5fa4dab0f..1f5609eb894a 100644 --- a/app/src/main/java/app/opendocument/droid/background/DocumentLoader.kt +++ b/app/src/main/java/app/opendocument/droid/background/DocumentLoader.kt @@ -74,8 +74,8 @@ class DocumentLoader(application: Application) : AndroidViewModel(application) { backgroundHandler.post { renderSync(request, file) } } - fun save(document: LoadedDocument, target: Uri, htmlDiff: String?) { - backgroundHandler.post { saveSync(document, target, htmlDiff) } + fun save(document: LoadedDocument, target: Uri, payload: String?) { + backgroundHandler.post { saveSync(document, target, payload) } } /** @@ -197,9 +197,9 @@ class DocumentLoader(application: Application) : AndroidViewModel(application) { } } - private fun saveSync(document: LoadedDocument, target: Uri, htmlDiff: String?) { + private fun saveSync(document: LoadedDocument, target: Uri, payload: String?) { try { - documentSaver.save(document, target, htmlDiff) + documentSaver.save(document, target, payload) deliver { it.onSaveSuccess(target) } } catch (e: Throwable) { diff --git a/app/src/main/java/app/opendocument/droid/background/DocumentRequest.kt b/app/src/main/java/app/opendocument/droid/background/DocumentRequest.kt index a8978224cf51..6bceef6bc9be 100644 --- a/app/src/main/java/app/opendocument/droid/background/DocumentRequest.kt +++ b/app/src/main/java/app/opendocument/droid/background/DocumentRequest.kt @@ -12,7 +12,7 @@ import android.os.Parcelable */ class DocumentRequest(val uri: Uri, val persistentUri: Boolean) : Parcelable { - /** Whether the html is rendered for editing, and the document held open to be written back. */ + /** Whether the edit mode is on. The render does not depend on it. */ var editable: Boolean = false var password: String? = null diff --git a/app/src/main/java/app/opendocument/droid/background/DocumentSaver.kt b/app/src/main/java/app/opendocument/droid/background/DocumentSaver.kt index c2c78796b802..73c09a623fce 100644 --- a/app/src/main/java/app/opendocument/droid/background/DocumentSaver.kt +++ b/app/src/main/java/app/opendocument/droid/background/DocumentSaver.kt @@ -23,25 +23,24 @@ class DocumentSaver( /** * Saves [document] to [target]. * - * @param htmlDiff the edits still only in the page, or null for a "full save" of the file as it - * is on disk. + * @param payload the edits or marks still only in the page, or null for a "full save" of the + * file as it is on disk. */ - fun save(document: LoadedDocument, target: Uri, htmlDiff: String?) { - // only the retranslated file is ours to remove afterwards - the other branch hands back - // the cache file of the document that is still open - var retranslated: File? = null + fun save(document: LoadedDocument, target: Uri, payload: String?) { + // only the edited file is ours to remove afterwards - the other branch hands back the + // cache file of the document that is still open + var edited: File? = null var backup: File? = null var backupIsTheLastCopy = false try { val fileToSave = - if (htmlDiff != null) { - val edited = - coreLoader.retranslate(document.request, document.file, htmlDiff) - ?: throw RuntimeException("retranslate failed") - retranslated = edited - - edited + if (payload != null) { + coreLoader + .writeEdits(document.request, document.file, document.editing, payload) + ?.also { + edited = it + } ?: throw RuntimeException("writing the edits failed") } else { // "full save" from the main UI checkNotNull(FileCache.getCacheFile(context, document.file.cacheUri)) { @@ -63,7 +62,7 @@ class DocumentSaver( throw e } } finally { - retranslated?.delete() + edited?.delete() // if the rollback did not get the old content back in, this copy is all that is // left of it - leave it in the cache rather than finishing the job diff --git a/app/src/main/java/app/opendocument/droid/background/EditingKind.kt b/app/src/main/java/app/opendocument/droid/background/EditingKind.kt new file mode 100644 index 000000000000..9fc7ba5d7905 --- /dev/null +++ b/app/src/main/java/app/opendocument/droid/background/EditingKind.kt @@ -0,0 +1,22 @@ +package app.opendocument.droid.background + +/** What the user can change in a document, as the core answers it. Each kind saves its own way. */ +enum class EditingKind { + /** Nothing: the core cannot write this document back. */ + NONE, + + /** A plain text file: its text, and nothing else. */ + TEXT, + + /** A text document or a presentation. */ + DOCUMENT, + + /** A spreadsheet: one cell at a time. */ + SHEET, + + /** A pdf, which takes marks rather than edits. */ + ANNOTATION; + + val isEditable: Boolean + get() = this != NONE +} diff --git a/app/src/main/java/app/opendocument/droid/background/FileIdentifier.kt b/app/src/main/java/app/opendocument/droid/background/FileIdentifier.kt index e7901d696f2e..3b6414220d90 100644 --- a/app/src/main/java/app/opendocument/droid/background/FileIdentifier.kt +++ b/app/src/main/java/app/opendocument/droid/background/FileIdentifier.kt @@ -118,9 +118,7 @@ class FileIdentifier(private val crashManager: CrashManager) { /** Whether the core can name the encoding of a file it decided is text. */ private fun hasKnownCharset(file: File): Boolean = try { - val opened = Odr.open(file.absolutePath) - - !opened.isTextFile || opened.asTextFile().charset() != null + CoreLoader.hasKnownEncoding(Odr.open(file.absolutePath)) } catch (e: Throwable) { crashManager.log(e) diff --git a/app/src/main/java/app/opendocument/droid/background/LoadedDocument.kt b/app/src/main/java/app/opendocument/droid/background/LoadedDocument.kt index aae89ca133d6..00f8aa87d540 100644 --- a/app/src/main/java/app/opendocument/droid/background/LoadedDocument.kt +++ b/app/src/main/java/app/opendocument/droid/background/LoadedDocument.kt @@ -11,8 +11,8 @@ import android.os.Parcelable * * [partCuts] runs alongside them, null for every part but a sheet that was cut. * - * [isEditable] and [readsAsDocument] are the core's own answers about this document, never a guess - * from its mime type - see `CoreLoader.isDocumentEditable` and `CoreLoader.readsAsDocument`. + * [editing] and [readsAsDocument] are the core's own answers about this document, never a guess + * from its mime type - see `CoreLoader.editing` and `CoreLoader.readsAsDocument`. */ class LoadedDocument( val request: DocumentRequest, @@ -20,7 +20,7 @@ class LoadedDocument( val partTitles: List, val partUris: List, val partCuts: List, - val isEditable: Boolean, + val editing: EditingKind, val readsAsDocument: Boolean, ) : Parcelable { @@ -32,7 +32,7 @@ class LoadedDocument( parcel.writeList(partTitles) parcel.writeList(partUris) parcel.writeList(partCuts) - ParcelUtil.writeBoolean(parcel, isEditable) + parcel.writeInt(editing.ordinal) ParcelUtil.writeBoolean(parcel, readsAsDocument) } @@ -64,7 +64,7 @@ class LoadedDocument( partTitles, partUris, partCuts, - ParcelUtil.readBoolean(parcel), + EditingKind.entries[parcel.readInt()], ParcelUtil.readBoolean(parcel), ) } diff --git a/app/src/main/java/app/opendocument/droid/nonfree/Features.kt b/app/src/main/java/app/opendocument/droid/nonfree/Features.kt index 8ee1b45fdcf2..b8a219b1be51 100644 --- a/app/src/main/java/app/opendocument/droid/nonfree/Features.kt +++ b/app/src/main/java/app/opendocument/droid/nonfree/Features.kt @@ -1,10 +1,12 @@ package app.opendocument.droid.nonfree +import app.opendocument.droid.background.EditingKind + /** - * What this build links, asked by name rather than by flavor. + * What this build links and what it sells, asked by name rather than by flavor. * - * The answer comes from [LINKS_ADS], which the `ads` and `noAds` source sets define next to the - * classes it describes, so the flag and the code it stands for cannot disagree. + * Both flags come from `Linked.kt`, which the `ads` and `noAds` source sets define next to the + * classes [withAds] describes, so a flag and the code it stands for cannot disagree. */ object Features { @@ -12,4 +14,11 @@ object Features { * The ad banner, the consent form and the ad removal purchase: lite, and neither of the rest. */ val withAds = LINKS_ADS + + /** Formatting, new and joined paragraphs, and pdf marks: pro and foss. */ + val advancedEditing = ADVANCED_EDITING + + /** Whether this build opens the edit mode for [kind], which the core decided. */ + fun offersEditing(kind: EditingKind): Boolean = + kind.isEditable && (kind != EditingKind.ANNOTATION || advancedEditing) } diff --git a/app/src/main/java/app/opendocument/droid/ui/EditActionModeCallback.kt b/app/src/main/java/app/opendocument/droid/ui/EditActionModeCallback.kt index 46b769707439..100b866dcb48 100644 --- a/app/src/main/java/app/opendocument/droid/ui/EditActionModeCallback.kt +++ b/app/src/main/java/app/opendocument/droid/ui/EditActionModeCallback.kt @@ -1,46 +1,37 @@ package app.opendocument.droid.ui -import android.content.Context import android.view.Menu import android.view.MenuItem -import android.view.inputmethod.InputMethodManager import android.widget.TextView import androidx.appcompat.view.ActionMode import app.opendocument.droid.R +import app.opendocument.droid.background.EditingKind import app.opendocument.droid.ui.activity.DocumentFragment import app.opendocument.droid.ui.activity.MainActivity -@Suppress("DEPRECATION") +/** The edit mode: the bar with save. The tools under it are `EditingTools`. */ class EditActionModeCallback( private val activity: MainActivity, private val documentFragment: DocumentFragment, ) : ActionMode.Callback { - private lateinit var imm: InputMethodManager - override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { + val annotating = documentFragment.editingKind == EditingKind.ANNOTATION + val statusView = TextView(activity) - statusView.setText(R.string.action_edit_banner) + statusView.setText( + if (annotating) R.string.action_annotate_banner else R.string.action_edit_banner + ) mode.customView = statusView mode.menuInflater.inflate(R.menu.edit, menu) - imm = activity.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + documentFragment.setEditing(true) return true } - override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { - documentFragment.reloadUri(true) - - imm.toggleSoftInputFromWindow( - activity.window.decorView.rootView.windowToken, - InputMethodManager.SHOW_FORCED, - InputMethodManager.HIDE_IMPLICIT_ONLY, - ) - - return true - } + override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean = false override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { if (item.itemId != R.id.edit_save) { @@ -56,8 +47,12 @@ class EditActionModeCallback( } override fun onDestroyActionMode(mode: ActionMode) { - imm.toggleSoftInputFromWindow(activity.window.decorView.rootView.windowToken, 0, 0) + documentFragment.setEditing(false) - documentFragment.reloadUri(false) + // the page keeps its edits with the mode off. not when the document is being closed: that + // asked already + if (documentFragment.isAdded && documentFragment.hasUnsavedEdits()) { + activity.confirmLeavingEdits { documentFragment.discardEdits() } + } } } diff --git a/app/src/main/java/app/opendocument/droid/ui/activity/DocumentFragment.kt b/app/src/main/java/app/opendocument/droid/ui/activity/DocumentFragment.kt index 78a118486489..87c9d2ac8213 100644 --- a/app/src/main/java/app/opendocument/droid/ui/activity/DocumentFragment.kt +++ b/app/src/main/java/app/opendocument/droid/ui/activity/DocumentFragment.kt @@ -15,6 +15,7 @@ import android.text.style.ClickableSpan import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.view.inputmethod.InputMethodManager import android.widget.EditText import android.widget.TextView import android.widget.Toast @@ -29,6 +30,7 @@ import app.opendocument.droid.R import app.opendocument.droid.background.DocumentDarkening import app.opendocument.droid.background.DocumentLoader import app.opendocument.droid.background.DocumentRequest +import app.opendocument.droid.background.EditingKind import app.opendocument.droid.background.IdentifiedFile import app.opendocument.droid.background.LoadedDocument import app.opendocument.droid.background.NightModeSetting @@ -38,14 +40,17 @@ import app.opendocument.droid.background.SheetCut import app.opendocument.droid.nonfree.AnalyticsConstants import app.opendocument.droid.nonfree.AnalyticsManager import app.opendocument.droid.nonfree.CrashManager +import app.opendocument.droid.nonfree.Features import app.opendocument.droid.ui.OpenFileIdling import app.opendocument.droid.ui.SnackbarHelper import app.opendocument.droid.ui.widget.DocumentActions +import app.opendocument.droid.ui.widget.EditingTools import app.opendocument.droid.ui.widget.PageView import app.opendocument.droid.ui.widget.ProgressDialogFragment import com.google.android.material.tabs.TabLayout import java.io.FileNotFoundException import java.text.NumberFormat +import org.json.JSONObject class DocumentFragment : Fragment(), DocumentLoader.Listener { @@ -62,8 +67,15 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { private set private lateinit var actions: DocumentActions + private lateinit var editingTools: EditingTools private var bottomInset = 0 + /** Whether lite offered pro during this edit - see [showRefusal]. */ + private var proOfferedThisEdit = false + + /** How many formula cells the page last said were out of date - see `onCellsStale`. */ + private var staleCells = 0 + /** Folding the actions back up is what back does first, while they are unfolded. */ private val actionsBackCallback = object : OnBackPressedCallback(false) { @@ -112,7 +124,11 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { /** Only ever the document currently on screen. */ var lastDocument: LoadedDocument? = null - var currentHtmlDiff: String? = null + /** What the page handed over for the save in progress: its operations, or its marks. */ + var currentEditPayload: String? = null + + /** Whether the page holds edits or marks no save has written - see [hasUnsavedEdits]. */ + var editsDirty = false // loads cannot be canceled once running, so results of abandoned loads // (e.g. user navigated back while the document was still loading) are @@ -177,6 +193,7 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { this.pageView = pageView pageView.setDocumentFragment(this) + pageView.editingListener = pageEditingListener } catch (t: Throwable) { // crashManager is not set yet: onViewCreated has not run @@ -214,6 +231,10 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { } actions.expandedListener = { expanded -> actionsBackCallback.isEnabled = expanded } + // the activity's, so it sits above the banner - see main.xml + editingTools = mainActivity.findViewById(R.id.editing_tools) + editingTools.listener = editingToolsListener + // on viewLifecycleOwner, so it stacks above the activity's own callback - the dispatcher // runs the most recently added enabled callback first mainActivity.onBackPressedDispatcher.addCallback(viewLifecycleOwner, actionsBackCallback) @@ -239,6 +260,10 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { // the page view is a new one, and knows nothing of what the old one was told applyDarkening(lastDocument.file) + // an action mode does not outlive its activity, so neither does the edit mode + state.lastRequest?.editable = false + pageView?.setEditing(lastDocument.editing, false) + restoreTabs(lastDocument) prepareActions(lastDocument) @@ -273,8 +298,9 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { @Suppress("DEPRECATION") state.lastDocument = savedInstanceState.getParcelable(SAVED_KEY_LAST_DOCUMENT) } - if (state.currentHtmlDiff == null) { - state.currentHtmlDiff = savedInstanceState.getString(SAVED_KEY_CURRENT_HTML_DIFF) + if (state.currentEditPayload == null) { + state.currentEditPayload = + savedInstanceState.getString(SAVED_KEY_CURRENT_EDIT_PAYLOAD) } return pageView?.restoreState(savedInstanceState) != null @@ -316,7 +342,7 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { outState.putParcelable(SAVED_KEY_LAST_REQUEST, state.lastRequest) outState.putParcelable(SAVED_KEY_LAST_FILE, state.lastFile) outState.putParcelable(SAVED_KEY_LAST_DOCUMENT, state.lastDocument) - outState.putString(SAVED_KEY_CURRENT_HTML_DIFF, state.currentHtmlDiff) + outState.putString(SAVED_KEY_CURRENT_EDIT_PAYLOAD, state.currentEditPayload) pageView?.saveState(outState) } @@ -348,6 +374,9 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { showProgress() + // the page that held them is going, and the one replacing it starts with a clean log + setEditState(dirty = false, canUndo = false, canRedo = false) + state.beginLoadIdling() } @@ -370,20 +399,182 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { load(DocumentRequest(uri, persistentUri).apply { this.editable = editable }) } - fun reloadUri(editable: Boolean) { - // closeDocument() removes this fragment and only then finishes the edit mode, whose - // onDestroyActionMode reloads - a load queued here would have nothing left to land in + /** Turns the page's edit mode on or off, without a render - see `PageView.setEditing`. */ + fun setEditing(editing: Boolean) { + // closeDocument() removes this fragment and only then finishes the edit mode if (!isAdded) { return } - val lastRequest = requireLastRequest() - lastRequest.editable = editable + val document = state.lastDocument ?: return + requireLastRequest().editable = editing + + if (editing) { + proOfferedThisEdit = false + staleCells = 0 + } + + pageView?.setEditing(document.editing, editing) + + showEditingTools(document, editing) + + if (!editing) { + // a keyboard left up over a document that no longer takes typing + val imm = requireContext().getSystemService(InputMethodManager::class.java) + imm?.hideSoftInputFromWindow(requireView().windowToken, 0) + } + } + + /** Drops the page's edits by rendering the cached copy again. */ + fun discardEdits() { + if (!isAdded || state.lastDocument == null) { + return + } - // entering or leaving edit mode is not a new document, and the user is working + // not a new document, and not one the user went and opened either freshOpenPending = false - reload(lastRequest, requireLastFile()) + reload(requireLastRequest(), requireLastFile()) + } + + /** The strip under the bar: what the kind of document takes, and undo and redo. */ + private fun showEditingTools(document: LoadedDocument, editing: Boolean) { + when { + !editing || !document.editing.isEditable -> editingTools.hide() + document.editing == EditingKind.DOCUMENT -> + editingTools.showFormatting(locked = !Features.advancedEditing) + document.editing == EditingKind.ANNOTATION -> editingTools.showMarking() + else -> editingTools.showPlain() + } + } + + fun undo() { + pageView?.undo() + } + + fun redo() { + pageView?.redo() + } + + /** What the page reports while a document is edited. */ + private val pageEditingListener = + object : PageView.EditingListener { + override fun onEditChanged(dirty: Boolean, canUndo: Boolean, canRedo: Boolean) { + setEditState(dirty, canUndo, canRedo) + } + + override fun onEditRefused(reason: String) { + showRefusal(reason) + } + + override fun onSelectionChanged(style: JSONObject) { + editingTools.setSelectionStyle(style) + } + + override fun onMarksChanged(count: Int) { + // a mark is taken back one at a time and never put back, so there is no redo + setEditState(dirty = count > 0, canUndo = count > 0, canRedo = false) + } + + override fun onCellsStale(count: Int) { + // said only as it grows + val grew = count > staleCells + staleCells = count + if (!grew || !isAdded) { + return + } + + SnackbarHelper.show( + requireActivity(), + resources.getQuantityString(R.plurals.edit_cells_stale, count, count), + null, + isIndefinite = false, + isError = false, + ) + } + } + + private val editingToolsListener = + object : EditingTools.Listener { + override fun onToggleStyle(property: String) { + analyticsManager.report("edit_format_$property") + + pageView?.toggleStyle(property) + } + + override fun onFormat(style: JSONObject) { + analyticsManager.report("edit_format_" + style.keys().asSequence().joinToString()) + + pageView?.formatStyle(style) + } + + override fun onMarkTool(tool: String, color: Int, recolor: Boolean) { + analyticsManager.report("edit_mark_$tool") + + pageView?.pressMarkTool(tool, color, EditingTools.INK_WIDTH, recolor) { armed -> + editingTools.setArmedTool(armed) + } + } + + override fun onLocked() { + (requireActivity() as MainActivity).offerPro(MainActivity.ProFeature.FORMATTING) + } + + override fun onUndo() { + analyticsManager.report("menu_edit_undo") + + undo() + } + + override fun onRedo() { + analyticsManager.report("menu_edit_redo") + + redo() + } + } + + private fun setEditState(dirty: Boolean, canUndo: Boolean, canRedo: Boolean) { + if (!::state.isInitialized) { + return + } + + state.editsDirty = dirty + + if (::editingTools.isInitialized) { + editingTools.setUndoState(canUndo, canRedo) + } + } + + /** Says why the page refused an edit, in our words rather than the page's. */ + private fun showRefusal(reason: String) { + if (!isAdded) { + return + } + + if (reason == "outOfScope" && !Features.advancedEditing) { + // once per edit, not once per refused keystroke + if (!proOfferedThisEdit) { + proOfferedThisEdit = true + + (requireActivity() as MainActivity).offerPro(MainActivity.ProFeature.FORMATTING) + } + + return + } + + val message = + when (reason) { + "newLine" -> R.string.edit_refused_new_line + "formula" -> R.string.edit_refused_formula + "formulaInput" -> R.string.edit_refused_formula_input + "rich" -> R.string.edit_refused_rich + "shapes" -> R.string.edit_refused_shapes + "readOnly" -> R.string.edit_refused_read_only + "range" -> R.string.edit_refused_range + else -> R.string.edit_refused_unsupported + } + + SnackbarHelper.show(requireActivity(), message, null, isIndefinite = false, isError = false) } /** Tells the page whether it may follow the app into night mode - see [DocumentDarkening]. */ @@ -443,21 +634,22 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { /** * Collects whatever the save needs and runs [callback] - exactly once. A full save writes the - * file as it is on disk, so it has no diff to ask the page for. + * file as it is on disk, so it has nothing to ask the page for. */ fun prepareSave(callback: Runnable, fullSave: Boolean) { val pageView = this.pageView + val document = state.lastDocument - if (fullSave || pageView == null) { - state.currentHtmlDiff = null + if (fullSave || pageView == null || document == null) { + state.currentEditPayload = null callback.run() return } - pageView.requestHtml { htmlDiff -> - state.currentHtmlDiff = htmlDiff + pageView.requestEditPayload(document.editing) { payload -> + state.currentEditPayload = payload callback.run() } @@ -476,7 +668,7 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { return } - documentLoader.save(requireLastDocument(), outFile, state.currentHtmlDiff) + documentLoader.save(requireLastDocument(), outFile, state.currentEditPayload) } private fun unload() { @@ -514,13 +706,21 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { // knows which of the documents it renders it can also write back, which is why neither the // legacy binary formats nor the spreadsheets of issue #442 need naming val edit = - if (!document.isEditable) null - else - DocumentActions.Action( - DocumentActions.ACTION_EDIT, - R.string.menu_edit, - R.drawable.ic_edit, - ) + when (document.editing) { + EditingKind.NONE -> null + EditingKind.ANNOTATION -> + DocumentActions.Action( + DocumentActions.ACTION_EDIT, + R.string.menu_annotate, + R.drawable.ic_marker, + ) + else -> + DocumentActions.Action( + DocumentActions.ACTION_EDIT, + R.string.menu_edit, + R.drawable.ic_edit, + ) + } // what the display rows offer is the opposite of what is on screen, so each says what // tapping it does rather than what it is called @@ -693,6 +893,10 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { // before the page is put in below, so it is drawn the way it is going to stay applyDarkening(file) + // the mode it is meant to be in, which a save carries over to the written document + pageView?.setEditing(document.editing, document.request.editable) + showEditingTools(document, document.request.editable) + analyticsManager.setCurrentScreen(activity, file.mimeType ?: UNKNOWN_FILE_TYPE) // clears lastSelectedTab, so what reloadForMargins put aside is read after it @@ -869,7 +1073,7 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { } override fun onSaveSuccess(target: Uri) { - state.currentHtmlDiff = null + state.currentEditPayload = null SnackbarHelper.show( requireActivity(), @@ -879,11 +1083,12 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { isError = false, ) - loadUri(target, true, true) + // the written document, in the mode the user left the old one in + loadUri(target, true, requireLastRequest().editable) } override fun onSaveError() { - state.currentHtmlDiff = null + state.currentEditPayload = null SnackbarHelper.show( requireActivity(), @@ -1241,9 +1446,15 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { return ::state.isInitialized && state.lastRequest != null } - /** Whether the document is in edit mode, so its changes are still only in the page. */ + /** Whether the document is in edit mode. */ fun isEditing(): Boolean = ::state.isInitialized && state.lastRequest?.editable == true + /** Whether the page holds edits or marks no save has written. */ + fun hasUnsavedEdits(): Boolean = ::state.isInitialized && state.editsDirty + + val editingKind: EditingKind + get() = state.lastDocument?.editing ?: EditingKind.NONE + val lastFileType: String? get() = state.lastFile?.mimeType @@ -1253,6 +1464,12 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { override fun onDestroyView() { super.onDestroyView() + // the row outlives this view, and closing a document ends no edit mode here + if (::editingTools.isInitialized) { + editingTools.hide() + editingTools.listener = null + } + if (::documentLoader.isInitialized) { documentLoader.listener = null } @@ -1270,7 +1487,7 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { const val SAVED_KEY_LAST_REQUEST = "LAST_REQUEST" const val SAVED_KEY_LAST_FILE = "LAST_FILE" const val SAVED_KEY_LAST_DOCUMENT = "LAST_DOCUMENT" - const val SAVED_KEY_CURRENT_HTML_DIFF = "CURRENT_HTML_DIFF" + const val SAVED_KEY_CURRENT_EDIT_PAYLOAD = "CURRENT_HTML_DIFF" /** What the analytics screen name is when nothing could name the bytes. */ const val UNKNOWN_FILE_TYPE = "N/A" diff --git a/app/src/main/java/app/opendocument/droid/ui/activity/MainActivity.kt b/app/src/main/java/app/opendocument/droid/ui/activity/MainActivity.kt index 2ce1312e2b9a..efab7c0b8028 100644 --- a/app/src/main/java/app/opendocument/droid/ui/activity/MainActivity.kt +++ b/app/src/main/java/app/opendocument/droid/ui/activity/MainActivity.kt @@ -13,6 +13,7 @@ import android.view.View import android.widget.LinearLayout import androidx.activity.OnBackPressedCallback import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.view.ActionMode as SupportActionMode @@ -620,6 +621,14 @@ class MainActivity : AppCompatActivity() { DocumentActions.ACTION_EDIT -> { analyticsManager.report("menu_edit") + // the button follows the core, so lite shows it over a pdf and offers pro + val kind = documentFragment?.editingKind ?: return + if (!Features.offersEditing(kind)) { + offerPro(ProFeature.PDF) + + return + } + documentFragment?.let { fragment -> currentActionMode = startSupportActionMode(EditActionModeCallback(this, fragment)) @@ -733,6 +742,32 @@ class MainActivity : AppCompatActivity() { ) } + /** Says that what was just tried is pro's, and leads to the pro listing. Lite only. */ + fun offerPro(feature: ProFeature) { + // the names OpenDocument.ios reports the same gate under + analyticsManager.report("pro_gate_shown", "feature", feature.name.lowercase()) + + AlertDialog.Builder(this) + .setTitle(R.string.pro_offer_title) + .setMessage(feature.message) + .setPositiveButton(R.string.house_ad_cta_get_pro) { _, _ -> + analyticsManager.report("pro_gate_tapped", "feature", feature.name.lowercase()) + + buyAdRemoval() + } + .setNegativeButton(R.string.not_now, null) + .show() + } + + /** What pro adds, as the reader runs into it. */ + enum class ProFeature(@param:StringRes val message: Int) { + /** Formatting text, and starting or joining a paragraph. */ + FORMATTING(R.string.pro_offer_formatting), + + /** Marking up a pdf. */ + PDF(R.string.pro_offer_markup), + } + /** What [buyAdRemoval] is for a build with no ad removal to sell. */ fun openSponsorPage() { analyticsManager.report(AnalyticsConstants.EVENT_ADD_TO_CART) @@ -776,13 +811,13 @@ class MainActivity : AppCompatActivity() { } /** - * Asks before walking away from a document being edited, then runs [leave]. Saving does not - * also leave: it opens the create-document picker, which still needs the page the diff comes - * from. + * Asks before walking away from edits that are only in the page, then runs [leave]. Saving does + * not also leave: it opens the create-document picker, which still needs the page the edits + * come from. */ - private fun confirmLeavingEdits(leave: () -> Unit) { + fun confirmLeavingEdits(leave: () -> Unit) { val documentFragment = this.documentFragment - if (documentFragment == null || !documentFragment.isEditing()) { + if (documentFragment == null || !documentFragment.hasUnsavedEdits()) { leave() return @@ -824,9 +859,7 @@ class MainActivity : AppCompatActivity() { SnackbarHelper.dismiss(this) } - // the fragment goes first: finishing an edit mode reloads the document it acts on, and - // that load would put a progress dialog up over a fragment that is about to be removed. - // reloadUri() is a no-op once it is detached + // the fragment goes first, so finishing the edit mode does not ask about its edits again documentFragment?.let { fragment -> supportFragmentManager.beginTransaction().remove(fragment).commitNow() diff --git a/app/src/main/java/app/opendocument/droid/ui/widget/EditingTools.kt b/app/src/main/java/app/opendocument/droid/ui/widget/EditingTools.kt new file mode 100644 index 000000000000..223b7d0ef8c6 --- /dev/null +++ b/app/src/main/java/app/opendocument/droid/ui/widget/EditingTools.kt @@ -0,0 +1,514 @@ +package app.opendocument.droid.ui.widget + +import android.content.Context +import android.graphics.Color +import android.graphics.drawable.GradientDrawable +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.widget.HorizontalScrollView +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.PopupWindow +import android.widget.TextView +import androidx.annotation.ColorInt +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.appcompat.widget.PopupMenu +import androidx.appcompat.widget.TooltipCompat +import app.opendocument.droid.R +import org.json.JSONObject + +/** + * The tools under the edit mode's bar, with undo and redo at the end. It only reports taps; which + * tool is on comes back from the page through [setSelectionStyle] and [setArmedTool]. + */ +class EditingTools(context: Context, attributeSet: AttributeSet?) : + HorizontalScrollView(context, attributeSet) { + + interface Listener { + + /** Flip `bold`, `italic`, `underline` or `strikethrough` on the selection. */ + fun onToggleStyle(property: String) + + /** State [style] on the selection, in the keys `odr.editing.format` takes. */ + fun onFormat(style: JSONObject) + + /** A marking tool was pressed, or given a new [color] where [recolor]. */ + fun onMarkTool(tool: String, @ColorInt color: Int, recolor: Boolean) + + /** A tool of pro's was tapped in a build without it. */ + fun onLocked() + + fun onUndo() + + fun onRedo() + } + + var listener: Listener? = null + + private val row: LinearLayout + + /** Whether the tools only offer pro, rather than doing anything - see [showFormatting]. */ + private var locked = false + + private val toggles = mutableMapOf() + private val markTools = mutableMapOf() + private val markColors = mutableMapOf() + + private var textColor = TEXT_COLORS.first().color + private var highlightColor = HIGHLIGHT_COLORS.first().color + + /** What the selection shows, as the page last reported it. */ + private var selectionStyle = JSONObject() + + private var textColorBar: View? = null + private var highlightTool: View? = null + private var highlightBar: View? = null + private var sizeTool: TextView? = null + private var undoTool: View? = null + private var redoTool: View? = null + + private var canUndo = false + private var canRedo = false + + init { + LayoutInflater.from(context).inflate(R.layout.view_editing_tools, this, true) + + row = findViewById(R.id.editing_tools_row) + + isHorizontalScrollBarEnabled = false + visibility = View.GONE + } + + fun hide() { + visibility = View.GONE + } + + /** The formatting tools. [locked] adds pro's badge, and makes each tool offer pro. */ + fun showFormatting(locked: Boolean) { + reset(locked) + + if (locked) { + val badge = newText(R.string.tool_pro_badge) + badge.isSelected = true + row.addView(badge) + } + + addToggle("bold", R.drawable.ic_format_bold, R.string.tool_bold) + addToggle("italic", R.drawable.ic_format_italic, R.string.tool_italic) + addToggle("underline", R.drawable.ic_format_underlined, R.string.tool_underline) + addToggle( + "strikethrough", + R.drawable.ic_format_strikethrough, + R.string.tool_strikethrough, + ) + + // one control: the colors open under it, and the bar shows the selection's own + val textColorTool = newTool(R.drawable.ic_text_color, R.string.tool_text_color) + textColorBar = barOf(textColorTool).also { paintBar(it, textColor) } + textColorTool.setOnClickListener { anchor -> + ifUnlocked { + showPalette(anchor, TEXT_COLORS) { color -> + listener?.onFormat(JSONObject().put("color", hex(color))) + } + } + } + row.addView(textColorTool) + + // a split button: the tool turns the highlight on and off, the arrow picks its colour + val highlight = newTool(R.drawable.ic_marker, R.string.tool_highlight) + highlightBar = barOf(highlight).also { paintBar(it, highlightColor) } + highlight.setOnClickListener { + ifUnlocked { + // isNull is also true of a key the page left out, where the runs disagree + val on = !selectionStyle.isNull("highlight") + + listener?.onFormat( + JSONObject().put("highlight", if (on) JSONObject.NULL else hex(highlightColor)) + ) + } + } + highlightTool = highlight + row.addView(highlight) + addChevron(R.string.tool_highlight) { anchor -> + showPalette(anchor, HIGHLIGHT_COLORS) { color -> + if (color == Color.TRANSPARENT) { + listener?.onFormat(JSONObject().put("highlight", JSONObject.NULL)) + + return@showPalette + } + + highlightColor = color + highlightBar?.let { paintBar(it, color) } + + listener?.onFormat(JSONObject().put("highlight", hex(color))) + } + } + + val size = newText(R.string.tool_font_size) + size.contentDescription = context.getString(R.string.tool_font_size) + TooltipCompat.setTooltipText(size, context.getString(R.string.tool_font_size)) + size.setOnClickListener { ifUnlocked { showSizes(size) } } + sizeTool = size + row.addView(size) + + addUndoRedo(redo = true) + + setSelectionStyle(selectionStyle) + + visibility = View.VISIBLE + } + + /** A sheet or a plain text file: nothing to format, so only the way back. */ + fun showPlain() { + reset(false) + + addUndoRedo(redo = true) + + visibility = View.VISIBLE + } + + /** The five marking tools of a pdf, each with a colour of its own. */ + fun showMarking() { + reset(false) + + for (mark in MARKS) { + val color = markColors.getOrPut(mark.tool) { mark.color } + + val tool = newTool(mark.icon, mark.label) + paintBar(barOf(tool), color) + tool.setOnClickListener { + listener?.onMarkTool(mark.tool, markColors.getValue(mark.tool), false) + } + markTools[mark.tool] = tool + row.addView(tool) + + addChevron(mark.label) { anchor -> + showPalette(anchor, MARK_COLORS) { picked -> + markColors[mark.tool] = picked + paintBar(barOf(tool), picked) + + listener?.onMarkTool(mark.tool, picked, true) + } + } + } + + // a mark is taken back one at a time and never put back + addUndoRedo(redo = false) + + visibility = View.VISIBLE + } + + /** What the page says can be taken back and put back. */ + fun setUndoState(canUndo: Boolean, canRedo: Boolean) { + this.canUndo = canUndo + this.canRedo = canRedo + + undoTool?.let { setUsable(it, canUndo) } + redoTool?.let { setUsable(it, canRedo) } + } + + /** Undo and redo are the page's in every edition, so they are never locked. */ + private fun addUndoRedo(redo: Boolean) { + val undo = newTool(R.drawable.ic_undo, R.string.action_undo) + undo.setOnClickListener { listener?.onUndo() } + undoTool = undo + row.addView(undo) + + if (redo) { + val tool = newTool(R.drawable.ic_redo, R.string.action_redo) + tool.setOnClickListener { listener?.onRedo() } + redoTool = tool + row.addView(tool) + } + + setUndoState(canUndo, canRedo) + } + + private fun setUsable(tool: View, usable: Boolean) { + tool.isEnabled = usable + tool.alpha = if (usable) 1f else DISABLED_ALPHA + } + + /** Shows which of the toggles the selection has on, and the size it is set in. */ + fun setSelectionStyle(style: JSONObject) { + selectionStyle = style + + for ((property, view) in toggles) { + view.isSelected = !locked && style.optBoolean(property, false) + } + + highlightTool?.isSelected = !locked && !style.isNull("highlight") + + // where the runs disagree, the bars keep what they showed + style + .optString("color") + .takeIf { !style.isNull("color") } + ?.let { parseColor(it) } + ?.let { color -> + textColorBar?.let { paintBar(it, color) } + } + style + .optString("highlight") + .takeIf { !style.isNull("highlight") } + ?.let { parseColor(it) } + ?.let { color -> + highlightColor = color + highlightBar?.let { paintBar(it, color) } + } + + sizeTool?.text = + style + .optString("size", "") + .removeSuffix("pt") + .takeIf { it.isNotEmpty() && !style.isNull("size") } + ?.let { context.getString(R.string.tool_font_size_points, it) } + ?: context.getString(R.string.tool_font_size) + } + + /** Shows which marking tool is armed, or none. */ + fun setArmedTool(tool: String?) { + for ((name, view) in markTools) { + view.isSelected = name == tool + } + } + + private fun reset(locked: Boolean) { + this.locked = locked + + row.removeAllViews() + toggles.clear() + markTools.clear() + textColorBar = null + highlightTool = null + highlightBar = null + sizeTool = null + undoTool = null + redoTool = null + selectionStyle = JSONObject() + + scrollTo(0, 0) + } + + private fun ifUnlocked(action: () -> Unit) { + if (locked) { + listener?.onLocked() + } else { + action() + } + } + + private fun addToggle(property: String, @DrawableRes icon: Int, @StringRes label: Int) { + val tool = newTool(icon, label) + tool.setOnClickListener { ifUnlocked { listener?.onToggleStyle(property) } } + + toggles[property] = tool + row.addView(tool) + } + + private fun addChevron(@StringRes label: Int, open: (View) -> Unit) { + val chevron = + LayoutInflater.from(context).inflate(R.layout.item_editing_tool_chevron, row, false) + + val description = context.getString(R.string.tool_color_of, context.getString(label)) + chevron.contentDescription = description + TooltipCompat.setTooltipText(chevron, description) + + chevron.setOnClickListener { ifUnlocked { open(it) } } + + row.addView(chevron) + } + + private fun newTool(@DrawableRes icon: Int, @StringRes label: Int): View { + val tool = LayoutInflater.from(context).inflate(R.layout.item_editing_tool, row, false) + + tool.findViewById(R.id.editing_tool_icon).setImageResource(icon) + tool.contentDescription = context.getString(label) + + // no label beside it, so the name is what a long press turns up + TooltipCompat.setTooltipText(tool, context.getString(label)) + + return tool + } + + private fun newText(@StringRes text: Int): TextView { + val view = + LayoutInflater.from(context).inflate(R.layout.item_editing_tool_text, row, false) + as TextView + view.setText(text) + + if (text == R.string.tool_pro_badge) { + view.setOnClickListener { listener?.onLocked() } + } + + return view + } + + private fun barOf(tool: View): View = + tool.findViewById(R.id.editing_tool_bar).also { it.visibility = View.VISIBLE } + + private fun paintBar(bar: View, @ColorInt color: Int) { + (bar.background.mutate() as GradientDrawable).setColor(color) + } + + private fun showSizes(anchor: View) { + val popup = PopupMenu(context, anchor) + for ((index, size) in FONT_SIZES.withIndex()) { + popup.menu.add( + 0, + index, + index, + context.getString(R.string.tool_font_size_points, "$size"), + ) + } + popup.setOnMenuItemClickListener { item -> + listener?.onFormat(JSONObject().put("size", "${FONT_SIZES[item.itemId]}pt")) + + true + } + popup.show() + } + + private fun showPalette(anchor: View, colors: List, picked: (Int) -> Unit) { + val content = LayoutInflater.from(context).inflate(R.layout.view_color_palette, null) + val paletteRow: LinearLayout = content.findViewById(R.id.color_palette_row) + + val popup = + PopupWindow( + content, + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT, + true, + ) + popup.elevation = 8 * resources.displayMetrics.density + popup.setBackgroundDrawable( + GradientDrawable().apply { + setColor(themeColor(com.google.android.material.R.attr.colorSurfaceContainer)) + cornerRadius = 12 * resources.displayMetrics.density + } + ) + + val size = (36 * resources.displayMetrics.density).toInt() + val margin = (4 * resources.displayMetrics.density).toInt() + + for (named in colors) { + val swatch = View(context) + swatch.layoutParams = + LinearLayout.LayoutParams(size, size).apply { setMargins(margin, 0, margin, 0) } + swatch.background = + context.getDrawable(R.drawable.bg_color_swatch)!!.mutate().also { + // no fill at all is the swatch for no highlight + (it as GradientDrawable).setColor(named.color) + } + swatch.contentDescription = context.getString(named.name) + TooltipCompat.setTooltipText(swatch, context.getString(named.name)) + swatch.isClickable = true + swatch.isFocusable = true + swatch.setOnClickListener { + popup.dismiss() + + picked(named.color) + } + + paletteRow.addView(swatch) + } + + popup.showAsDropDown(anchor) + } + + @ColorInt + private fun themeColor(attribute: Int): Int { + val value = android.util.TypedValue() + context.theme.resolveAttribute(attribute, value, true) + + return value.data + } + + private class NamedColor(@param:ColorInt val color: Int, @param:StringRes val name: Int) + + private class Mark( + val tool: String, + @param:DrawableRes val icon: Int, + @param:StringRes val label: Int, + @param:ColorInt val color: Int, + ) + + companion object { + + /** The width of a line the Draw tool makes, in pdf points. */ + const val INK_WIDTH = 2f + + /** `#rrggbb`, the one spelling `odr.editing.format` takes. */ + private fun hex(@ColorInt color: Int) = String.format("#%06x", color and 0xffffff) + + private fun parseColor(hex: String): Int? = + try { + Color.parseColor(hex) + } catch (e: IllegalArgumentException) { + null + } + + /** Material's opacity for a disabled icon, 38%. */ + private const val DISABLED_ALPHA = 0.38f + + /** Point sizes a document commonly uses. */ + private val FONT_SIZES = listOf(8, 9, 10, 11, 12, 14, 16, 18, 20, 24, 28, 32, 36, 48) + + // the same colors as OpenDocument.ios' EditToolBar; the first of each is the default + private val TEXT_COLORS = + listOf( + NamedColor(0xff191c1e.toInt(), R.string.color_black), + NamedColor(0xffe53935.toInt(), R.string.color_red), + NamedColor(0xff1e88e5.toInt(), R.string.color_blue), + NamedColor(0xff43a047.toInt(), R.string.color_green), + ) + + private val HIGHLIGHT_COLORS = + listOf( + NamedColor(0xfffff59d.toInt(), R.string.color_yellow), + NamedColor(0xffc5e1a5.toInt(), R.string.color_green), + NamedColor(0xfff8bbd0.toInt(), R.string.color_pink), + NamedColor(0xffb3e5fc.toInt(), R.string.color_blue), + NamedColor(Color.TRANSPARENT, R.string.color_none), + ) + + private val MARK_COLORS = + listOf( + NamedColor(0xffffe633.toInt(), R.string.color_yellow), + NamedColor(0xffe53935.toInt(), R.string.color_red), + NamedColor(0xff1e88e5.toInt(), R.string.color_blue), + NamedColor(0xff43a047.toInt(), R.string.color_green), + ) + + /** The marks a pdf takes, in the annotator's names, each with its starting colour. */ + private val MARKS = + listOf( + Mark( + "highlight", + R.drawable.ic_marker, + R.string.tool_mark_highlight, + 0xffffe633.toInt(), + ), + Mark( + "underline", + R.drawable.ic_format_underlined, + R.string.tool_mark_underline, + 0xffe53935.toInt(), + ), + Mark( + "strikeOut", + R.drawable.ic_format_strikethrough, + R.string.tool_mark_strike_out, + 0xffe53935.toInt(), + ), + Mark( + "squiggly", + R.drawable.ic_format_squiggly, + R.string.tool_mark_squiggly, + 0xffe53935.toInt(), + ), + Mark("ink", R.drawable.ic_draw, R.string.tool_mark_draw, 0xff1e88e5.toInt()), + ) + } +} diff --git a/app/src/main/java/app/opendocument/droid/ui/widget/PageView.kt b/app/src/main/java/app/opendocument/droid/ui/widget/PageView.kt index f355c594e3bd..d3991ebd1422 100644 --- a/app/src/main/java/app/opendocument/droid/ui/widget/PageView.kt +++ b/app/src/main/java/app/opendocument/droid/ui/widget/PageView.kt @@ -20,6 +20,7 @@ import android.webkit.WebViewClient import androidx.annotation.Keep import androidx.webkit.WebSettingsCompat import androidx.webkit.WebViewFeature +import app.opendocument.droid.background.EditingKind import app.opendocument.droid.background.FileCache import app.opendocument.droid.background.StreamUtil import app.opendocument.droid.nonfree.CrashManager @@ -27,6 +28,8 @@ import app.opendocument.droid.ui.ParagraphListener import app.opendocument.droid.ui.activity.DocumentFragment import java.io.ByteArrayInputStream import java.io.IOException +import org.json.JSONObject +import org.json.JSONTokener /** * The WebView the documents are displayed in, plus the javascript bridge the page talks back on. @@ -42,7 +45,16 @@ constructor(context: Context, attributeSet: AttributeSet?) : private lateinit var documentFragment: DocumentFragment private lateinit var crashManager: CrashManager - private var htmlCallback: HtmlCallback? = null + /** Told what the page's editor reports, on the main thread - see `editing-bridge.js`. */ + var editingListener: EditingListener? = null + + /** What [setEditing] was last told, applied again to every page that loads. */ + private var editingKind = EditingKind.NONE + private var isEditing = false + + private val editingBridgeScript: String by lazy { + context.assets.open(EDITING_BRIDGE_ASSET).bufferedReader().use { it.readText() } + } /** * Progress 100 reported before the page commits leaves it blank @@ -92,6 +104,15 @@ constructor(context: Context, attributeSet: AttributeSet?) : restorePendingScroll(0) + // a sheet loads a page per tab, and each one is a page of its own to wire up + if (isOwnContent(url)) { + evaluateJavascript(editingBridgeScript, null) + + if (isEditing) { + applyEditing() + } + } + buggyWebViewHandler.postDelayed( { // [url] and not whatever is loaded now: this callback can arrive after @@ -436,16 +457,133 @@ constructor(context: Context, attributeSet: AttributeSet?) : } } - fun requestHtml(callback: HtmlCallback) { - this.htmlCallback = callback + /** Turns the page's edit mode on or off. A pdf has no mode, so leaving only disarms. */ + fun setEditing(kind: EditingKind, editing: Boolean) { + editingKind = kind + isEditing = editing + + applyEditing() + } - loadUrl("${JAVASCRIPT_SCHEME}window.$BRIDGE_NAME.sendHtml(odr.generateDiff());") + private fun applyEditing() { + evaluateJavascript( + when { + editingKind == EditingKind.ANNOTATION -> + if (isEditing) "void 0" + else "window.odr && odr.annotation && odr.annotation.setTool(null)" + isEditing -> "window.odr && odr.editing && odr.editing.enable()" + else -> "window.odr && odr.editing && odr.editing.disable()" + }, + null, + ) + } + + fun undo() { + evaluateJavascript( + if (editingKind == EditingKind.ANNOTATION) + "window.odr && odr.annotation && odr.annotation.undo()" + else "window.odr && odr.editing && odr.editing.undo()", + null, + ) + } + + fun redo() { + evaluateJavascript("window.odr && odr.editing && odr.editing.redo()", null) + } + + /** Flips `bold`, `italic`, `underline` or `strikethrough` on the selection. */ + fun toggleStyle(property: String) { + evaluateJavascript("odr.editing.toggle(${JSONObject.quote(property)})", null) + } + + /** States [style] on the selection, in the keys `odr.editing.format` takes. */ + fun formatStyle(style: JSONObject) { + evaluateJavascript("odr.editing.format($style)", null) + } + + /** A marking tool pressed, or given a new colour; [callback] gets the tool left armed. */ + fun pressMarkTool( + tool: String, + color: Int, + width: Float, + recolor: Boolean, + callback: (String?) -> Unit, + ) { + val rgb = + "[${android.graphics.Color.red(color) / 255f}," + + "${android.graphics.Color.green(color) / 255f}," + + "${android.graphics.Color.blue(color) / 255f}]" + val method = if (recolor) "recolor" else "press" + + evaluateJavascript( + "window.odr && odr.annotation ? odr.annotation.$method(" + + "${JSONObject.quote(tool)}, {color: $rgb, width: $width}) : null" + ) { + callback(decodeString(it)) + } + } + + /** What a save hands the core: the page's operations, or a pdf's marks. Null if none. */ + fun requestEditPayload(kind: EditingKind, callback: (String?) -> Unit) { + val expression = + if (kind == EditingKind.ANNOTATION) { + "window.odr && odr.annotation ? odr.annotation.getAnnotations() : null" + } else { + "window.odr && odr.editing ? odr.editing.getOperations() : null" + } + + evaluateJavascript("(function(){return $expression;})()") { callback(decodeString(it)) } + } + + /** A string evaluateJavascript answered with, which arrives as a json literal. */ + private fun decodeString(result: String?): String? = + try { + JSONTokener(result ?: "null").nextValue() as? String + } catch (e: Exception) { + crashManager.log(e) + + null + } + + // called by editing-bridge.js on the javabridge thread, so each posts to the main one + + @JavascriptInterface + @Keep + fun editChanged(dirty: Boolean, canUndo: Boolean, canRedo: Boolean) { + post { editingListener?.onEditChanged(dirty, canUndo, canRedo) } + } + + @JavascriptInterface + @Keep + fun editRefused(reason: String) { + post { editingListener?.onEditRefused(reason) } + } + + @JavascriptInterface + @Keep + fun selectionChanged(style: String) { + val parsed = + try { + JSONObject(style) + } catch (e: Exception) { + crashManager.log(e) + + return + } + + post { editingListener?.onSelectionChanged(parsed) } + } + + @JavascriptInterface + @Keep + fun marksChanged(count: Int) { + post { editingListener?.onMarksChanged(count) } } @JavascriptInterface @Keep - fun sendHtml(htmlDiff: String) { - htmlCallback?.onHtml(htmlDiff) + fun cellsStale(count: Int) { + post { editingListener?.onCellsStale(count) } } @JavascriptInterface @@ -490,15 +628,29 @@ constructor(context: Context, attributeSet: AttributeSet?) : paragraphListener?.end() } - fun interface HtmlCallback { + interface EditingListener { + + fun onEditChanged(dirty: Boolean, canUndo: Boolean, canRedo: Boolean) + + /** [reason] is the page's name for it, such as `outOfScope` or `range`. */ + fun onEditRefused(reason: String) - fun onHtml(htmlDiff: String) + /** What the selection shows, a key per property the runs under it agree on. */ + fun onSelectionChanged(style: JSONObject) + + /** How many marks the pdf holds that no save has written. */ + fun onMarksChanged(count: Int) + + /** How many formula cells an edit left showing an old result. */ + fun onCellsStale(count: Int) } private companion object { const val BRIDGE_NAME = "paragraphListener" + const val EDITING_BRIDGE_ASSET = "editing-bridge.js" + const val JAVASCRIPT_SCHEME = "javascript:" /** Where CoreLoader publishes a translated document. */ diff --git a/app/src/main/res/drawable/bg_color_bar.xml b/app/src/main/res/drawable/bg_color_bar.xml new file mode 100644 index 000000000000..7853c4fa3335 --- /dev/null +++ b/app/src/main/res/drawable/bg_color_bar.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/drawable/bg_color_swatch.xml b/app/src/main/res/drawable/bg_color_swatch.xml new file mode 100644 index 000000000000..c6420eea6527 --- /dev/null +++ b/app/src/main/res/drawable/bg_color_swatch.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/drawable/bg_editing_tool.xml b/app/src/main/res/drawable/bg_editing_tool.xml new file mode 100644 index 000000000000..60402c01c914 --- /dev/null +++ b/app/src/main/res/drawable/bg_editing_tool.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_arrow_drop_down.xml b/app/src/main/res/drawable/ic_arrow_drop_down.xml new file mode 100644 index 000000000000..ce583469c11f --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_drop_down.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_draw.xml b/app/src/main/res/drawable/ic_draw.xml new file mode 100644 index 000000000000..1a5c1e6be3b5 --- /dev/null +++ b/app/src/main/res/drawable/ic_draw.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_format_bold.xml b/app/src/main/res/drawable/ic_format_bold.xml new file mode 100644 index 000000000000..25e5865c1c4d --- /dev/null +++ b/app/src/main/res/drawable/ic_format_bold.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_format_italic.xml b/app/src/main/res/drawable/ic_format_italic.xml new file mode 100644 index 000000000000..c854767416fc --- /dev/null +++ b/app/src/main/res/drawable/ic_format_italic.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_format_squiggly.xml b/app/src/main/res/drawable/ic_format_squiggly.xml new file mode 100644 index 000000000000..fcdeb60f914d --- /dev/null +++ b/app/src/main/res/drawable/ic_format_squiggly.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_format_strikethrough.xml b/app/src/main/res/drawable/ic_format_strikethrough.xml new file mode 100644 index 000000000000..df6860c9e25d --- /dev/null +++ b/app/src/main/res/drawable/ic_format_strikethrough.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_format_underlined.xml b/app/src/main/res/drawable/ic_format_underlined.xml new file mode 100644 index 000000000000..e97ab8b32c95 --- /dev/null +++ b/app/src/main/res/drawable/ic_format_underlined.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_marker.xml b/app/src/main/res/drawable/ic_marker.xml new file mode 100644 index 000000000000..233fb5008a0f --- /dev/null +++ b/app/src/main/res/drawable/ic_marker.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_redo.xml b/app/src/main/res/drawable/ic_redo.xml new file mode 100644 index 000000000000..4e218168e2a7 --- /dev/null +++ b/app/src/main/res/drawable/ic_redo.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_text_color.xml b/app/src/main/res/drawable/ic_text_color.xml new file mode 100644 index 000000000000..123c59949948 --- /dev/null +++ b/app/src/main/res/drawable/ic_text_color.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_undo.xml b/app/src/main/res/drawable/ic_undo.xml new file mode 100644 index 000000000000..4ce4108809f1 --- /dev/null +++ b/app/src/main/res/drawable/ic_undo.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/layout/item_editing_tool.xml b/app/src/main/res/layout/item_editing_tool.xml new file mode 100644 index 000000000000..8ecbd629233d --- /dev/null +++ b/app/src/main/res/layout/item_editing_tool.xml @@ -0,0 +1,28 @@ + + + + + + + + diff --git a/app/src/main/res/layout/item_editing_tool_chevron.xml b/app/src/main/res/layout/item_editing_tool_chevron.xml new file mode 100644 index 000000000000..1ad4eb320a26 --- /dev/null +++ b/app/src/main/res/layout/item_editing_tool_chevron.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/layout/item_editing_tool_text.xml b/app/src/main/res/layout/item_editing_tool_text.xml new file mode 100644 index 000000000000..6f094f08e23c --- /dev/null +++ b/app/src/main/res/layout/item_editing_tool_text.xml @@ -0,0 +1,15 @@ + + + diff --git a/app/src/main/res/layout/main.xml b/app/src/main/res/layout/main.xml index 98ed0f401450..eeed8a2494c6 100644 --- a/app/src/main/res/layout/main.xml +++ b/app/src/main/res/layout/main.xml @@ -6,6 +6,18 @@ android:layout_gravity="start|top" android:orientation="vertical"> + + + + diff --git a/app/src/main/res/layout/view_editing_tools.xml b/app/src/main/res/layout/view_editing_tools.xml new file mode 100644 index 000000000000..f60791647a91 --- /dev/null +++ b/app/src/main/res/layout/view_editing_tools.xml @@ -0,0 +1,17 @@ + + + + + + diff --git a/app/src/main/res/menu/edit.xml b/app/src/main/res/menu/edit.xml index a590a94c509d..e8624b58583f 100644 --- a/app/src/main/res/menu/edit.xml +++ b/app/src/main/res/menu/edit.xml @@ -1,4 +1,5 @@ + @@ -8,4 +9,4 @@ app:showAsAction="always" android:title="@string/action_edit_save" /> - \ No newline at end of file + diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 32e39d024c76..d34127aa1967 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -75,4 +75,47 @@ Canvis sense desar Voleu desar-los ara? Descarta + + Marca el PDF + Seleccioneu text i després una eina per marcar-lo + Desfés + Refés + Negreta + Cursiva + Subratllat + Ratllat + Color del text + Ressalta + Mida del text + Color de %1$s + %1$s pt + Ressalta + Subratlla + Ratlla + Subratllat ondulat + Dibuixa + Negre + Vermell + Groc + Verd + Blau + Rosa + Sense ressaltat + Pro + Part de Pro + Donar format al text, i afegir o unir paràgrafs, forma part d\'OpenDocument Reader Pro. + Marcar un PDF forma part d\'OpenDocument Reader Pro. + Ara no + Un salt de línia dins d\'un paràgraf no es pot desar. Premeu Retorn per a un paràgraf nou. + Aquesta cel·la conté una fórmula i es queda tal com està. + Encara no es pot escriure una fórmula. + Aquesta cel·la conté més que text pla i es queda tal com està. + Aquesta cel·la conté un dibuix i es queda tal com està. + Aquest document no es pot editar. + Una edició no pot abastar una imatge o una taula. + Aquesta edició no és possible aquí. + + %d cel·la amb fórmula mostra un resultat que la vostra edició ha deixat desactualitzat. El fitxer desat conserva la fórmula, i una aplicació de fulls de càlcul la torna a calcular. + %d cel·les amb fórmula mostren resultats que la vostra edició ha deixat desactualitzats. El fitxer desat conserva les fórmules, i una aplicació de fulls de càlcul les torna a calcular. + diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index e4807bcd3805..eb988f756c45 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -75,4 +75,49 @@ Neuložené změny Chcete je nyní uložit? Zahodit + + Označit PDF + Vyberte text a pak nástroj, kterým ho označíte + Zpět + Znovu + Tučné + Kurzíva + Podtržené + Přeškrtnuté + Barva textu + Zvýraznit + Velikost textu + Barva: %1$s + %1$s pt + Zvýraznit + Podtrhnout + Přeškrtnout + Vlnité podtržení + Kreslit + Černá + Červená + Žlutá + Zelená + Modrá + Růžová + Bez zvýraznění + Pro + Součást verze Pro + Formátování textu a přidávání nebo spojování odstavců je součástí OpenDocument Reader Pro. + Označování PDF je součástí OpenDocument Reader Pro. + Teď ne + Zalomení řádku uvnitř odstavce nelze uložit. Pro nový odstavec stiskněte Enter. + Tato buňka obsahuje vzorec a zůstane beze změny. + Psaní vzorců zatím není podporováno. + Tato buňka obsahuje víc než prostý text a zůstane beze změny. + Tato buňka obsahuje kresbu a zůstane beze změny. + Tento dokument nelze upravit. + Úprava nemůže zasahovat přes obrázek nebo tabulku. + Tato úprava zde není možná. + + %d buňka se vzorcem ukazuje výsledek, který vaše úprava učinila zastaralým. Uložený soubor vzorec zachová a tabulkový program ho spočítá znovu. + %d buňky se vzorcem ukazují výsledky, které vaše úprava učinila zastaralými. Uložený soubor vzorce zachová a tabulkový program je spočítá znovu. + %d buňky se vzorcem ukazují výsledky, které vaše úprava učinila zastaralými. Uložený soubor vzorce zachová a tabulkový program je spočítá znovu. + %d buněk se vzorcem ukazuje výsledky, které vaše úprava učinila zastaralými. Uložený soubor vzorce zachová a tabulkový program je spočítá znovu. + diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 182e8692cebc..0669132b8b72 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -75,4 +75,47 @@ Ikke-gemte ændringer Vil du gemme dem nu? Kassér + + Markér PDF + Vælg tekst og derefter et værktøj for at markere den + Fortryd + Gentag + Fed + Kursiv + Understreget + Gennemstreget + Tekstfarve + Fremhæv + Tekststørrelse + Farve til %1$s + %1$s pt + Fremhæv + Understreg + Gennemstreg + Bølget understregning + Tegn + Sort + Rød + Gul + Grøn + Blå + Lyserød + Ingen fremhævning + Pro + En del af Pro + Formatering af tekst og tilføjelse eller sammenlægning af afsnit er en del af OpenDocument Reader Pro. + Markering af PDF-filer er en del af OpenDocument Reader Pro. + Ikke nu + Et linjeskift inde i et afsnit kan ikke gemmes. Tryk på Enter for et nyt afsnit. + Cellen indeholder en formel og forbliver, som den er. + Indtastning af formler understøttes endnu ikke. + Cellen indeholder mere end almindelig tekst og forbliver, som den er. + Cellen indeholder en tegning og forbliver, som den er. + Dette dokument kan ikke redigeres. + En redigering kan ikke strække sig over et billede eller en tabel. + Den redigering er ikke mulig her. + + %d formelcelle viser et resultat, som din redigering har gjort forældet. Den gemte fil beholder formlen, og et regnearksprogram beregner den igen. + %d formelceller viser resultater, som din redigering har gjort forældede. Den gemte fil beholder formlerne, og et regnearksprogram beregner dem igen. + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 9d4a0439ffaa..53d48500a29d 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -75,4 +75,47 @@ Nicht gespeicherte Änderungen Möchten Sie sie jetzt speichern? Verwerfen + + PDF markieren + Text auswählen, dann ein Werkzeug, um ihn zu markieren + Rückgängig + Wiederholen + Fett + Kursiv + Unterstrichen + Durchgestrichen + Textfarbe + Hervorheben + Textgröße + Farbe für %1$s + %1$s pt + Hervorheben + Unterstreichen + Durchstreichen + Wellenlinie + Zeichnen + Schwarz + Rot + Gelb + Grün + Blau + Rosa + Keine Hervorhebung + Pro + Teil von Pro + Text formatieren sowie Absätze einfügen oder zusammenführen ist Teil von OpenDocument Reader Pro. + PDFs markieren ist Teil von OpenDocument Reader Pro. + Nicht jetzt + Ein Zeilenumbruch innerhalb eines Absatzes kann nicht gespeichert werden. Drücken Sie die Eingabetaste für einen neuen Absatz. + Diese Zelle enthält eine Formel und bleibt, wie sie ist. + Formeln eingeben wird noch nicht unterstützt. + Diese Zelle enthält mehr als reinen Text und bleibt, wie sie ist. + Diese Zelle enthält eine Zeichnung und bleibt, wie sie ist. + Dieses Dokument kann nicht bearbeitet werden. + Eine Änderung kann nicht über ein Bild oder eine Tabelle hinausreichen. + Diese Änderung ist hier nicht möglich. + + %d Formelzelle zeigt ein Ergebnis, das durch Ihre Änderung veraltet ist. Die gespeicherte Datei behält die Formel, und eine Tabellenkalkulation berechnet sie neu. + %d Formelzellen zeigen Ergebnisse, die durch Ihre Änderung veraltet sind. Die gespeicherte Datei behält die Formeln, und eine Tabellenkalkulation berechnet sie neu. + diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index ea5c1fe0bce6..e050d71ff807 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -75,4 +75,47 @@ Cambios sin guardar ¿Quiere guardarlos ahora? Descartar + + Marcar PDF + Seleccione texto y luego una herramienta para marcarlo + Deshacer + Rehacer + Negrita + Cursiva + Subrayado + Tachado + Color del texto + Resaltar + Tamaño del texto + Color de %1$s + %1$s pt + Resaltar + Subrayar + Tachar + Subrayado ondulado + Dibujar + Negro + Rojo + Amarillo + Verde + Azul + Rosa + Sin resaltado + Pro + Parte de Pro + Dar formato al texto, así como añadir o unir párrafos, forma parte de OpenDocument Reader Pro. + Marcar un PDF forma parte de OpenDocument Reader Pro. + Ahora no + Un salto de línea dentro de un párrafo no se puede guardar. Pulse Intro para crear un párrafo nuevo. + Esa celda contiene una fórmula y se queda como está. + Todavía no se admite escribir fórmulas. + Esa celda contiene más que texto sin formato y se queda como está. + Esa celda contiene un dibujo y se queda como está. + Este documento no se puede editar. + Una edición no puede abarcar una imagen o una tabla. + Esa edición no es posible aquí. + + %d celda con fórmula muestra un resultado que su edición dejó desactualizado. El archivo guardado conserva la fórmula y una aplicación de hojas de cálculo la vuelve a calcular. + %d celdas con fórmula muestran resultados que su edición dejó desactualizados. El archivo guardado conserva las fórmulas y una aplicación de hojas de cálculo las vuelve a calcular. + diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 9666c69d859e..eabf600ce171 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -75,4 +75,47 @@ Salvestamata muudatused Kas soovid need nüüd salvestada? Loobu + + Märgista PDF + Vali tekst ja siis tööriist, et see märgistada + Võta tagasi + Tee uuesti + Paks + Kaldkiri + Allajoonitud + Läbikriipsutatud + Teksti värv + Esiletõst + Teksti suurus + %1$s: värv + %1$s pt + Esiletõst + Allajoonimine + Läbikriipsutus + Lainjas allajoonimine + Joonista + Must + Punane + Kollane + Roheline + Sinine + Roosa + Esiletõstuta + Pro + Osa Pro-versioonist + Teksti vormindamine ning lõikude lisamine või ühendamine on osa rakendusest OpenDocument Reader Pro. + PDF-i märgistamine on osa rakendusest OpenDocument Reader Pro. + Mitte praegu + Reavahetust lõigu sees ei saa salvestada. Uue lõigu jaoks vajuta Enter. + Selles lahtris on valem ja see jääb nii, nagu on. + Valemite sisestamist veel ei toetata. + Selles lahtris on rohkem kui lihttekst ja see jääb nii, nagu on. + Selles lahtris on joonis ja see jääb nii, nagu on. + Seda dokumenti ei saa muuta. + Muudatus ei saa ulatuda üle pildi ega tabeli. + See muudatus pole siin võimalik. + + %d valemiga lahter näitab tulemust, mille sinu muudatus aegunuks muutis. Salvestatud fail säilitab valemi ja arvutustabelirakendus arvutab selle uuesti. + %d valemiga lahtrit näitavad tulemusi, mille sinu muudatus aegunuks muutis. Salvestatud fail säilitab valemid ja arvutustabelirakendus arvutab need uuesti. + diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 61f625ee39cf..0967f5b64898 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -75,4 +75,47 @@ Modifications non enregistrées Voulez-vous les enregistrer maintenant ? Ne pas enregistrer + + Annoter le PDF + Sélectionnez du texte, puis un outil, pour le marquer + Annuler + Rétablir + Gras + Italique + Souligné + Barré + Couleur du texte + Surligner + Taille du texte + Couleur : %1$s + %1$s pt + Surligner + Souligner + Barrer + Soulignement ondulé + Dessiner + Noir + Rouge + Jaune + Vert + Bleu + Rose + Aucun surlignage + Pro + Inclus dans Pro + La mise en forme du texte, ainsi que l\'ajout ou la fusion de paragraphes, fait partie d\'OpenDocument Reader Pro. + L\'annotation des PDF fait partie d\'OpenDocument Reader Pro. + Pas maintenant + Un saut de ligne à l\'intérieur d\'un paragraphe ne peut pas être enregistré. Appuyez sur Entrée pour créer un nouveau paragraphe. + Cette cellule contient une formule et reste telle quelle. + La saisie de formules n\'est pas encore prise en charge. + Cette cellule contient plus que du texte brut et reste telle quelle. + Cette cellule contient un dessin et reste telle quelle. + Ce document ne peut pas être modifié. + Une modification ne peut pas s\'étendre sur une image ou un tableau. + Cette modification n\'est pas possible ici. + + %d cellule de formule affiche un résultat que votre modification a rendu obsolète. Le fichier enregistré conserve la formule, et un tableur la recalcule. + %d cellules de formule affichent des résultats que votre modification a rendus obsolètes. Le fichier enregistré conserve les formules, et un tableur les recalcule. + diff --git a/app/src/main/res/values-ga/strings.xml b/app/src/main/res/values-ga/strings.xml index eb834b4c1f64..4b7ddf9c9e8d 100644 --- a/app/src/main/res/values-ga/strings.xml +++ b/app/src/main/res/values-ga/strings.xml @@ -75,4 +75,50 @@ Athruithe gan sábháil Ar mhaith leat iad a shábháil anois? Ná sábháil + + Marcáil an PDF + Roghnaigh téacs, ansin uirlis, chun é a mharcáil + Cealaigh + Athdhéan + Trom + Iodálach + Líne faoi + Líne tríd + Dath an téacs + Aibhsigh + Méid an téacs + Dath: %1$s + %1$s pt + Aibhsigh + Cuir líne faoi + Cuir líne tríd + Líne chasta faoi + Tarraing + Dubh + Dearg + Buí + Glas + Gorm + Bándearg + Gan aibhsiú + Pro + Cuid de Pro + Is cuid de OpenDocument Reader Pro é téacs a fhormáidiú, agus míreanna a chur leis nó a chumasc. + Is cuid de OpenDocument Reader Pro é PDF a mharcáil. + Ní anois + Ní féidir briseadh líne laistigh de mhír a shábháil. Brúigh Enter le haghaidh míre nua. + Tá foirmle sa chill sin agus fanfaidh sí mar atá. + Ní thacaítear le foirmle a chlóscríobh fós. + Tá níos mó ná gnáth-théacs sa chill sin agus fanfaidh sí mar atá. + Tá líníocht sa chill sin agus fanfaidh sí mar atá. + Ní féidir an cháipéis seo a chur in eagar. + Ní féidir le heagarthóireacht dul thar phictiúr ná thar tábla. + Níl an eagarthóireacht sin indéanta anseo. + + Taispeánann %d chill fhoirmle toradh atá as dáta de bharr d\'eagarthóireachta. Coinníonn an comhad sábháilte an fhoirmle, agus ríomhann aip scarbhileog arís í. + Taispeánann %d cill fhoirmle torthaí atá as dáta de bharr d\'eagarthóireachta. Coinníonn an comhad sábháilte na foirmlí, agus ríomhann aip scarbhileog arís iad. + Taispeánann %d cill fhoirmle torthaí atá as dáta de bharr d\'eagarthóireachta. Coinníonn an comhad sábháilte na foirmlí, agus ríomhann aip scarbhileog arís iad. + Taispeánann %d cill fhoirmle torthaí atá as dáta de bharr d\'eagarthóireachta. Coinníonn an comhad sábháilte na foirmlí, agus ríomhann aip scarbhileog arís iad. + Taispeánann %d cill fhoirmle torthaí atá as dáta de bharr d\'eagarthóireachta. Coinníonn an comhad sábháilte na foirmlí, agus ríomhann aip scarbhileog arís iad. + diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index c5d8934ad221..81eb4697fa12 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -75,4 +75,47 @@ सहेजे नहीं गए बदलाव क्या आप उन्हें अभी सहेजना चाहते हैं? छोड़ दें + + PDF चिह्नित करें + चिह्नित करने के लिए टेक्स्ट चुनें, फिर कोई टूल + पूर्ववत करें + फिर से करें + बोल्ड + इटैलिक + रेखांकित + काटा हुआ + टेक्स्ट का रंग + हाइलाइट + टेक्स्ट का आकार + %1$s का रंग + %1$s pt + हाइलाइट + रेखांकन + काटें + लहरदार रेखांकन + ड्रॉ करें + काला + लाल + पीला + हरा + नीला + गुलाबी + कोई हाइलाइट नहीं + Pro + Pro का हिस्सा + टेक्स्ट को फ़ॉर्मैट करना और अनुच्छेद जोड़ना या मिलाना OpenDocument Reader Pro का हिस्सा है। + PDF चिह्नित करना OpenDocument Reader Pro का हिस्सा है। + अभी नहीं + अनुच्छेद के भीतर लाइन ब्रेक सहेजा नहीं जा सकता। नए अनुच्छेद के लिए Enter दबाएँ। + इस सेल में एक सूत्र है, इसलिए यह जैसा है वैसा ही रहेगा। + सूत्र टाइप करना अभी समर्थित नहीं है। + इस सेल में सादे टेक्स्ट से अधिक है, इसलिए यह जैसा है वैसा ही रहेगा। + इस सेल में एक ड्रॉइंग है, इसलिए यह जैसा है वैसा ही रहेगा। + यह दस्तावेज़ संपादित नहीं किया जा सकता। + कोई संपादन किसी चित्र या तालिका के पार नहीं जा सकता। + यह संपादन यहाँ संभव नहीं है। + + %d सूत्र वाला सेल ऐसा परिणाम दिखा रहा है जो आपके संपादन से पुराना हो गया है। सहेजी गई फ़ाइल सूत्र को रखती है, और स्प्रेडशीट ऐप उसे फिर से गणना करता है। + %d सूत्र वाले सेल ऐसे परिणाम दिखा रहे हैं जो आपके संपादन से पुराने हो गए हैं। सहेजी गई फ़ाइल सूत्रों को रखती है, और स्प्रेडशीट ऐप उन्हें फिर से गणना करता है। + diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index a91a318f0d31..5616e5c050f8 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -75,4 +75,47 @@ Modifiche non salvate Vuoi salvarle ora? Ignora + + Annota PDF + Seleziona il testo, poi uno strumento, per evidenziarlo + Annulla + Ripeti + Grassetto + Corsivo + Sottolineato + Barrato + Colore del testo + Evidenzia + Dimensione del testo + Colore di %1$s + %1$s pt + Evidenzia + Sottolinea + Barra + Sottolineatura ondulata + Disegna + Nero + Rosso + Giallo + Verde + Blu + Rosa + Nessuna evidenziazione + Pro + Parte di Pro + Formattare il testo e aggiungere o unire paragrafi fa parte di OpenDocument Reader Pro. + Annotare un PDF fa parte di OpenDocument Reader Pro. + Non ora + Un\'interruzione di riga all\'interno di un paragrafo non può essere salvata. Premi Invio per un nuovo paragrafo. + Questa cella contiene una formula e resta così com\'è. + Digitare una formula non è ancora supportato. + Questa cella contiene più del semplice testo e resta così com\'è. + Questa cella contiene un disegno e resta così com\'è. + Questo documento non può essere modificato. + Una modifica non può estendersi su un\'immagine o una tabella. + Questa modifica non è possibile qui. + + %d cella con formula mostra un risultato reso obsoleto dalla tua modifica. Il file salvato mantiene la formula e un\'app per fogli di calcolo la ricalcola. + %d celle con formula mostrano risultati resi obsoleti dalla tua modifica. Il file salvato mantiene le formule e un\'app per fogli di calcolo le ricalcola. + diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 8aa8d7998fd2..3eb66f983533 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -75,4 +75,46 @@ 保存されていない変更 今すぐ保存しますか? 破棄 + + PDF に書き込む + テキストを選択してからツールを選ぶと、マークできます + 元に戻す + やり直す + 太字 + 斜体 + 下線 + 取り消し線 + 文字の色 + ハイライト + 文字サイズ + %1$sの色 + %1$s pt + ハイライト + 下線 + 取り消し線 + 波線 + 描画 + 黒 + 赤 + 黄 + 緑 + 青 + ピンク + ハイライトなし + Pro + Pro の機能 + テキストの書式設定と段落の追加・結合は OpenDocument Reader Pro の機能です。 + PDF への書き込みは OpenDocument Reader Pro の機能です。 + 今はしない + 段落内の改行は保存できません。新しい段落にするには Enter を押してください。 + このセルには数式があるため、そのままになります。 + 数式の入力にはまだ対応していません。 + このセルにはプレーンテキスト以外の内容があるため、そのままになります。 + このセルには図形があるため、そのままになります。 + このドキュメントは編集できません。 + 画像や表をまたいで編集することはできません。 + ここではその編集はできません。 + + %d 個の数式セルに、編集によって古くなった結果が表示されています。保存したファイルには数式が残り、表計算アプリで再計算されます。 + diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 2ac53a6585c4..7c530efa5642 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -75,4 +75,46 @@ 저장하지 않은 변경 사항 지금 저장하시겠습니까? 저장 안 함 + + PDF 표시하기 + 텍스트를 선택한 다음 도구를 선택해 표시하세요 + 실행 취소 + 다시 실행 + 굵게 + 기울임꼴 + 밑줄 + 취소선 + 글자 색 + 강조 표시 + 글자 크기 + %1$s 색상 + %1$s pt + 강조 표시 + 밑줄 + 취소선 + 물결 밑줄 + 그리기 + 검정 + 빨강 + 노랑 + 초록 + 파랑 + 분홍 + 강조 표시 없음 + Pro + Pro 기능 + 텍스트 서식 지정과 단락 추가 또는 병합은 OpenDocument Reader Pro 기능입니다. + PDF 표시는 OpenDocument Reader Pro 기능입니다. + 나중에 + 단락 안의 줄 바꿈은 저장할 수 없습니다. 새 단락을 만들려면 Enter를 누르세요. + 이 셀에는 수식이 있어 그대로 유지됩니다. + 수식 입력은 아직 지원되지 않습니다. + 이 셀에는 일반 텍스트 이상의 내용이 있어 그대로 유지됩니다. + 이 셀에는 그림이 있어 그대로 유지됩니다. + 이 문서는 편집할 수 없습니다. + 편집 범위에 그림이나 표를 포함할 수 없습니다. + 여기서는 그렇게 편집할 수 없습니다. + + 수식 셀 %d개에 편집으로 인해 오래된 결과가 표시됩니다. 저장된 파일에는 수식이 유지되며, 스프레드시트 앱에서 다시 계산합니다. + diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 45e7d1c45859..68fc190eeb99 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -75,4 +75,49 @@ Niezapisane zmiany Czy chcesz je teraz zapisać? Odrzuć + + Oznacz PDF + Zaznacz tekst, a potem wybierz narzędzie, aby go oznaczyć + Cofnij + Ponów + Pogrubienie + Kursywa + Podkreślenie + Przekreślenie + Kolor tekstu + Wyróżnienie + Rozmiar tekstu + Kolor: %1$s + %1$s pt + Wyróżnienie + Podkreślenie + Przekreślenie + Podkreślenie falowane + Rysowanie + Czarny + Czerwony + Żółty + Zielony + Niebieski + Różowy + Bez wyróżnienia + Pro + Część wersji Pro + Formatowanie tekstu oraz dodawanie i łączenie akapitów to część OpenDocument Reader Pro. + Oznaczanie plików PDF to część OpenDocument Reader Pro. + Nie teraz + Podziału wiersza wewnątrz akapitu nie można zapisać. Naciśnij Enter, aby utworzyć nowy akapit. + Ta komórka zawiera formułę i pozostaje bez zmian. + Wpisywanie formuł nie jest jeszcze obsługiwane. + Ta komórka zawiera więcej niż zwykły tekst i pozostaje bez zmian. + Ta komórka zawiera rysunek i pozostaje bez zmian. + Tego dokumentu nie można edytować. + Edycja nie może obejmować obrazu ani tabeli. + Ta edycja nie jest tu możliwa. + + %d komórka z formułą pokazuje wynik, który Twoja edycja zdezaktualizowała. Zapisany plik zachowuje formułę, a arkusz kalkulacyjny przeliczy ją ponownie. + %d komórki z formułami pokazują wyniki, które Twoja edycja zdezaktualizowała. Zapisany plik zachowuje formuły, a arkusz kalkulacyjny przeliczy je ponownie. + %d komórek z formułami pokazuje wyniki, które Twoja edycja zdezaktualizowała. Zapisany plik zachowuje formuły, a arkusz kalkulacyjny przeliczy je ponownie. + %d komórki z formułami pokazuje wyniki, które Twoja edycja zdezaktualizowała. Zapisany plik zachowuje formuły, a arkusz kalkulacyjny przeliczy je ponownie. + diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index a63be4891f48..e746a5eeb998 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -75,4 +75,47 @@ Alterações não salvas Deseja salvá-las agora? Descartar + + Marcar PDF + Selecione o texto e depois uma ferramenta para marcá-lo + Desfazer + Refazer + Negrito + Itálico + Sublinhado + Tachado + Cor do texto + Realçar + Tamanho do texto + Cor de %1$s + %1$s pt + Realçar + Sublinhar + Tachar + Sublinhado ondulado + Desenhar + Preto + Vermelho + Amarelo + Verde + Azul + Rosa + Sem realce + Pro + Parte do Pro + Formatar texto e adicionar ou juntar parágrafos faz parte do OpenDocument Reader Pro. + Marcar um PDF faz parte do OpenDocument Reader Pro. + Agora não + Uma quebra de linha dentro de um parágrafo não pode ser salva. Pressione Enter para um novo parágrafo. + Essa célula contém uma fórmula e fica como está. + Digitar fórmulas ainda não é suportado. + Essa célula contém mais do que texto simples e fica como está. + Essa célula contém um desenho e fica como está. + Este documento não pode ser editado. + Uma edição não pode abranger uma imagem ou uma tabela. + Essa edição não é possível aqui. + + %d célula com fórmula mostra um resultado que sua edição desatualizou. O arquivo salvo mantém a fórmula, e um aplicativo de planilhas a calcula de novo. + %d células com fórmula mostram resultados que sua edição desatualizou. O arquivo salvo mantém as fórmulas, e um aplicativo de planilhas as calcula de novo. + diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 9021cf67e263..d8c7e08510b9 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -75,4 +75,49 @@ Несохранённые изменения Сохранить их сейчас? Не сохранять + + Разметить PDF + Выделите текст, затем выберите инструмент, чтобы его отметить + Отменить + Повторить + Полужирный + Курсив + Подчёркнутый + Зачёркнутый + Цвет текста + Выделение цветом + Размер текста + Цвет: %1$s + %1$s пт + Выделение + Подчёркивание + Зачёркивание + Волнистое подчёркивание + Рисование + Чёрный + Красный + Жёлтый + Зелёный + Синий + Розовый + Без выделения + Pro + Входит в Pro + Форматирование текста, а также добавление и объединение абзацев входят в OpenDocument Reader Pro. + Разметка PDF входит в OpenDocument Reader Pro. + Не сейчас + Разрыв строки внутри абзаца нельзя сохранить. Нажмите Enter, чтобы начать новый абзац. + В этой ячейке формула, и она останется без изменений. + Ввод формул пока не поддерживается. + В этой ячейке больше чем простой текст, и она останется без изменений. + В этой ячейке рисунок, и она останется без изменений. + Этот документ нельзя редактировать. + Правка не может охватывать изображение или таблицу. + Такая правка здесь невозможна. + + %d ячейка с формулой показывает результат, который ваша правка сделала устаревшим. Сохранённый файл сохраняет формулу, и табличный редактор пересчитает её. + %d ячейки с формулами показывают результаты, которые ваша правка сделала устаревшими. Сохранённый файл сохраняет формулы, и табличный редактор пересчитает их. + %d ячеек с формулами показывают результаты, которые ваша правка сделала устаревшими. Сохранённый файл сохраняет формулы, и табличный редактор пересчитает их. + %d ячейки с формулами показывают результаты, которые ваша правка сделала устаревшими. Сохранённый файл сохраняет формулы, и табличный редактор пересчитает их. + diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml index cb9da75f6d1d..b81c6a38b41b 100644 --- a/app/src/main/res/values-sl/strings.xml +++ b/app/src/main/res/values-sl/strings.xml @@ -75,4 +75,49 @@ Neshranjene spremembe Ali jih želite shraniti zdaj? Zavrzi + + Označi PDF + Izberite besedilo in nato orodje, da ga označite + Razveljavi + Uveljavi + Krepko + Ležeče + Podčrtano + Prečrtano + Barva besedila + Označevanje + Velikost besedila + Barva: %1$s + %1$s pt + Označevanje + Podčrtovanje + Prečrtovanje + Valovito podčrtovanje + Risanje + Črna + Rdeča + Rumena + Zelena + Modra + Rožnata + Brez označevanja + Pro + Del različice Pro + Oblikovanje besedila ter dodajanje ali združevanje odstavkov je del aplikacije OpenDocument Reader Pro. + Označevanje PDF-jev je del aplikacije OpenDocument Reader Pro. + Ne zdaj + Preloma vrstice znotraj odstavka ni mogoče shraniti. Za nov odstavek pritisnite Enter. + Ta celica vsebuje formulo in ostane nespremenjena. + Vnašanje formul še ni podprto. + Ta celica vsebuje več kot navadno besedilo in ostane nespremenjena. + Ta celica vsebuje risbo in ostane nespremenjena. + Tega dokumenta ni mogoče urejati. + Urejanje ne more segati prek slike ali tabele. + To urejanje tukaj ni mogoče. + + %d celica s formulo prikazuje rezultat, ki ga je vaše urejanje naredilo zastarelega. Shranjena datoteka ohrani formulo, program za preglednice pa jo znova izračuna. + %d celici s formulo prikazujeta rezultata, ki ju je vaše urejanje naredilo zastarela. Shranjena datoteka ohrani formuli, program za preglednice pa ju znova izračuna. + %d celice s formulo prikazujejo rezultate, ki jih je vaše urejanje naredilo zastarele. Shranjena datoteka ohrani formule, program za preglednice pa jih znova izračuna. + %d celic s formulo prikazuje rezultate, ki jih je vaše urejanje naredilo zastarele. Shranjena datoteka ohrani formule, program za preglednice pa jih znova izračuna. + diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 073264695cf6..c2b3dd4d2c14 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -75,4 +75,47 @@ Osparade ändringar Vill du spara dem nu? Spara inte + + Markera PDF + Välj text och sedan ett verktyg för att markera den + Ångra + Gör om + Fet + Kursiv + Understruken + Genomstruken + Textfärg + Överstrykning + Textstorlek + Färg för %1$s + %1$s pt + Överstrykning + Understrykning + Genomstrykning + Vågig understrykning + Rita + Svart + Röd + Gul + Grön + Blå + Rosa + Ingen överstrykning + Pro + En del av Pro + Att formatera text och lägga till eller slå ihop stycken ingår i OpenDocument Reader Pro. + Att markera en PDF ingår i OpenDocument Reader Pro. + Inte nu + En radbrytning inuti ett stycke kan inte sparas. Tryck på Enter för ett nytt stycke. + Cellen innehåller en formel och förblir som den är. + Att skriva formler stöds inte ännu. + Cellen innehåller mer än vanlig text och förblir som den är. + Cellen innehåller en ritning och förblir som den är. + Det här dokumentet kan inte redigeras. + En redigering kan inte sträcka sig över en bild eller en tabell. + Den redigeringen är inte möjlig här. + + %d formelcell visar ett resultat som din redigering har gjort inaktuellt. Den sparade filen behåller formeln, och ett kalkylprogram räknar ut den igen. + %d formelceller visar resultat som din redigering har gjort inaktuella. Den sparade filen behåller formlerna, och ett kalkylprogram räknar ut dem igen. + diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 8b583817e27b..52a1f590206f 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -75,4 +75,47 @@ Kaydedilmemiş değişiklikler Bunları şimdi kaydetmek ister misiniz? Kaydetme + + PDF\'yi işaretle + İşaretlemek için metni, ardından bir araç seçin + Geri al + Yinele + Kalın + İtalik + Altı çizili + Üstü çizili + Metin rengi + Vurgula + Metin boyutu + %1$s rengi + %1$s pt + Vurgula + Altını çiz + Üstünü çiz + Dalgalı alt çizgi + Çiz + Siyah + Kırmızı + Sarı + Yeşil + Mavi + Pembe + Vurgu yok + Pro + Pro\'nun bir parçası + Metni biçimlendirmek ve paragraf eklemek ya da birleştirmek OpenDocument Reader Pro\'nun bir parçasıdır. + PDF işaretlemek OpenDocument Reader Pro\'nun bir parçasıdır. + Şimdi değil + Paragraf içindeki bir satır sonu kaydedilemez. Yeni paragraf için Enter tuşuna basın. + Bu hücre bir formül içeriyor ve olduğu gibi kalıyor. + Formül yazmak henüz desteklenmiyor. + Bu hücre düz metinden fazlasını içeriyor ve olduğu gibi kalıyor. + Bu hücre bir çizim içeriyor ve olduğu gibi kalıyor. + Bu belge düzenlenemez. + Bir düzenleme bir resmin veya tablonun üzerinden geçemez. + Bu düzenleme burada mümkün değil. + + %d formül hücresi, düzenlemenizin eskittiği bir sonuç gösteriyor. Kaydedilen dosya formülü korur ve bir hesap tablosu uygulaması onu yeniden hesaplar. + %d formül hücresi, düzenlemenizin eskittiği sonuçlar gösteriyor. Kaydedilen dosya formülleri korur ve bir hesap tablosu uygulaması onları yeniden hesaplar. + diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index f557a23331f9..c7ab041c5319 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -75,4 +75,46 @@ 有未保存的更改 要现在保存吗? 不保存 + + 标注 PDF + 先选择文本,再选择工具来标注 + 撤销 + 重做 + 粗体 + 斜体 + 下划线 + 删除线 + 文字颜色 + 突出显示 + 文字大小 + %1$s颜色 + %1$s 磅 + 突出显示 + 下划线 + 删除线 + 波浪线 + 绘图 + 黑色 + 红色 + 黄色 + 绿色 + 蓝色 + 粉色 + 无突出显示 + Pro + Pro 功能 + 设置文本格式以及添加或合并段落是 OpenDocument Reader Pro 的功能。 + 标注 PDF 是 OpenDocument Reader Pro 的功能。 + 以后再说 + 段落内的换行无法保存。按 Enter 键可新建段落。 + 该单元格包含公式,将保持不变。 + 尚不支持输入公式。 + 该单元格包含的不只是纯文本,将保持不变。 + 该单元格包含绘图,将保持不变。 + 此文档无法编辑。 + 编辑不能跨越图片或表格。 + 此处无法进行该编辑。 + + %d 个公式单元格显示的结果因您的编辑而过时。保存的文件会保留公式,电子表格应用会重新计算。 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 13c3b0850c12..d4aea09e923b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -97,6 +97,55 @@ support@opendocument.app + + Mark up PDF + Select text, then a tool, to mark it + Undo + Redo + Bold + Italic + Underline + Strikethrough + Text color + Highlight + Text size + + %1$s color + + %1$s pt + Highlight + Underline + Strike out + Squiggly underline + Draw + Black + Red + Yellow + Green + Blue + Pink + No highlight + + Pro + + Part of Pro + Formatting text, and adding or joining paragraphs, is part of OpenDocument Reader Pro. + Marking up a PDF is part of OpenDocument Reader Pro. + Not now + + A line break inside a paragraph cannot be saved. Press Enter for a new paragraph. + That cell holds a formula and stays as it is. + Typing a formula is not supported yet. + That cell holds more than plain text and stays as it is. + That cell holds a drawing and stays as it is. + This document cannot be edited. + An edit cannot reach over a picture or a table. + That edit is not possible here. + + %d formula cell shows a result your edit made out of date. The saved file keeps the formula, and a spreadsheet app computes it again. + %d formula cells show results your edit made out of date. The saved file keeps the formulas, and a spreadsheet app computes them again. + + OK Cancel diff --git a/app/src/noAds/java/app/opendocument/droid/nonfree/Linked.kt b/app/src/noAds/java/app/opendocument/droid/nonfree/Linked.kt index 4b1b643b92ec..c13e25a0537f 100644 --- a/app/src/noAds/java/app/opendocument/droid/nonfree/Linked.kt +++ b/app/src/noAds/java/app/opendocument/droid/nonfree/Linked.kt @@ -2,3 +2,6 @@ package app.opendocument.droid.nonfree /** Read through [Features]. */ internal const val LINKS_ADS = false + +/** Read through [Features]. */ +internal const val ADVANCED_EDITING = true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6240a4f7fd0b..49535e932e0e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,7 +5,7 @@ googleJavaFormat = "1.35.0" ktfmt = "0.64" # odrcore's JNI bindings, java and native in one AAR, published from OpenDocument.core -odrCore = "6.13.0" +odrCore = "7.1.0" androidxAnnotation = "1.10.0" androidxAppcompat = "1.8.0"