From 05ab48930ae83e0d2d4448d7359cab70972a8d27 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sun, 20 Sep 2026 18:28:36 +0200 Subject: [PATCH 1/5] Catch up with the Android app's editing UI, on core 7.2.1 The engine moves to 7.2.1, and the editing UI follows OpenDocument.droid #668 through #677. The bar holds what is done to the document, the strip what is done to the text. Undo, redo and save are bar buttons, so they do not scroll away, and the page's report enables them. Redo stays out over a pdf, where a mark is taken back one at a time and never put back. The magnifier and the document's name stand down while an edit is on, because six buttons and a name do not fit a phone's bar. The strip is formatting alone, so a sheet or a plain text file shows no strip at all. Every tool is one square: a tap does the tool's one job and a long press opens the colours it applies, so the chevrons are gone. The text colour and the size open on a tap, having no state to turn off. VoiceOver has no long press, so the colours are the button's own actions too. Each tool is an icon over a slot, and the slot is there whether it holds anything or not, so the icons sit on one line and no colour bar runs into the glyph. The row is centred where the tools leave room. The gate moved from the mode to the tool. Both editions open every kind the core calls editable, a pdf included, and a locked strip dims what only offers Pro and leaves the highlighter working - highlight being the formatting style and the pdf's marking tool alike. The two offers say what is still Pro, in every language. Core 7.2.0 is what the free highlighter needs: it takes a format that starts and ends in one paragraph. The same pencil edits a document and marks up a pdf, since the two never stand in the bar together and the label separates them. The banner leaves the middle of the screen for the top, so the tools stand on the document rather than on the ad. The bridge states the sheet's tap to edit, because a web view answers the pointer as a mouse in the simulator. Checked on an iPhone 17 simulator in both editions, and 91 unit tests pass. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XASU9ZfSJ6QwsAJGE3DrN1 --- CHANGELOG.md | 21 +- OpenDocumentReader.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- .../DocumentViewController.swift | 199 ++++++--- OpenDocumentReader/EditToolBar.swift | 404 ++++++++++++------ OpenDocumentReader/Main.storyboard | 1 - .../ca.lproj/Localizable.strings | 4 +- .../cs.lproj/Localizable.strings | 4 +- .../da.lproj/Localizable.strings | 4 +- .../de.lproj/Localizable.strings | 4 +- .../en.lproj/Localizable.strings | 13 +- .../es.lproj/Localizable.strings | 4 +- .../fr.lproj/Localizable.strings | 4 +- .../ga.lproj/Localizable.strings | 4 +- .../it.lproj/Localizable.strings | 4 +- .../ja.lproj/Localizable.strings | 4 +- .../pl.lproj/Localizable.strings | 4 +- .../pt-BR.lproj/Localizable.strings | 4 +- .../ru.lproj/Localizable.strings | 4 +- .../sl.lproj/Localizable.strings | 4 +- .../tr.lproj/Localizable.strings | 4 +- .../zh-Hans.lproj/Localizable.strings | 4 +- .../EditWorkflowTests.swift | 100 ++++- README.md | 46 +- 24 files changed, 584 insertions(+), 266 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc57da9..e8f3f5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,23 +23,32 @@ once the version tag exists. - The name of the open document is shown at the top, between the buttons. - Editing tools under the bar: bold, italic, underline, strikethrough, text - colour, highlight, text size, undo and redo. + colour, highlight and text size. A tap uses the tool, a long press picks its + colour. +- Undo, redo and save sit in the bar while you edit. - A PDF can be marked up with highlights, lines and drawings. - Presentations, Excel files and plain text files can be edited and saved. ### Changed -- The engine is odrcore 7.1.0, up from 6.13.0. +- The engine is odrcore 7.2.1, up from 6.13.0. - Entering an edit keeps your place in the document. -- The pencil turns editing on and off, and a new save button saves without - leaving the edit. -- Lite edits text inside a paragraph. Formatting, paragraphs and PDF marks are - part of Pro. +- The pencil turns editing on and off, and marks up a PDF as well. +- Lite opens every document for editing. The highlighter is in both apps; bold, + italic, colours, text size, paragraphs and the other PDF marks are part of + Pro. +- In the Lite app the advert sits above the bar, so the tools stand on the + document. +- A very wide page, such as an A0 drawing, can be zoomed out to fit. +- A cell in a spreadsheet opens for editing on a single tap. ### Fixed - An email address or a date in a PDF no longer has a link drawn over it, which showed the text twice. +- A mark on a PDF covers the letters it marks, and an underline sits under the + text rather than through it. +- Selecting a word in a PDF keeps the word readable and gives it both handles. ## [1.44] diff --git a/OpenDocumentReader.xcodeproj/project.pbxproj b/OpenDocumentReader.xcodeproj/project.pbxproj index 627f082..cc78241 100644 --- a/OpenDocumentReader.xcodeproj/project.pbxproj +++ b/OpenDocumentReader.xcodeproj/project.pbxproj @@ -830,7 +830,7 @@ repositoryURL = "https://github.com/opendocument-app/OpenDocument.core.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 7.1.0; + minimumVersion = 7.2.1; }; }; AD584FCD41577C8CDEE974AA /* XCRemoteSwiftPackageReference "swift-package-manager-google-mobile-ads" */ = { diff --git a/OpenDocumentReader.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/OpenDocumentReader.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 8ed0f6c..7c82040 100644 --- a/OpenDocumentReader.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/OpenDocumentReader.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/opendocument-app/OpenDocument.core.git", "state" : { - "revision" : "cee4ef5e7dbc4e781bb8459dc5269cded98f824a", - "version" : "7.1.0" + "revision" : "7d6b5ac9555bef8432f246ebb1c6ff8cf69d1619", + "version" : "7.2.1" } }, { diff --git a/OpenDocumentReader/DocumentViewController.swift b/OpenDocumentReader/DocumentViewController.swift index 843efd7..621f28b 100644 --- a/OpenDocumentReader/DocumentViewController.swift +++ b/OpenDocumentReader/DocumentViewController.swift @@ -60,6 +60,8 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel /// The document's name, sitting in the bar's empty middle. let documentTitleLabel = DocumentTitleLabel() private lazy var documentTitleItem = UIBarButtonItem(customView: documentTitleLabel) + private lazy var documentTitleSpacer = UIBarButtonItem( + barButtonSystemItem: .flexibleSpace, target: nil, action: nil) /// The bar as the storyboard has it, taken before anything is removed, since /// that is the only moment every button is there to be read. @@ -68,8 +70,14 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel /// Whether the document on screen can be edited and searched. Neither button /// stays in the bar when it cannot be used. private var canEdit = false { didSet { updateToolBar() } } - /// Whether the document is a pdf that takes marks. - private var canMark = false { didSet { updateEditButtonRole() } } + /// Whether the document is a pdf that takes marks. It decides the pen's + /// label and whether redo is in the bar. + private var canMark = false { + didSet { + updateEditButtonRole() + updateToolBar() + } + } private var isEditingDocument = false { didSet { updateEditButtonRole() @@ -81,25 +89,43 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel } } + /// Takes the last edit back. Only in the bar while editing. + lazy var undoButton: UIBarButtonItem = makeEditButton( + symbol: "arrow.uturn.backward", label: "edit_undo", action: #selector(undoTapped(_:))) + + /// Puts it back. A mark on a pdf is taken back one at a time and never put + /// back, so this one leaves the bar over a pdf - see ``updateToolBar()``. + lazy var redoButton: UIBarButtonItem = makeEditButton( + symbol: "arrow.uturn.forward", label: "edit_redo", action: #selector(redoTapped(_:))) + /// Saves the edit, and stays in it. Only in the bar while editing. - lazy var saveButton: UIBarButtonItem = { + lazy var saveButton: UIBarButtonItem = makeEditButton( + symbol: "square.and.arrow.down", label: "action_edit_save", action: #selector(saveTapped(_:))) + + /// The bar holds what is done to the document; the strip under it what is + /// done to the text. All three start off, since a page that has just been + /// opened holds nothing to take back or write. + private func makeEditButton(symbol: String, label: String, action: Selector) -> UIBarButtonItem { let item = UIBarButtonItem( - image: UIImage(systemName: "square.and.arrow.down"), style: .plain, target: self, - action: #selector(saveTapped(_:))) - item.accessibilityLabel = NSLocalizedString("action_edit_save", comment: "") + image: UIImage(systemName: symbol), style: .plain, target: self, action: action) + item.accessibilityLabel = NSLocalizedString(label, comment: "") item.isEnabled = false return item - }() + } - private lazy var saveButtonSpacer: UIBarButtonItem = { + private func makeSpacer() -> UIBarButtonItem { let item = UIBarButtonItem(barButtonSystemItem: .fixedSpace, target: nil, action: nil) item.width = 10 return item - }() + } + + private lazy var undoButtonSpacer: UIBarButtonItem = makeSpacer() + private lazy var redoButtonSpacer: UIBarButtonItem = makeSpacer() + private lazy var saveButtonSpacer: UIBarButtonItem = makeSpacer() - /// The row of tools under the bar while a document is edited. + /// The strip of tools under the bar while a document is edited. let editToolBar = EditToolBar() /// The colour each marker on a pdf takes, until the reader picks another. @@ -190,7 +216,7 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel barButtonItem.accessibilityLabel = NSLocalizedString("back_to_documents", comment: "") updateEditButtonRole() - setUpSaveButton() + setUpEditButtons() setUpDocumentTitle() // nothing is editable or searchable until a page says so @@ -429,9 +455,10 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel } /// From iOS 26 the bar's buttons are glass capsules filling its whole - /// height, which whatever is pinned to its bottom edge would cut off. Older - /// bars have a background of their own and want no such gap. - private static var toolBarBottomMargin: CGFloat { + /// height, which whatever is pinned to either edge would cut off - the + /// banner above it as much as the tools below. Older bars have a + /// background of their own and want no such gap. + private static var toolBarMargin: CGFloat { if #available(iOS 26.0, *) { return 8 } @@ -439,27 +466,43 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel return 0 } + /// What the bar takes. A glass capsule is drawn to the bar's height, so a + /// bar sized to the glyph alone leaves the capsules touching both edges. + private static var toolBarHeight: CGFloat { + if #available(iOS 26.0, *) { + return 50 + } + + return 44 + } + func setVCconstraints() { searchBar.translatesAutoresizingMaskIntoConstraints = false bannerSlot.translatesAutoresizingMaskIntoConstraints = false pageTabBar.translatesAutoresizingMaskIntoConstraints = false webview.translatesAutoresizingMaskIntoConstraints = false - searchBar.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true - searchBar.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true - searchBar.topAnchor.constraint(equalTo: barStack.bottomAnchor, constant: Self.toolBarBottomMargin).isActive = - true - + // the banner sits above everything, the bar included. Under the bar it + // stood between the editing tools and the page, so the tools read as + // part of the ad rather than as part of the document bannerSlot.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true bannerSlot.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true - bannerSlot.topAnchor.constraint(equalTo: searchBar.bottomAnchor).isActive = true + bannerSlot.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor).isActive = true // no height here: that is bannerSlotHeight from the storyboard, which // hideBannerSlot zeroes, and a second one would fight it - // below the banner, which is why the tab bar is not in the tool bar's - // stack: an arranged subview is placed by the stack, and these would be - // a second answer to the same question - pageTabBar.topAnchor.constraint(equalTo: bannerSlot.bottomAnchor).isActive = true + barStack.topAnchor.constraint(equalTo: bannerSlot.bottomAnchor, constant: Self.toolBarMargin).isActive = true + toolBar.heightAnchor.constraint(equalToConstant: Self.toolBarHeight).isActive = true + + searchBar.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true + searchBar.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true + searchBar.topAnchor.constraint(equalTo: barStack.bottomAnchor, constant: Self.toolBarMargin).isActive = + true + + // under the search bar, which is why the tab bar is not in the tool + // bar's stack: an arranged subview is placed by the stack, and these + // would be a second answer to the same question + pageTabBar.topAnchor.constraint(equalTo: searchBar.bottomAnchor).isActive = true pageTabBar.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true pageTabBar.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true pageTabBarHeight.isActive = true @@ -564,11 +607,12 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel /// The pen: turns the mode on, and off again. Leaving with changes the page /// alone holds asks first. See ``updateEditButtonRole()``. + /// + /// The button stands on the core's answer, and every edition opens what it + /// names. What Lite does not sell is the tool, which says so in the strip. @IBAction func toggleEdit(_ sender: UIBarButtonItem) { if isEditingDocument { leaveEdit() - } else if canMark, !Features.advancedEditing { - offerPro(.pdf) } else { editDocument() } @@ -621,6 +665,20 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel saveAndStay() } + /// A pdf keeps its marks in the annotator, not in the editor, so the two + /// take back through different calls. + @objc func undoTapped(_ sender: UIBarButtonItem) { + AnalyticsManager.shared.report("menu_edit_undo") + + run(canMark ? "odr.annotation.undo()" : "odr.editing.undo()") + } + + @objc func redoTapped(_ sender: UIBarButtonItem) { + AnalyticsManager.shared.report("menu_edit_redo") + + run("odr.editing.redo()") + } + func saveAndStay(completion: ((Bool) -> Void)? = nil) { saveContent { success in if success { @@ -673,9 +731,15 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel odr.onCellsStale = function (detail) { post({ type: 'cellsStale', count: detail && detail.cells ? detail.cells.length : 0 }); }; + // only a sheet carries the sheet editor, so this is asked of the + // page rather than taken for granted. The pointer is not asked + // either: a web view answers that one as a mouse in the simulator + if (odr.editing && odr.editing.setSheetOptions) { + odr.editing.setSheetOptions({ editOnClick: true }); + } if (!odr.annotation) { return; } - // an armed tool marks a selection as it is made, which is what a - // touch screen needs + // an armed tool marks each selection as it is made: a tap elsewhere + // would lose it odr.annotation.setOptions({ markOnSelection: true }); odr.onAnnotationChange = function (e) { post({ type: 'marks', count: e && e.count ? e.count : 0 }); @@ -689,13 +753,13 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel switch type { case "editChange": hasUnsavedEdits = body["dirty"] as? Bool ?? false - editToolBar.setEnabled(.undo, body["canUndo"] as? Bool ?? false) - editToolBar.setEnabled(.redo, body["canRedo"] as? Bool ?? false) + undoButton.isEnabled = body["canUndo"] as? Bool ?? false + redoButton.isEnabled = body["canRedo"] as? Bool ?? false case "marks": let count = body["count"] as? Int ?? 0 hasUnsavedEdits = count > 0 - editToolBar.setEnabled(.undo, count > 0) + undoButton.isEnabled = count > 0 case "cellsStale": let count = body["count"] as? Int ?? 0 @@ -737,15 +801,20 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel /// Turns the mode on in the page already on screen and shows its tools. /// A pdf needs no mode, only a marker that acts on a selection. + /// + /// A sheet or a plain text file takes no formatting, so it gets no strip at + /// all: the cells are the editor, and the bar holds the rest. private func beginEditSession() { hasOfferedProForThisEdit = false hasUnsavedEdits = false staleCells = 0 selectionHasHighlight = false + isEditSessionReady = false + undoButton.isEnabled = false + redoButton.isEnabled = false if document?.isAnnotatable == true { editToolBar.layout = .pdf - editToolBar.setEnabled(.undo, false) for tool in EditToolBar.Layout.pdf.tools where tool.showsColor { editToolBar.setColor(tool, markColor(of: tool)) } @@ -755,22 +824,29 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel return } - editToolBar.setEnabled(.undo, false) - editToolBar.setEnabled(.redo, false) - let isPlainText = document?.isPlainText == true webview.evaluateJavaScript("odr.editing.enable(); typeof odr.sheet === 'object'") { [weak self] isSheet, _ in guard let self, self.isEditingDocument else { return } - self.editToolBar.layout = isPlainText || isSheet as? Bool == true ? .plain : .text - self.editToolBar.setColor(.highlight, self.highlightColor) + let formats = !isPlainText && isSheet as? Bool != true + self.editToolBar.layout = formats ? .text : nil + if formats { + self.editToolBar.setColor(.highlight, self.highlightColor) + } self.editSessionReady() } } + /// Whether the page has said what it is and the strip stands as it will. + /// A sheet answers this with no strip at all, so nothing can wait on the + /// strip itself. + private(set) var isEditSessionReady = false + /// The tools are up, which is what a screenshot of an edit waits for. private func editSessionReady() { + isEditSessionReady = true + if ScreenshotMode.screen == .edit { ScreenshotMode.markReady(view) } @@ -784,18 +860,17 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel } } + /// The gate is on the tool, not on the mode: a tool Lite does not sell + /// offers Pro instead of doing its work, and the highlighter beside it + /// works in every edition. private func editToolTapped(_ tool: EditToolBar.Tool) { - if tool.isAdvanced, !Features.advancedEditing { + if !tool.isFree, !Features.advancedEditing { offerPro(canMark ? .pdf : .formatting) return } switch tool { - case .undo: - run(canMark ? "odr.annotation.undo()" : "odr.editing.undo()") - case .redo: - run("odr.editing.redo()") case .bold, .italic, .underline, .strikethrough: run("odr.editing.toggle('\(tool.pageName ?? "")')") case .highlight: @@ -947,11 +1022,14 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel } } - /// The disc goes after the pen, as on the website. - private func setUpSaveButton() { + /// What is done to the document goes after the pen: take back, put back, + /// write. The disc is last, as on the website. + private func setUpEditButtons() { guard let pen = toolBarItems.firstIndex(where: { $0 === editButtonSpacer }) else { return } - toolBarItems.insert(contentsOf: [saveButton, saveButtonSpacer], at: pen + 1) + toolBarItems.insert( + contentsOf: [undoButton, undoButtonSpacer, redoButton, redoButtonSpacer, saveButton, saveButtonSpacer], + at: pen + 1) } /// A gap either side of the name, which is what puts it in the middle. @@ -963,12 +1041,7 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel guard let back = toolBarItems.firstIndex(where: { $0 === barButtonItem }) else { return } - toolBarItems.insert( - contentsOf: [ - UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil), - documentTitleItem, - ], - at: back + 1) + toolBarItems.insert(contentsOf: [documentTitleSpacer, documentTitleItem], at: back + 1) updateDocumentTitle() } @@ -1009,16 +1082,28 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel /// Kept clear either side of the name, so it never sits against a button. private static let toolBarTitleGap: CGFloat = 16 + /// While an edit is on the bar is the edit's: undo, redo and save join it, + /// and the magnifier and the document's name stand down to leave them the + /// width - six buttons and a name do not fit a phone's bar. A mark on a pdf + /// is taken back one at a time and never put back, so redo stays out. private func updateToolBar() { toolBar.items = toolBarItems.filter { item in if item === editButton || item === editButtonSpacer { return canEdit } - if item === saveButton || item === saveButtonSpacer { + if item === documentTitleItem || item === documentTitleSpacer { + return !isEditingDocument + } + if item === redoButton || item === redoButtonSpacer { + return canEdit && isEditingDocument && !canMark + } + if item === undoButton || item === undoButtonSpacer || item === saveButton + || item === saveButtonSpacer + { return canEdit && isEditingDocument } if item === searchButton || item === searchButtonSpacer { - return canSearch + return canSearch && !isEditingDocument } return true @@ -1033,10 +1118,12 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel isEditingDocument = document?.edit ?? false } - /// A pencil to edit, a highlighter to mark a pdf, selected while editing. - /// VoiceOver reads the label, not the glyph. + /// The pencil, for a document and for a pdf alike: the two never stand in + /// the bar together, so the label is what separates them. It is drawn + /// selected while the mode is on, and VoiceOver reads the label, not the + /// glyph. private func updateEditButtonRole() { - editButton.image = UIImage(systemName: canMark ? "highlighter" : "pencil") + editButton.image = UIImage(systemName: "pencil") editButton.accessibilityLabel = NSLocalizedString(canMark ? "mark_pdf" : "menu_edit", comment: "") editButton.isSelected = isEditingDocument } diff --git a/OpenDocumentReader/EditToolBar.swift b/OpenDocumentReader/EditToolBar.swift index 288107d..b879e07 100644 --- a/OpenDocumentReader/EditToolBar.swift +++ b/OpenDocumentReader/EditToolBar.swift @@ -1,14 +1,24 @@ import UIKit -/// The row of editing tools under the bar, shown while a document is edited. +/// The strip of tools under the bar: what changes the text, and nothing else. +/// Undo, redo and save are the bar's, see ``DocumentViewController``. +/// +/// Every tool is one square button, and a tap does the one thing the tool is +/// for. A tool that applies a colour shows it in the bar under its icon, and a +/// **long press** opens the colours - there is no second button beside it. +/// Which tool is on comes back from the page. +/// +/// Without ``advancedEditing`` the strip is locked: the highlighter still +/// works, every other tool is dimmed and offers Pro, and Pro's badge stands in +/// front of the row. One free tool of each kind is what makes the mode worth +/// opening - see ``Tool/isFree``. final class EditToolBar: UIView { - /// One button of the row. + /// One button of the strip. enum Tool: CaseIterable { case bold, italic, underline, strikethrough case textColor, highlight, fontSize case markHighlight, markUnderline, markStrikeOut, markSquiggly, markDraw - case undo, redo var symbol: String { switch self { @@ -23,8 +33,6 @@ final class EditToolBar: UIView { case .markStrikeOut: return "strikethrough" case .markSquiggly: return "scribble.variable" case .markDraw: return "pencil.tip" - case .undo: return "arrow.uturn.backward" - case .redo: return "arrow.uturn.forward" } } @@ -42,13 +50,11 @@ final class EditToolBar: UIView { case .markStrikeOut: return NSLocalizedString("mark_strike_out", comment: "") case .markSquiggly: return NSLocalizedString("mark_squiggly", comment: "") case .markDraw: return NSLocalizedString("mark_draw", comment: "") - case .undo: return NSLocalizedString("edit_undo", comment: "") - case .redo: return NSLocalizedString("edit_redo", comment: "") } } /// The tool's own name in the page: what `odr.editing.toggle` and - /// `odr.annotation.setTool` take. + /// `odr.annotation.press` take. var pageName: String? { switch self { case .bold: return "bold" @@ -64,37 +70,25 @@ final class EditToolBar: UIView { } } - /// The tools that go past typing inside a paragraph - see - /// ``Features/advancedEditing``. - var isAdvanced: Bool { - switch self { - case .undo, .redo: return false - default: return true - } + /// The one tool a locked strip still does the work of: the highlighter, + /// under both the names it has. It is the formatting style in a text + /// document and the marking tool on a pdf, so a reader of either kind + /// has the same free tool. + var isFree: Bool { + self == .highlight || self == .markHighlight } - /// Whether the button opens a menu rather than acting at once. - var opensMenu: Bool { - switch self { - case .textColor, .fontSize: return true - default: return false - } + /// Whether the tool's own colours open on a tap rather than on a long + /// press. The text colour has no state to turn off, so a tap is the + /// colours themselves; the size has no colour and opens its sizes. + var opensOnTap: Bool { + self == .textColor || self == .fontSize } /// Whether a bar under the icon shows the colour the tool applies. + /// A toggle applies no colour, so its slot stays empty. var showsColor: Bool { - switch self { - case .textColor, .highlight, .markHighlight, .markUnderline, .markStrikeOut, .markSquiggly, - .markDraw: - return true - default: return false - } - } - - /// A split button: the tool acts, and the chevron beside it picks the - /// colour it acts with. - var hasColorChevron: Bool { - showsColor && self != .textColor + defaultColor != nil } /// The colours the tool's menu offers. @@ -119,26 +113,21 @@ final class EditToolBar: UIView { } } - /// What the row holds, by what the page is. + /// What the strip holds, by what the page is. A sheet or a plain text file + /// takes no formatting at all, so there is no strip over it: see + /// ``layout`` set to nil. enum Layout { /// a text document or a presentation case text - /// a spreadsheet or a plain text file: nothing to format - case plain /// a pdf, which takes marks case pdf var tools: [Tool] { switch self { case .text: - return [ - .bold, .italic, .underline, .strikethrough, .textColor, .highlight, .fontSize, - .undo, .redo, - ] - case .plain: - return [.undo, .redo] + return [.bold, .italic, .underline, .strikethrough, .textColor, .highlight, .fontSize] case .pdf: - return [.markHighlight, .markUnderline, .markStrikeOut, .markSquiggly, .markDraw, .undo] + return [.markHighlight, .markUnderline, .markStrikeOut, .markSquiggly, .markDraw] } } } @@ -183,17 +172,31 @@ final class EditToolBar: UIView { static let fontSizes = [8, 9, 10, 11, 12, 14, 16, 18, 20, 24, 28, 32, 36, 48] - static let height: CGFloat = 44 + static let height: CGFloat = 46 + + /// One tool. The slot under the icon is there on every tool, holding + /// something or not, so the icons sit on one line, and it is just deep + /// enough for the caption - a tool carrying nothing reads as empty for + /// every point over that. + private static let toolWidth: CGFloat = 44 + private static let toolHeight: CGFloat = 40 + private static let iconSize: CGFloat = 22 + private static let slotHeight: CGFloat = 12 - /// The tools shown; nil shows none and hides the row. + /// What a tool that only offers Pro is drawn at, against the free one + /// beside it. + private static let proAlpha: CGFloat = 0.45 + + /// The tools shown; nil shows none and hides the strip. var layout: Layout? { didSet { rebuild() } } - /// Whether the advanced tools act. Without it they are shown behind a - /// "Pro" badge, and a tap goes to `onTap`, which says what Pro is. + /// Whether the tools do their work. Without it every tool but the free one + /// is dimmed, a tap goes to `onTap`, which says what Pro is, and Pro's + /// badge stands in front of the row. var advancedEditing = true { didSet { rebuild() @@ -207,6 +210,14 @@ final class EditToolBar: UIView { private let stack = UIStackView() private var buttons: [Tool: UIButton] = [:] private var bars: [Tool: UIView] = [:] + private var icons: [Tool: UIImageView] = [:] + + /// The size the selection is in, under the size tool's icon. + private var sizeCaption: UILabel? + + /// The size the selection is in, in points, or nil where the runs + /// disagree. The menu marks it. + private var selectionSize: String? override init(frame: CGRect) { super.init(frame: frame) @@ -234,6 +245,13 @@ final class EditToolBar: UIView { stack.translatesAutoresizingMaskIntoConstraints = false scrollView.addSubview(stack) + // the row is as wide as the screen where the tools leave room, so that + // the spacers at its ends centre them; where they do not fit, it keeps + // its own width and scrolls + let fills = stack.widthAnchor.constraint( + greaterThanOrEqualTo: scrollView.frameLayoutGuide.widthAnchor, constant: -16) + fills.priority = .required + NSLayoutConstraint.activate([ scrollView.leadingAnchor.constraint(equalTo: leadingAnchor), scrollView.trailingAnchor.constraint(equalTo: trailingAnchor), @@ -246,9 +264,26 @@ final class EditToolBar: UIView { stack.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), stack.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), stack.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor), + fills, ]) } + /// One end of the row, which takes what the tools leave and so centres + /// them. Two of equal width, one at each end. + private func makeEdgeSpacer() -> UIView { + let spacer = UIView() + spacer.setContentHuggingPriority(.defaultLow - 1, for: .horizontal) + spacer.setContentCompressionResistancePriority(.defaultLow - 1, for: .horizontal) + + return spacer + } + + /// Whether `tool` only offers Pro in this build, rather than doing its + /// work. + private func isPro(_ tool: Tool) -> Bool { + !advancedEditing && !tool.isFree + } + private func rebuild() { for view in stack.arrangedSubviews { stack.removeArrangedSubview(view) @@ -256,6 +291,9 @@ final class EditToolBar: UIView { } buttons = [:] bars = [:] + icons = [:] + sizeCaption = nil + selectionSize = nil guard let layout else { isHidden = true @@ -265,29 +303,27 @@ final class EditToolBar: UIView { isHidden = false - if !advancedEditing, layout.tools.contains(where: \.isAdvanced) { + let leading = makeEdgeSpacer() + stack.addArrangedSubview(leading) + + if !advancedEditing { stack.addArrangedSubview(makeBadge()) } for tool in layout.tools { let button = makeButton(for: tool) buttons[tool] = button + stack.addArrangedSubview(button) + } - guard tool.hasColorChevron else { - stack.addArrangedSubview(button) - - continue - } + let trailing = makeEdgeSpacer() + stack.addArrangedSubview(trailing) + trailing.widthAnchor.constraint(equalTo: leading.widthAnchor).isActive = true - let pair = UIStackView(arrangedSubviews: [button, makeChevron(for: tool)]) - pair.axis = .horizontal - pair.spacing = 0 - pair.alignment = .center - stack.addArrangedSubview(pair) - } + scrollView.contentOffset = .zero } - /// Says the tools behind it are Pro's. + /// Says the dimmed tools beside it are Pro's. private func makeBadge() -> UIView { let label = UILabel() label.text = NSLocalizedString("tool_pro_badge", comment: "") @@ -295,84 +331,144 @@ final class EditToolBar: UIView { label.textColor = .white label.backgroundColor = tintColor label.textAlignment = .center - label.layer.cornerRadius = 8 + label.layer.cornerRadius = 14 label.clipsToBounds = true label.accessibilityIdentifier = "edit-tool-pro" NSLayoutConstraint.activate([ - label.heightAnchor.constraint(equalToConstant: 20), - label.widthAnchor.constraint(greaterThanOrEqualToConstant: 36), + label.heightAnchor.constraint(equalToConstant: 28), + label.widthAnchor.constraint(greaterThanOrEqualToConstant: 40), ]) return label } + /// The icon on one line with every other tool's, and under it the slot + /// that carries what the tool applies or what it reads on the selection. private func makeButton(for tool: Tool) -> UIButton { var configuration = UIButton.Configuration.plain() - configuration.image = UIImage(systemName: tool.symbol) configuration.cornerStyle = .capsule - configuration.contentInsets = NSDirectionalEdgeInsets(top: 6, leading: 10, bottom: 6, trailing: 10) + configuration.contentInsets = .zero let button = UIButton(configuration: configuration) button.accessibilityLabel = tool.label button.accessibilityIdentifier = "edit-tool-\(tool.symbol)" - // filled while pressed - button.configurationUpdateHandler = { button in + + let icon = UIImageView(image: UIImage(systemName: tool.symbol)) + icon.contentMode = .scaleAspectFit + icon.tintColor = button.tintColor + icon.translatesAutoresizingMaskIntoConstraints = false + button.addSubview(icon) + icons[tool] = icon + + // the slot is there on every tool, holding something or not, so that + // the icons sit on one line + let slot = UILayoutGuide() + button.addLayoutGuide(slot) + + NSLayoutConstraint.activate([ + button.widthAnchor.constraint(equalToConstant: Self.toolWidth), + button.heightAnchor.constraint(equalToConstant: Self.toolHeight), + + icon.widthAnchor.constraint(equalToConstant: Self.iconSize), + icon.heightAnchor.constraint(equalToConstant: Self.iconSize), + icon.centerXAnchor.constraint(equalTo: button.centerXAnchor), + icon.topAnchor.constraint( + equalTo: button.topAnchor, constant: (Self.toolHeight - Self.iconSize - Self.slotHeight) / 2), + + slot.topAnchor.constraint(equalTo: icon.bottomAnchor), + slot.heightAnchor.constraint(equalToConstant: Self.slotHeight), + slot.leadingAnchor.constraint(equalTo: button.leadingAnchor), + slot.trailingAnchor.constraint(equalTo: button.trailingAnchor), + ]) + + // filled while pressed, which is what says the tool is on + button.configurationUpdateHandler = { [weak icon] button in var configuration = button.configuration - if button.isSelected { - configuration?.background.backgroundColor = button.tintColor - configuration?.baseForegroundColor = .white - } else { - configuration?.background.backgroundColor = .clear - configuration?.baseForegroundColor = button.tintColor - } + configuration?.background.backgroundColor = button.isSelected ? button.tintColor : .clear button.configuration = configuration + + icon?.tintColor = button.isSelected ? .white : button.tintColor } if tool.showsColor { - addBar(to: button, for: tool) + addBar(to: button, in: slot, for: tool) + } else if tool == .fontSize { + addCaption(to: button, in: slot) } - if tool.opensMenu, advancedEditing || !tool.isAdvanced { - button.menu = makeMenu(for: tool) - button.showsMenuAsPrimaryAction = true - } else { + // a tool that only offers Pro is dimmed, so the free one is the one + // that stands out + button.alpha = isPro(tool) ? Self.proAlpha : 1 + + addActions(to: button, for: tool) + + return button + } + + /// A tap does the tool's one job, and a long press opens what it applies. + /// Where the tool has nothing to turn off, the tap opens it instead. + private func addActions(to button: UIButton, for tool: Tool) { + guard !isPro(tool) else { button.addAction( UIAction { [weak self] _ in self?.onTap?(tool) }, for: .touchUpInside) + + return } - return button + button.menu = makeMenu(for: tool) + button.showsMenuAsPrimaryAction = tool.opensOnTap + + guard !tool.opensOnTap else { return } + + button.addAction( + UIAction { [weak self] _ in + self?.onTap?(tool) + }, for: .touchUpInside) + + // VoiceOver has no long press, so the colours behind it are offered as + // the button's own actions instead, each named "Highlight yellow" + button.accessibilityCustomActions = colorActions(for: tool) } - /// The half of a split button that picks the tool's colour. - private func makeChevron(for tool: Tool) -> UIButton { - var configuration = UIButton.Configuration.plain() - configuration.image = UIImage( - systemName: "chevron.down", withConfiguration: UIImage.SymbolConfiguration(scale: .small)) - configuration.contentInsets = NSDirectionalEdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 6) - - let chevron = UIButton(configuration: configuration) - chevron.accessibilityLabel = String( - format: NSLocalizedString("edit_color_of", comment: ""), tool.label) - chevron.accessibilityIdentifier = "edit-tool-\(tool.symbol)-color" - - if advancedEditing || !tool.isAdvanced { - chevron.menu = colorMenu(for: tool, swatches: tool.swatches, offersNone: tool == .highlight) - chevron.showsMenuAsPrimaryAction = true - } else { - chevron.addAction( - UIAction { [weak self] _ in - self?.onTap?(tool) - }, for: .touchUpInside) + /// What the long press opens, as one action per colour, for a reader who + /// cannot make that press. + private func colorActions(for tool: Tool) -> [UIAccessibilityCustomAction] { + let of = String(format: NSLocalizedString("edit_color_of", comment: ""), tool.label) + + var actions = tool.swatches.map { swatch in + UIAccessibilityCustomAction(name: "\(of) \(NSLocalizedString(swatch.name, comment: ""))") { + [weak self] _ in + self?.onChoice?(tool, .color(swatch.hex)) + + return true + } + } + + if tool == .highlight { + actions.append( + UIAccessibilityCustomAction(name: NSLocalizedString("color_none", comment: "")) { [weak self] _ in + self?.onChoice?(tool, .color(nil)) + + return true + }) } - return chevron + actions.append( + UIAccessibilityCustomAction(name: NSLocalizedString("color_custom", comment: "")) { [weak self] _ in + self?.onChoice?(tool, .customColor) + + return true + }) + + return actions } - /// A bar under the icon in the colour the tool applies. - private func addBar(to button: UIButton, for tool: Tool) { + /// A bar in the slot, in the colour the tool applies, so that the bar is + /// what the next tap uses. + private func addBar(to button: UIButton, in slot: UILayoutGuide, for tool: Tool) { let bar = UIView() bar.isUserInteractionEnabled = false bar.layer.cornerRadius = 1 @@ -383,32 +479,55 @@ final class EditToolBar: UIView { button.addSubview(bar) NSLayoutConstraint.activate([ - bar.widthAnchor.constraint(equalToConstant: 16), - bar.heightAnchor.constraint(equalToConstant: 3), + bar.widthAnchor.constraint(equalToConstant: 20), + bar.heightAnchor.constraint(equalToConstant: 4), bar.centerXAnchor.constraint(equalTo: button.centerXAnchor), - bar.bottomAnchor.constraint(equalTo: button.bottomAnchor, constant: -3), + bar.bottomAnchor.constraint(equalTo: slot.bottomAnchor, constant: -1), ]) bars[tool] = bar } - private func makeMenu(for tool: Tool) -> UIMenu { - switch tool { - case .fontSize: - return UIMenu( - title: tool.label, - children: Self.fontSizes.map { size in - UIAction(title: "\(size)") { [weak self] _ in - self?.onChoice?(tool, .size(size)) - } - }) + /// What the tool reads on the selection, in the slot: the size the text is + /// in. The icon stays, so the tool is still the one it was. + private func addCaption(to button: UIButton, in slot: UILayoutGuide) { + let caption = UILabel() + caption.isUserInteractionEnabled = false + caption.font = .systemFont(ofSize: 10) + caption.textColor = .secondaryLabel + caption.textAlignment = .center + caption.translatesAutoresizingMaskIntoConstraints = false + button.addSubview(caption) + + NSLayoutConstraint.activate([ + caption.leadingAnchor.constraint(equalTo: slot.leadingAnchor), + caption.trailingAnchor.constraint(equalTo: slot.trailingAnchor), + caption.centerYAnchor.constraint(equalTo: slot.centerYAnchor), + ]) - case .textColor: - return colorMenu(for: tool, swatches: Self.textColors, offersNone: false) + sizeCaption = caption + } - default: - return UIMenu() + private func makeMenu(for tool: Tool) -> UIMenu { + guard tool != .fontSize else { + return sizeMenu() } + + return colorMenu(for: tool, swatches: tool.swatches, offersNone: tool == .highlight) + } + + /// The fourteen sizes, with the one the text is in marked. + private func sizeMenu() -> UIMenu { + UIMenu( + title: Tool.fontSize.label, + children: Self.fontSizes.map { size in + let action = UIAction(title: "\(size)") { [weak self] _ in + self?.onChoice?(.fontSize, .size(size)) + } + action.state = "\(size)" == selectionSize ? .on : .off + + return action + }) } private func colorMenu(for tool: Tool, swatches: [Swatch], offersNone: Bool) -> UIMenu { @@ -450,13 +569,10 @@ final class EditToolBar: UIView { } /// Whether `tool` is drawn as the mode: a style the selection shows, or - /// the armed marker on a pdf. + /// the armed marker on a pdf. A locked strip shows none of it, since + /// nothing it dims is on. func setPressed(_ tool: Tool, _ pressed: Bool) { - buttons[tool]?.isSelected = pressed - } - - func setEnabled(_ tool: Tool, _ enabled: Bool) { - buttons[tool]?.isEnabled = enabled + buttons[tool]?.isSelected = pressed && !isPro(tool) } /// Paints the bar under `tool` in the colour it now applies. @@ -464,20 +580,19 @@ final class EditToolBar: UIView { bars[tool]?.backgroundColor = color } - /// Shows the selection's size as "12 pt", or the symbol if it has none. + /// Captions the size tool with "12 pt", or with nothing where the runs + /// disagree. func setFontSize(_ points: String?) { - guard let button = buttons[.fontSize] else { return } - - var configuration = button.configuration - if let points { - configuration?.image = nil - configuration?.title = String( - format: NSLocalizedString("edit_font_size_points", comment: ""), points) - } else { - configuration?.image = UIImage(systemName: Tool.fontSize.symbol) - configuration?.title = nil + selectionSize = points + + sizeCaption?.text = points.map { + String(format: NSLocalizedString("edit_font_size_points", comment: ""), $0) + } + + // the mark moves with the selection, and a menu is built once + if let button = buttons[.fontSize], !isPro(.fontSize) { + button.menu = sizeMenu() } - button.configuration = configuration } /// For the tests: the colour the bar under `tool` shows. @@ -487,12 +602,13 @@ final class EditToolBar: UIView { /// For the tests: what the size tool says. var fontSizeTitle: String? { - buttons[.fontSize]?.configuration?.title + sizeCaption?.text } - /// For the tests: whether the row starts with the Pro badge. + /// For the tests: whether the row carries the Pro badge, in front of the + /// tools. var showsProBadge: Bool { - stack.arrangedSubviews.first?.accessibilityIdentifier == "edit-tool-pro" + stack.arrangedSubviews.contains { $0.accessibilityIdentifier == "edit-tool-pro" } } /// For the tests: whether the row shows `tool`. @@ -500,6 +616,12 @@ final class EditToolBar: UIView { buttons[tool] != nil } + /// For the tests: whether `tool` is dimmed, which is how a locked strip + /// says the tool is Pro's. + func isDimmed(_ tool: Tool) -> Bool { + (buttons[tool]?.alpha ?? 1) < 1 + } + func isPressed(_ tool: Tool) -> Bool { buttons[tool]?.isSelected ?? false } diff --git a/OpenDocumentReader/Main.storyboard b/OpenDocumentReader/Main.storyboard index 5b57558..7e77604 100644 --- a/OpenDocumentReader/Main.storyboard +++ b/OpenDocumentReader/Main.storyboard @@ -98,7 +98,6 @@ - diff --git a/OpenDocumentReader/ca.lproj/Localizable.strings b/OpenDocumentReader/ca.lproj/Localizable.strings index d03a235..182e2d1 100644 --- a/OpenDocumentReader/ca.lproj/Localizable.strings +++ b/OpenDocumentReader/ca.lproj/Localizable.strings @@ -147,8 +147,8 @@ /* What Pro adds, shown in the Lite app */ "pro_feature_title" = "Part de Pro"; -"pro_feature_formatting" = "Formatar el text i afegir o unir paràgrafs forma part d’OpenDocument Reader Pro."; -"pro_feature_pdf" = "Marcar un PDF forma part d’OpenDocument Reader Pro."; +"pro_feature_formatting" = "La negreta, la cursiva, els colors i la mida del text, i afegir o unir paràgrafs, formen part d’OpenDocument Reader Pro. El marcador hi és a totes les edicions."; +"pro_feature_pdf" = "Subratllar, ratllar i dibuixar en un PDF formen part d’OpenDocument Reader Pro. El marcador hi és a totes les edicions."; "not_now" = "Ara no"; /* Shown when the marking tools come up on a PDF */ diff --git a/OpenDocumentReader/cs.lproj/Localizable.strings b/OpenDocumentReader/cs.lproj/Localizable.strings index b16fcbf..f30fe32 100644 --- a/OpenDocumentReader/cs.lproj/Localizable.strings +++ b/OpenDocumentReader/cs.lproj/Localizable.strings @@ -147,8 +147,8 @@ /* What Pro adds, shown in the Lite app */ "pro_feature_title" = "Součást Pro"; -"pro_feature_formatting" = "Formátování textu a přidávání nebo spojování odstavců je součástí OpenDocument Reader Pro."; -"pro_feature_pdf" = "Označování PDF je součástí OpenDocument Reader Pro."; +"pro_feature_formatting" = "Tučné písmo, kurzíva, barvy a velikost textu a přidávání nebo spojování odstavců jsou součástí OpenDocument Reader Pro. Zvýrazňovač je v každé edici."; +"pro_feature_pdf" = "Podtržení, přeškrtnutí a kreslení v PDF jsou součástí OpenDocument Reader Pro. Zvýrazňovač je v každé edici."; "not_now" = "Teď ne"; /* Shown when the marking tools come up on a PDF */ diff --git a/OpenDocumentReader/da.lproj/Localizable.strings b/OpenDocumentReader/da.lproj/Localizable.strings index 0d614ae..6b8017b 100644 --- a/OpenDocumentReader/da.lproj/Localizable.strings +++ b/OpenDocumentReader/da.lproj/Localizable.strings @@ -147,8 +147,8 @@ /* What Pro adds, shown in the Lite app */ "pro_feature_title" = "En del af Pro"; -"pro_feature_formatting" = "Formatering af tekst samt tilføjelse eller sammenføjning af afsnit er en del af OpenDocument Reader Pro."; -"pro_feature_pdf" = "Markering af PDF er en del af OpenDocument Reader Pro."; +"pro_feature_formatting" = "Fed, kursiv, farver og tekststørrelse samt tilføjelse eller sammenføjning af afsnit er en del af OpenDocument Reader Pro. Overstregningstuschen er med i alle udgaver."; +"pro_feature_pdf" = "Understregning, gennemstregning og tegning i en PDF er en del af OpenDocument Reader Pro. Overstregningstuschen er med i alle udgaver."; "not_now" = "Ikke nu"; /* Shown when the marking tools come up on a PDF */ diff --git a/OpenDocumentReader/de.lproj/Localizable.strings b/OpenDocumentReader/de.lproj/Localizable.strings index 049d332..00eff55 100644 --- a/OpenDocumentReader/de.lproj/Localizable.strings +++ b/OpenDocumentReader/de.lproj/Localizable.strings @@ -147,8 +147,8 @@ /* What Pro adds, shown in the Lite app */ "pro_feature_title" = "Teil von Pro"; -"pro_feature_formatting" = "Text formatieren sowie Absätze einfügen oder zusammenführen ist Teil von OpenDocument Reader Pro."; -"pro_feature_pdf" = "PDFs markieren ist Teil von OpenDocument Reader Pro."; +"pro_feature_formatting" = "Fett, kursiv, Farben und Schriftgröße sowie Absätze einfügen oder zusammenführen sind Teil von OpenDocument Reader Pro. Der Textmarker ist in jeder Ausgabe dabei."; +"pro_feature_pdf" = "Unterstreichen, Durchstreichen und Zeichnen in einem PDF sind Teil von OpenDocument Reader Pro. Der Textmarker ist in jeder Ausgabe dabei."; "not_now" = "Nicht jetzt"; /* Shown when the marking tools come up on a PDF */ diff --git a/OpenDocumentReader/en.lproj/Localizable.strings b/OpenDocumentReader/en.lproj/Localizable.strings index daad039..b26b7be 100644 --- a/OpenDocumentReader/en.lproj/Localizable.strings +++ b/OpenDocumentReader/en.lproj/Localizable.strings @@ -107,7 +107,7 @@ /* Shown for a format odrcore does not read */ "toast_error_illegal_file_reopen" = "Unsupported file format. Try opening it in another app."; -/* The editing tools under the bar, read by VoiceOver */ +/* The editing tools, read by VoiceOver. Undo and redo are the bar's, the rest the strip under it */ "edit_bold" = "Bold"; "edit_italic" = "Italic"; "edit_underline" = "Underline"; @@ -145,19 +145,20 @@ "edit_refused_read_only" = "This document cannot be edited."; "edit_refused_range" = "An edit cannot reach over a picture or a table."; -/* What Pro adds, shown in the Lite app */ +/* What Pro adds, shown in the Lite app as the reader runs into it. The highlighter + is free of it in both kinds of document, so that the mode is worth opening */ "pro_feature_title" = "Part of Pro"; -"pro_feature_formatting" = "Formatting text, and adding or joining paragraphs, is part of OpenDocument Reader Pro."; -"pro_feature_pdf" = "Marking up a PDF is part of OpenDocument Reader Pro."; +"pro_feature_formatting" = "Bold, italic, colors and text size, and adding or joining paragraphs, are part of OpenDocument Reader Pro. The highlighter is in every edition."; +"pro_feature_pdf" = "Underlining, striking out and drawing on a PDF are part of OpenDocument Reader Pro. The highlighter is in every edition."; "not_now" = "Not now"; /* Shown when the marking tools come up on a PDF */ "mark_hint" = "Select text, then a tool, to mark it."; -/* The badge in front of the tools that are Pro's, in the Lite app */ +/* The badge in front of the dimmed tools that are Pro's, in the Lite app */ "tool_pro_badge" = "Pro"; -/* The chevron beside a tool, which picks the color it uses: "Highlight color" */ +/* What a long press on a tool opens, which picks the color it uses: "Highlight color" */ "edit_color_of" = "%@ color"; /* The size of the selected text, on the size tool: "12 pt" */ diff --git a/OpenDocumentReader/es.lproj/Localizable.strings b/OpenDocumentReader/es.lproj/Localizable.strings index 1743694..04f2e07 100644 --- a/OpenDocumentReader/es.lproj/Localizable.strings +++ b/OpenDocumentReader/es.lproj/Localizable.strings @@ -147,8 +147,8 @@ /* What Pro adds, shown in the Lite app */ "pro_feature_title" = "Parte de Pro"; -"pro_feature_formatting" = "Dar formato al texto y añadir o unir párrafos forma parte de OpenDocument Reader Pro."; -"pro_feature_pdf" = "Marcar un PDF forma parte de OpenDocument Reader Pro."; +"pro_feature_formatting" = "La negrita, la cursiva, los colores y el tamaño del texto, y añadir o unir párrafos, forman parte de OpenDocument Reader Pro. El marcador está en todas las ediciones."; +"pro_feature_pdf" = "Subrayar, tachar y dibujar en un PDF forman parte de OpenDocument Reader Pro. El marcador está en todas las ediciones."; "not_now" = "Ahora no"; /* Shown when the marking tools come up on a PDF */ diff --git a/OpenDocumentReader/fr.lproj/Localizable.strings b/OpenDocumentReader/fr.lproj/Localizable.strings index d927f51..a4f6eb2 100644 --- a/OpenDocumentReader/fr.lproj/Localizable.strings +++ b/OpenDocumentReader/fr.lproj/Localizable.strings @@ -147,8 +147,8 @@ /* What Pro adds, shown in the Lite app */ "pro_feature_title" = "Réservé à Pro"; -"pro_feature_formatting" = "La mise en forme du texte, ainsi que l’ajout ou la fusion de paragraphes, fait partie d’OpenDocument Reader Pro."; -"pro_feature_pdf" = "L’annotation de PDF fait partie d’OpenDocument Reader Pro."; +"pro_feature_formatting" = "Le gras, l’italique, les couleurs et la taille du texte, ainsi que l’ajout ou la fusion de paragraphes, font partie d’OpenDocument Reader Pro. Le surligneur est dans toutes les éditions."; +"pro_feature_pdf" = "Souligner, barrer et dessiner sur un PDF font partie d’OpenDocument Reader Pro. Le surligneur est dans toutes les éditions."; "not_now" = "Pas maintenant"; /* Shown when the marking tools come up on a PDF */ diff --git a/OpenDocumentReader/ga.lproj/Localizable.strings b/OpenDocumentReader/ga.lproj/Localizable.strings index 0ef9663..7b26b9c 100644 --- a/OpenDocumentReader/ga.lproj/Localizable.strings +++ b/OpenDocumentReader/ga.lproj/Localizable.strings @@ -147,8 +147,8 @@ /* What Pro adds, shown in the Lite app */ "pro_feature_title" = "Cuid de Pro"; -"pro_feature_formatting" = "Is cuid de OpenDocument Reader Pro é téacs a fhormáidiú agus ailt a chur leis nó a nascadh."; -"pro_feature_pdf" = "Is cuid de OpenDocument Reader Pro é PDF a mharcáil."; +"pro_feature_formatting" = "Is cuid de OpenDocument Reader Pro iad cló trom, cló iodálach, dathanna agus méid an téacs, agus ailt a chur leis nó a nascadh. Tá an marcóir i ngach eagrán."; +"pro_feature_pdf" = "Is cuid de OpenDocument Reader Pro iad líne a chur faoi théacs, líne a chur tríd agus líníocht a dhéanamh ar PDF. Tá an marcóir i ngach eagrán."; "not_now" = "Ní anois"; /* Shown when the marking tools come up on a PDF */ diff --git a/OpenDocumentReader/it.lproj/Localizable.strings b/OpenDocumentReader/it.lproj/Localizable.strings index aa7fca6..7589487 100644 --- a/OpenDocumentReader/it.lproj/Localizable.strings +++ b/OpenDocumentReader/it.lproj/Localizable.strings @@ -147,8 +147,8 @@ /* What Pro adds, shown in the Lite app */ "pro_feature_title" = "Parte di Pro"; -"pro_feature_formatting" = "Formattare il testo e aggiungere o unire paragrafi fa parte di OpenDocument Reader Pro."; -"pro_feature_pdf" = "Annotare un PDF fa parte di OpenDocument Reader Pro."; +"pro_feature_formatting" = "Il grassetto, il corsivo, i colori e la dimensione del testo, e aggiungere o unire paragrafi, fanno parte di OpenDocument Reader Pro. L’evidenziatore c’è in ogni edizione."; +"pro_feature_pdf" = "Sottolineare, barrare e disegnare su un PDF fanno parte di OpenDocument Reader Pro. L’evidenziatore c’è in ogni edizione."; "not_now" = "Non ora"; /* Shown when the marking tools come up on a PDF */ diff --git a/OpenDocumentReader/ja.lproj/Localizable.strings b/OpenDocumentReader/ja.lproj/Localizable.strings index f42cfbd..76ecc9a 100644 --- a/OpenDocumentReader/ja.lproj/Localizable.strings +++ b/OpenDocumentReader/ja.lproj/Localizable.strings @@ -147,8 +147,8 @@ /* What Pro adds, shown in the Lite app */ "pro_feature_title" = "Proの機能"; -"pro_feature_formatting" = "文字の書式設定と、段落の追加や結合はOpenDocument Reader Proの機能です。"; -"pro_feature_pdf" = "PDFへのマーク付けはOpenDocument Reader Proの機能です。"; +"pro_feature_formatting" = "太字、斜体、色、文字サイズと、段落の追加や結合はOpenDocument Reader Proの機能です。蛍光ペンはどの版でも使えます。"; +"pro_feature_pdf" = "PDFへの下線、取り消し線、手書きはOpenDocument Reader Proの機能です。蛍光ペンはどの版でも使えます。"; "not_now" = "あとで"; /* Shown when the marking tools come up on a PDF */ diff --git a/OpenDocumentReader/pl.lproj/Localizable.strings b/OpenDocumentReader/pl.lproj/Localizable.strings index a03b58e..515285c 100644 --- a/OpenDocumentReader/pl.lproj/Localizable.strings +++ b/OpenDocumentReader/pl.lproj/Localizable.strings @@ -147,8 +147,8 @@ /* What Pro adds, shown in the Lite app */ "pro_feature_title" = "Część wersji Pro"; -"pro_feature_formatting" = "Formatowanie tekstu oraz dodawanie i łączenie akapitów to część OpenDocument Reader Pro."; -"pro_feature_pdf" = "Oznaczanie PDF to część OpenDocument Reader Pro."; +"pro_feature_formatting" = "Pogrubienie, kursywa, kolory i rozmiar tekstu oraz dodawanie i łączenie akapitów to część OpenDocument Reader Pro. Zakreślacz jest w każdej edycji."; +"pro_feature_pdf" = "Podkreślanie, przekreślanie i rysowanie w PDF to część OpenDocument Reader Pro. Zakreślacz jest w każdej edycji."; "not_now" = "Nie teraz"; /* Shown when the marking tools come up on a PDF */ diff --git a/OpenDocumentReader/pt-BR.lproj/Localizable.strings b/OpenDocumentReader/pt-BR.lproj/Localizable.strings index df214e6..702b213 100644 --- a/OpenDocumentReader/pt-BR.lproj/Localizable.strings +++ b/OpenDocumentReader/pt-BR.lproj/Localizable.strings @@ -147,8 +147,8 @@ /* What Pro adds, shown in the Lite app */ "pro_feature_title" = "Parte do Pro"; -"pro_feature_formatting" = "Formatar texto e adicionar ou juntar parágrafos faz parte do OpenDocument Reader Pro."; -"pro_feature_pdf" = "Marcar um PDF faz parte do OpenDocument Reader Pro."; +"pro_feature_formatting" = "Negrito, itálico, cores e tamanho do texto, e adicionar ou juntar parágrafos, fazem parte do OpenDocument Reader Pro. O marca-texto está em todas as edições."; +"pro_feature_pdf" = "Sublinhar, riscar e desenhar em um PDF fazem parte do OpenDocument Reader Pro. O marca-texto está em todas as edições."; "not_now" = "Agora não"; /* Shown when the marking tools come up on a PDF */ diff --git a/OpenDocumentReader/ru.lproj/Localizable.strings b/OpenDocumentReader/ru.lproj/Localizable.strings index 5fc5e43..faaa8c0 100644 --- a/OpenDocumentReader/ru.lproj/Localizable.strings +++ b/OpenDocumentReader/ru.lproj/Localizable.strings @@ -147,8 +147,8 @@ /* What Pro adds, shown in the Lite app */ "pro_feature_title" = "Часть Pro"; -"pro_feature_formatting" = "Форматирование текста, а также добавление и объединение абзацев — часть OpenDocument Reader Pro."; -"pro_feature_pdf" = "Разметка PDF — часть OpenDocument Reader Pro."; +"pro_feature_formatting" = "Полужирный, курсив, цвета и размер текста, а также добавление и объединение абзацев — часть OpenDocument Reader Pro. Маркер есть в любом издании."; +"pro_feature_pdf" = "Подчёркивание, зачёркивание и рисование в PDF — часть OpenDocument Reader Pro. Маркер есть в любом издании."; "not_now" = "Не сейчас"; /* Shown when the marking tools come up on a PDF */ diff --git a/OpenDocumentReader/sl.lproj/Localizable.strings b/OpenDocumentReader/sl.lproj/Localizable.strings index 3f201c8..ec5e15a 100644 --- a/OpenDocumentReader/sl.lproj/Localizable.strings +++ b/OpenDocumentReader/sl.lproj/Localizable.strings @@ -147,8 +147,8 @@ /* What Pro adds, shown in the Lite app */ "pro_feature_title" = "Del različice Pro"; -"pro_feature_formatting" = "Oblikovanje besedila ter dodajanje ali združevanje odstavkov je del OpenDocument Reader Pro."; -"pro_feature_pdf" = "Označevanje PDF je del OpenDocument Reader Pro."; +"pro_feature_formatting" = "Krepko, ležeče, barve in velikost besedila ter dodajanje ali združevanje odstavkov so del OpenDocument Reader Pro. Označevalnik je v vsaki izdaji."; +"pro_feature_pdf" = "Podčrtovanje, prečrtovanje in risanje v PDF so del OpenDocument Reader Pro. Označevalnik je v vsaki izdaji."; "not_now" = "Ne zdaj"; /* Shown when the marking tools come up on a PDF */ diff --git a/OpenDocumentReader/tr.lproj/Localizable.strings b/OpenDocumentReader/tr.lproj/Localizable.strings index 0efc3ea..f6f77fa 100644 --- a/OpenDocumentReader/tr.lproj/Localizable.strings +++ b/OpenDocumentReader/tr.lproj/Localizable.strings @@ -147,8 +147,8 @@ /* What Pro adds, shown in the Lite app */ "pro_feature_title" = "Pro’nun parçası"; -"pro_feature_formatting" = "Metni biçimlendirmek ve paragraf eklemek ya da birleştirmek OpenDocument Reader Pro’nun bir parçasıdır."; -"pro_feature_pdf" = "PDF işaretlemek OpenDocument Reader Pro’nun bir parçasıdır."; +"pro_feature_formatting" = "Kalın, italik, renkler ve metin boyutu ile paragraf eklemek ya da birleştirmek OpenDocument Reader Pro’nun bir parçasıdır. Fosforlu kalem her sürümde vardır."; +"pro_feature_pdf" = "Bir PDF’te altını çizmek, üstünü çizmek ve çizim yapmak OpenDocument Reader Pro’nun bir parçasıdır. Fosforlu kalem her sürümde vardır."; "not_now" = "Şimdi değil"; /* Shown when the marking tools come up on a PDF */ diff --git a/OpenDocumentReader/zh-Hans.lproj/Localizable.strings b/OpenDocumentReader/zh-Hans.lproj/Localizable.strings index 6ecbb45..aff6636 100644 --- a/OpenDocumentReader/zh-Hans.lproj/Localizable.strings +++ b/OpenDocumentReader/zh-Hans.lproj/Localizable.strings @@ -147,8 +147,8 @@ /* What Pro adds, shown in the Lite app */ "pro_feature_title" = "Pro 功能"; -"pro_feature_formatting" = "设置文字格式以及添加或合并段落是 OpenDocument Reader Pro 的功能。"; -"pro_feature_pdf" = "标注 PDF 是 OpenDocument Reader Pro 的功能。"; +"pro_feature_formatting" = "粗体、斜体、颜色和文字大小,以及添加或合并段落,都是 OpenDocument Reader Pro 的功能。荧光笔在每个版本中都能用。"; +"pro_feature_pdf" = "在 PDF 上添加下划线、删除线和手绘是 OpenDocument Reader Pro 的功能。荧光笔在每个版本中都能用。"; "not_now" = "暂不"; /* Shown when the marking tools come up on a PDF */ diff --git a/OpenDocumentReaderTests/EditWorkflowTests.swift b/OpenDocumentReaderTests/EditWorkflowTests.swift index 67a1dfc..76d3e8b 100644 --- a/OpenDocumentReaderTests/EditWorkflowTests.swift +++ b/OpenDocumentReaderTests/EditWorkflowTests.swift @@ -69,12 +69,14 @@ class EditWorkflowTests: XCTestCase { XCTAssertEqual(controller.editButton.image, UIImage(systemName: "pencil")) } - /// The pencil is selected while editing. The save button shows only while - /// editing, and is enabled only after a change. - func testThePenStaysAndTheSaveButtonJoinsItWhileEditing() throws { + /// The pencil is selected while editing. Undo, redo and save join the bar + /// only while editing, and each is enabled only once the page says so. + func testTheBarTakesUndoRedoAndSaveWhileEditing() throws { openDocument() XCTAssertFalse(barContains(controller.saveButton)) + XCTAssertFalse(barContains(controller.undoButton)) + XCTAssertFalse(barContains(controller.redoButton)) controller.toggleEdit(controller.editButton) waitForEditablePage() @@ -83,11 +85,32 @@ class EditWorkflowTests: XCTestCase { XCTAssertTrue(barContains(controller.editButton)) XCTAssertEqual(controller.editButton.image, UIImage(systemName: "pencil")) XCTAssertTrue(controller.editButton.isSelected) + XCTAssertTrue(barContains(controller.undoButton)) + XCTAssertTrue(barContains(controller.redoButton)) XCTAssertTrue(barContains(controller.saveButton)) XCTAssertFalse(controller.saveButton.isEnabled) + XCTAssertFalse(controller.undoButton.isEnabled) typeIntoTheFirstRun() waitUntil { self.controller.saveButton.isEnabled } + waitUntil { self.controller.undoButton.isEnabled } + } + + /// The magnifier stands down while an edit is on, to leave the three their + /// width. + func testTheSearchButtonLeavesTheBarWhileEditing() throws { + openDocument() + waitUntil { self.barContains(self.controller.searchButton) } + + controller.toggleEdit(controller.editButton) + waitForEditablePage() + + XCTAssertFalse(barContains(controller.searchButton)) + + controller.discardChanges() + waitForPage(where: "document.querySelectorAll('x-s').length > 0") + + waitUntil { self.barContains(self.controller.searchButton) } } /// With nothing to lose the pen only turns the mode off, and the page stays. @@ -103,6 +126,7 @@ class EditWorkflowTests: XCTestCase { XCTAssertFalse(document.edit) XCTAssertFalse(controller.editButton.isSelected) XCTAssertFalse(barContains(controller.saveButton)) + XCTAssertFalse(barContains(controller.undoButton)) XCTAssertNil(controller.editToolBar.layout) } @@ -160,8 +184,9 @@ class EditWorkflowTests: XCTestCase { // MARK: - the tools - /// The row under the bar: formatting for a text document, once the page - /// says it is editable, and gone again with the edit. + /// The strip under the bar: formatting for a text document, once the page + /// says it is editable, and gone again with the edit. Undo and redo are + /// the bar's, so the strip does not hold them. func testATextDocumentShowsTheFormattingToolsWhileEditing() throws { openDocument() @@ -173,7 +198,8 @@ class EditWorkflowTests: XCTestCase { XCTAssertEqual(controller.editToolBar.layout, .text) XCTAssertTrue(controller.editToolBar.shows(.bold)) - XCTAssertTrue(controller.editToolBar.shows(.undo)) + XCTAssertTrue(controller.editToolBar.shows(.highlight)) + XCTAssertTrue(controller.editToolBar.shows(.fontSize)) controller.discardChanges() waitForPage(where: "document.querySelectorAll('x-s').length > 0") @@ -181,8 +207,9 @@ class EditWorkflowTests: XCTestCase { XCTAssertNil(controller.editToolBar.layout) } - /// The cells are the editor, so a spreadsheet gets only the way back. - func testASpreadsheetShowsOnlyUndoAndRedo() throws { + /// The cells are the editor and there is nothing to format, so a + /// spreadsheet gets no strip at all - only the bar. + func testASpreadsheetShowsNoToolStrip() throws { documentURL = try copyFixture(ofType: "ods") try present(documentURL) openDocument(where: "document.querySelectorAll('td').length > 0") @@ -190,9 +217,11 @@ class EditWorkflowTests: XCTestCase { controller.toggleEdit(controller.editButton) waitForTools() - XCTAssertEqual(controller.editToolBar.layout, .plain) - XCTAssertFalse(controller.editToolBar.shows(.bold)) - XCTAssertTrue(controller.editToolBar.shows(.undo)) + XCTAssertNil(controller.editToolBar.layout) + XCTAssertTrue(controller.editToolBar.isHidden) + XCTAssertTrue(barContains(controller.undoButton)) + XCTAssertTrue(barContains(controller.redoButton)) + XCTAssertTrue(barContains(controller.saveButton)) } /// A style the caret sits in is shown pressed, the way the page reports it. @@ -247,9 +276,43 @@ class EditWorkflowTests: XCTestCase { waitForPage(where: "document.querySelector('x-s[data-odr-id]').style.backgroundColor === ''") } + // MARK: - the gate + + /// Lite dims what only offers Pro and leaves the highlighter working, in a + /// text document and on a pdf alike. The badge says whose the dimmed tools + /// are. Driven on the strip itself, since the test bundle is Pro's. + func testALockedStripKeepsTheHighlighterAndDimsTheRest() throws { + let tools = EditToolBar() + tools.advancedEditing = false + + tools.layout = .text + + XCTAssertTrue(tools.showsProBadge) + XCTAssertTrue(tools.isDimmed(.bold)) + XCTAssertTrue(tools.isDimmed(.fontSize)) + XCTAssertFalse(tools.isDimmed(.highlight)) + + tools.layout = .pdf + + XCTAssertTrue(tools.showsProBadge) + XCTAssertTrue(tools.isDimmed(.markUnderline)) + XCTAssertTrue(tools.isDimmed(.markDraw)) + XCTAssertFalse(tools.isDimmed(.markHighlight)) + } + + /// Pro dims nothing and wears no badge. + func testAnUnlockedStripShowsNoBadge() throws { + let tools = EditToolBar() + tools.layout = .text + + XCTAssertFalse(tools.showsProBadge) + XCTAssertFalse(tools.isDimmed(.bold)) + } + // MARK: - a pdf - /// The pencil is a highlighter on a pdf, and the edit is a set of marks. + /// The pencil is the same on a pdf - the label is what separates them - + /// and the edit is a set of marks. func testAPdfOffersMarksAndSavesThem() throws { documentURL = try copyFixture(ofType: "pdf") try present(documentURL) @@ -257,7 +320,9 @@ class EditWorkflowTests: XCTestCase { XCTAssertTrue(document.isAnnotatable) XCTAssertTrue(barContains(controller.editButton)) - XCTAssertEqual(controller.editButton.image, UIImage(systemName: "highlighter")) + XCTAssertEqual(controller.editButton.image, UIImage(systemName: "pencil")) + XCTAssertEqual( + controller.editButton.accessibilityLabel, NSLocalizedString("mark_pdf", comment: "")) let sizeBefore = try fileSize() @@ -267,7 +332,9 @@ class EditWorkflowTests: XCTestCase { XCTAssertEqual(controller.editToolBar.layout, .pdf) XCTAssertTrue(controller.editToolBar.shows(.markHighlight)) - XCTAssertFalse(controller.editToolBar.shows(.redo)) + // a mark is taken back one at a time and never put back + XCTAssertTrue(barContains(controller.undoButton)) + XCTAssertFalse(barContains(controller.redoButton)) // each marker has a colour of its own XCTAssertEqual(controller.editToolBar.color(of: .markHighlight)?.hexString, "#ffe633") @@ -410,9 +477,10 @@ class EditWorkflowTests: XCTestCase { waitForPage(where: condition) } - /// The tools appear once the editable page has answered what it is. + /// The strip stands as it will once the editable page has answered what it + /// is. Not the strip itself: a sheet answers with no strip at all. private func waitForTools(file: StaticString = #filePath, line: UInt = #line) { - waitUntil(file: file, line: line) { self.controller.editToolBar.layout != nil } + waitUntil(file: file, line: line) { self.controller.isEditSessionReady } } /// A message from the page lands on a later turn of the run loop. diff --git a/README.md b/README.md index b26b15a..1f3ab8a 100644 --- a/README.md +++ b/README.md @@ -59,8 +59,17 @@ else, so there is nothing to withhold. Pro also has `Features.advancedEditing`, from `ADVANCED_EDITING` in the same two files. Lite renders with the editing scope `paragraph`, so odrcore refuses -formatting and paragraph changes with `outOfScope`, and the app offers Pro. -PDF marks and the tools behind the "Pro" badge offer Pro as well. +a change that reaches past one paragraph with `outOfScope`, and the app offers +Pro. + +**The gate is on the tool, not on the mode.** Both editions open every kind the +core calls editable, a PDF included. A locked `EditToolBar` dims what only +offers Pro, puts the "Pro" badge in front of the row, and leaves the +**highlighter** working under both the names it has - `highlight` is the +formatting style and the PDF's marking tool alike. A mode nobody can do +anything in is not worth opening, and a reader who has highlighted a paragraph +knows what the rest would do. Do not put the whole-mode gate back. +`OpenDocument.droid` draws the same line with the same flag. `configs/full` and `configs/lite` hold each bundle's `Info.plist` and privacy manifest, out of the synchronized folder, since anything left in there would be @@ -101,11 +110,34 @@ in App Store review. ## Editing Every document is rendered editable, so the pencil only calls -`odr.editing.enable()` and the page stays where it is. `EditToolBar` shows the -tools for the page: formatting for a text document, undo and redo for a sheet or -plain text, markers for a PDF. The tools match the website and -OpenDocument.droid. The page reports to the app through one -`WKScriptMessageHandler`. +`odr.editing.enable()` and the page stays where it is. The same pencil edits a +document and marks up a PDF: the two never stand in the bar together, so the +label is what separates them. + +**The bar holds what is done to the document, the strip what is done to the +text.** Undo, redo and save are bar buttons, so they do not scroll away, and the +page's own report enables them. Redo stays out over a PDF, where a mark is taken +back one at a time and never put back. The magnifier stands down while an edit is +on, to leave the three their width. + +`EditToolBar` under the bar is the formatting alone, which is why a sheet or a +plain text file shows no strip at all. A tap does the tool's one job, and a +**long press** opens the colours it applies - there is no second button beside a +tool, and the bar under its icon is what the next tap uses, not what the +selection is. The text colour and the text size are the exception and open on a +tap, having no state to turn off. Do not put the chevrons back. VoiceOver has no +long press, so the colours are the button's `accessibilityCustomActions` as +well. Every tool is an icon over a slot, and the slot is there whether it holds +anything or not, so the icons sit on one line and no colour bar runs into the +glyph above it. The row is centred where the tools leave room, and scrolls where +they do not. The tools match the website and OpenDocument.droid, and the page reports +to the app through one `WKScriptMessageHandler`. + +**A PDF's tools are the page's to arm.** `odr.annotation.press` marks a standing +selection, and arms where there is none - so a tap is one mark where the reader +has chosen the text already, and a mode where they have not. `markOnSelection` +then marks each selection as it is made. Do not disarm on the app's side: a tool +the reader turned on is theirs to turn off. A save reads the page's log (`odr.editing.getOperations()`, or `odr.annotation.getAnnotations()` for a PDF), and odrcore writes the file next From ed79845d69af91e958054c0699215ae1d27e16d2 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sun, 20 Sep 2026 18:38:22 +0200 Subject: [PATCH 2/5] Compress the tools strip, and put the right glyph on each pdf mark The slot under a tool's icon was 10 points deeper than the colour bar needs, so the bar sat far under the icon. The bar now hangs 3 points under it - close enough to belong to the tool, far enough not to be read as part of a glyph that draws a line along its own foot. A tool is 36 points rather than 40, and the strip is 42 where it was 46. The squiggly mark wore `scribble.variable`, which reads as a drawing rather than as an underline, and Draw wore `pencil.tip`, which at 22 points reads as a letter. Draw takes the scribble, and the squiggly mark takes a wave drawn at the weight of the symbols beside it, since the system set holds no wavy underline. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XASU9ZfSJ6QwsAJGE3DrN1 --- OpenDocumentReader/EditToolBar.swift | 55 ++++++++++++++++++++++++---- README.md | 6 ++- 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/OpenDocumentReader/EditToolBar.swift b/OpenDocumentReader/EditToolBar.swift index b879e07..c538eee 100644 --- a/OpenDocumentReader/EditToolBar.swift +++ b/OpenDocumentReader/EditToolBar.swift @@ -31,11 +31,18 @@ final class EditToolBar: UIView { case .fontSize: return "textformat.size" case .markUnderline: return "underline" case .markStrikeOut: return "strikethrough" - case .markSquiggly: return "scribble.variable" - case .markDraw: return "pencil.tip" + // no wavy underline in the system set, so the wave is drawn - see + // ``EditToolBar/squigglyImage`` + case .markSquiggly: return "squiggly" + case .markDraw: return "scribble.variable" } } + /// The glyph itself. + var image: UIImage? { + self == .markSquiggly ? EditToolBar.squigglyImage : UIImage(systemName: symbol) + } + var label: String { switch self { case .bold: return NSLocalizedString("edit_bold", comment: "") @@ -172,16 +179,21 @@ final class EditToolBar: UIView { static let fontSizes = [8, 9, 10, 11, 12, 14, 16, 18, 20, 24, 28, 32, 36, 48] - static let height: CGFloat = 46 + static let height: CGFloat = 42 /// One tool. The slot under the icon is there on every tool, holding /// something or not, so the icons sit on one line, and it is just deep /// enough for the caption - a tool carrying nothing reads as empty for /// every point over that. private static let toolWidth: CGFloat = 44 - private static let toolHeight: CGFloat = 40 + private static let toolHeight: CGFloat = 36 private static let iconSize: CGFloat = 22 - private static let slotHeight: CGFloat = 12 + private static let slotHeight: CGFloat = 10 + + /// What the colour bar keeps clear of the icon. Close enough that the bar + /// belongs to the tool, and far enough that it is not read as part of the + /// glyph - `underline` draws a line of its own along its foot. + private static let barGap: CGFloat = 3 /// What a tool that only offers Pro is drawn at, against the free one /// beside it. @@ -354,7 +366,7 @@ final class EditToolBar: UIView { button.accessibilityLabel = tool.label button.accessibilityIdentifier = "edit-tool-\(tool.symbol)" - let icon = UIImageView(image: UIImage(systemName: tool.symbol)) + let icon = UIImageView(image: tool.image) icon.contentMode = .scaleAspectFit icon.tintColor = button.tintColor icon.translatesAutoresizingMaskIntoConstraints = false @@ -482,7 +494,7 @@ final class EditToolBar: UIView { bar.widthAnchor.constraint(equalToConstant: 20), bar.heightAnchor.constraint(equalToConstant: 4), bar.centerXAnchor.constraint(equalTo: button.centerXAnchor), - bar.bottomAnchor.constraint(equalTo: slot.bottomAnchor, constant: -1), + bar.topAnchor.constraint(equalTo: slot.topAnchor, constant: Self.barGap), ]) bars[tool] = bar @@ -558,6 +570,35 @@ final class EditToolBar: UIView { return UIMenu(title: tool.label, children: children) } + /// A wave standing for an underline, at the weight of the system glyphs + /// beside it. The system set has no wavy underline, and `scribble` is the + /// Draw tool's. + private static let squigglyImage: UIImage = { + let size = CGSize(width: iconSize, height: iconSize) + let humps = 4 + let width: CGFloat = 16 + let step = width / CGFloat(humps) + let baseline: CGFloat = 14 + // a quadratic curve reaches half of what its control point offers + let reach: CGFloat = 5 + + let wave = UIBezierPath() + wave.move(to: CGPoint(x: 3, y: baseline)) + for hump in 0.. UIImage { let size = CGSize(width: 20, height: 20) diff --git a/README.md b/README.md index 1f3ab8a..949a010 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,11 @@ long press, so the colours are the button's `accessibilityCustomActions` as well. Every tool is an icon over a slot, and the slot is there whether it holds anything or not, so the icons sit on one line and no colour bar runs into the glyph above it. The row is centred where the tools leave room, and scrolls where -they do not. The tools match the website and OpenDocument.droid, and the page reports +they do not. + +The glyphs are the system set's, bar one: it holds no wavy underline, so the +squiggly mark's wave is drawn at the weight of the symbols beside it. `scribble` +is the Draw tool's, which is what it reads as. The tools match the website and OpenDocument.droid, and the page reports to the app through one `WKScriptMessageHandler`. **A PDF's tools are the page's to arm.** `odr.annotation.press` marks a standing From c9a7a88f259a6f00d482b7ac95ae860de8634306 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sun, 20 Sep 2026 18:47:07 +0200 Subject: [PATCH 3/5] Give the squiggly mark a letter, as the underline beside it has The mark's wave stood on its own, while the underline and the strike out beside it are a letter with a line through or under it. It is now a letter over the wave, in the same shape. The letter is the English one, and does not follow the language the way the system glyph does. The system set holds no wavy underline at all, so there is nothing to take the letter from. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XASU9ZfSJ6QwsAJGE3DrN1 --- OpenDocumentReader/EditToolBar.swift | 25 ++++++++++++++++++++----- README.md | 6 ++++-- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/OpenDocumentReader/EditToolBar.swift b/OpenDocumentReader/EditToolBar.swift index c538eee..ed7916c 100644 --- a/OpenDocumentReader/EditToolBar.swift +++ b/OpenDocumentReader/EditToolBar.swift @@ -570,17 +570,26 @@ final class EditToolBar: UIView { return UIMenu(title: tool.label, children: children) } - /// A wave standing for an underline, at the weight of the system glyphs - /// beside it. The system set has no wavy underline, and `scribble` is the - /// Draw tool's. + /// A letter over a wave, in the shape of the `underline` and + /// `strikethrough` glyphs beside it: the system set holds no wavy + /// underline, and `scribble` is the Draw tool's. + /// + /// The letter is the one Apple's own `underline` draws in English. It does + /// not follow the language, which the system glyph does. private static let squigglyImage: UIImage = { let size = CGSize(width: iconSize, height: iconSize) + + let font = UIFont.systemFont(ofSize: 16) + let letter = NSAttributedString( + string: "U", attributes: [.font: font, .foregroundColor: UIColor.black]) + let measured = letter.size() + let humps = 4 let width: CGFloat = 16 let step = width / CGFloat(humps) - let baseline: CGFloat = 14 + let baseline: CGFloat = 19.5 // a quadratic curve reaches half of what its control point offers - let reach: CGFloat = 5 + let reach: CGFloat = 3.5 let wave = UIBezierPath() wave.move(to: CGPoint(x: 3, y: baseline)) @@ -594,6 +603,12 @@ final class EditToolBar: UIView { wave.lineCapStyle = .round return UIGraphicsImageRenderer(size: size).image { _ in + // drawn from the letter's own baseline, which sits clear of the wave + // draw takes the top of the line box, and the baseline sits an + // ascent below that + letter.draw( + at: CGPoint(x: (iconSize - measured.width) / 2, y: 15.5 - measured.height - font.descender)) + UIColor.black.setStroke() wave.stroke() }.withRenderingMode(.alwaysTemplate) diff --git a/README.md b/README.md index 949a010..7a6cd61 100644 --- a/README.md +++ b/README.md @@ -133,8 +133,10 @@ glyph above it. The row is centred where the tools leave room, and scrolls where they do not. The glyphs are the system set's, bar one: it holds no wavy underline, so the -squiggly mark's wave is drawn at the weight of the symbols beside it. `scribble` -is the Draw tool's, which is what it reads as. The tools match the website and OpenDocument.droid, and the page reports +squiggly mark is drawn - a letter over a wave, shaped like the `underline` and +`strikethrough` beside it. Its letter is the English one and does not follow the +language, which the system glyph does. `scribble` is the Draw tool's, which is +what it reads as. The tools match the website and OpenDocument.droid, and the page reports to the app through one `WKScriptMessageHandler`. **A PDF's tools are the page's to arm.** `odr.annotation.press` marks a standing From 40836c33b7dd50292c686e638419f97d20c0833c Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Mon, 21 Sep 2026 07:12:52 +0200 Subject: [PATCH 4/5] Take core 7.2.2, where ink lands where the finger went Drawing on a pdf put the stroke about twice as far from the page's corner as the finger went, because the ink tool read the pointer against a box that leaves the fit's zoom out. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XASU9ZfSJ6QwsAJGE3DrN1 --- CHANGELOG.md | 4 +++- OpenDocumentReader.xcodeproj/project.pbxproj | 2 +- .../project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 4 ++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8f3f5e..e333663 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +31,7 @@ once the version tag exists. ### Changed -- The engine is odrcore 7.2.1, up from 6.13.0. +- The engine is odrcore 7.2.2, up from 6.13.0. - Entering an edit keeps your place in the document. - The pencil turns editing on and off, and marks up a PDF as well. - Lite opens every document for editing. The highlighter is in both apps; bold, @@ -49,6 +49,8 @@ once the version tag exists. - A mark on a PDF covers the letters it marks, and an underline sits under the text rather than through it. - Selecting a word in a PDF keeps the word readable and gives it both handles. +- Drawing on a PDF puts the ink where your finger went, instead of about twice + as far from the page's corner. ## [1.44] diff --git a/OpenDocumentReader.xcodeproj/project.pbxproj b/OpenDocumentReader.xcodeproj/project.pbxproj index cc78241..d7a2326 100644 --- a/OpenDocumentReader.xcodeproj/project.pbxproj +++ b/OpenDocumentReader.xcodeproj/project.pbxproj @@ -830,7 +830,7 @@ repositoryURL = "https://github.com/opendocument-app/OpenDocument.core.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 7.2.1; + minimumVersion = 7.2.2; }; }; AD584FCD41577C8CDEE974AA /* XCRemoteSwiftPackageReference "swift-package-manager-google-mobile-ads" */ = { diff --git a/OpenDocumentReader.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/OpenDocumentReader.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 7c82040..d9fe5f5 100644 --- a/OpenDocumentReader.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/OpenDocumentReader.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/opendocument-app/OpenDocument.core.git", "state" : { - "revision" : "7d6b5ac9555bef8432f246ebb1c6ff8cf69d1619", - "version" : "7.2.1" + "revision" : "c256685a77ff13d1af95821dfd0c041e3e5102ef", + "version" : "7.2.2" } }, { From 9500d48c4e999667e93bc23d38dfcd22c55df945 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Mon, 21 Sep 2026 07:24:13 +0200 Subject: [PATCH 5/5] Open the colours only on a tool that has them, and cut the comments A long press on bold, italic, underline or strikethrough opened a list of colours that did nothing, because every tool that was not dimmed got one. Only a tool that carries a colour opens one now, and a test holds it. The comments, the readme and the string notes lose their back stories, and an unused dictionary of icons goes with them. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XASU9ZfSJ6QwsAJGE3DrN1 --- .../DocumentViewController.swift | 73 ++++------ OpenDocumentReader/EditToolBar.swift | 134 +++++++----------- .../en.lproj/Localizable.strings | 5 +- .../EditWorkflowTests.swift | 47 +++--- README.md | 55 +++---- 5 files changed, 138 insertions(+), 176 deletions(-) diff --git a/OpenDocumentReader/DocumentViewController.swift b/OpenDocumentReader/DocumentViewController.swift index 621f28b..f306bfa 100644 --- a/OpenDocumentReader/DocumentViewController.swift +++ b/OpenDocumentReader/DocumentViewController.swift @@ -70,8 +70,7 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel /// Whether the document on screen can be edited and searched. Neither button /// stays in the bar when it cannot be used. private var canEdit = false { didSet { updateToolBar() } } - /// Whether the document is a pdf that takes marks. It decides the pen's - /// label and whether redo is in the bar. + /// Whether the document is a pdf that takes marks. private var canMark = false { didSet { updateEditButtonRole() @@ -93,8 +92,7 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel lazy var undoButton: UIBarButtonItem = makeEditButton( symbol: "arrow.uturn.backward", label: "edit_undo", action: #selector(undoTapped(_:))) - /// Puts it back. A mark on a pdf is taken back one at a time and never put - /// back, so this one leaves the bar over a pdf - see ``updateToolBar()``. + /// Puts it back. Not over a pdf - see ``updateToolBar()``. lazy var redoButton: UIBarButtonItem = makeEditButton( symbol: "arrow.uturn.forward", label: "edit_redo", action: #selector(redoTapped(_:))) @@ -102,9 +100,8 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel lazy var saveButton: UIBarButtonItem = makeEditButton( symbol: "square.and.arrow.down", label: "action_edit_save", action: #selector(saveTapped(_:))) - /// The bar holds what is done to the document; the strip under it what is - /// done to the text. All three start off, since a page that has just been - /// opened holds nothing to take back or write. + /// The bar holds what is done to the document; the strip what is done to + /// the text. All three start off: a fresh page has nothing to write. private func makeEditButton(symbol: String, label: String, action: Selector) -> UIBarButtonItem { let item = UIBarButtonItem( image: UIImage(systemName: symbol), style: .plain, target: self, action: action) @@ -455,9 +452,8 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel } /// From iOS 26 the bar's buttons are glass capsules filling its whole - /// height, which whatever is pinned to either edge would cut off - the - /// banner above it as much as the tools below. Older bars have a - /// background of their own and want no such gap. + /// height, which whatever is pinned to either edge would cut off. Older + /// bars have a background of their own and want no such gap. private static var toolBarMargin: CGFloat { if #available(iOS 26.0, *) { return 8 @@ -466,8 +462,8 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel return 0 } - /// What the bar takes. A glass capsule is drawn to the bar's height, so a - /// bar sized to the glyph alone leaves the capsules touching both edges. + /// What the bar takes. A capsule is drawn to the bar's height, so a bar + /// sized to the glyph alone leaves them touching both edges. private static var toolBarHeight: CGFloat { if #available(iOS 26.0, *) { return 50 @@ -482,9 +478,8 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel pageTabBar.translatesAutoresizingMaskIntoConstraints = false webview.translatesAutoresizingMaskIntoConstraints = false - // the banner sits above everything, the bar included. Under the bar it - // stood between the editing tools and the page, so the tools read as - // part of the ad rather than as part of the document + // above everything, the bar included: under it the banner stood + // between the tools and the page they act on bannerSlot.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true bannerSlot.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true bannerSlot.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor).isActive = true @@ -605,11 +600,11 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel findAll(searchText: searchText) } - /// The pen: turns the mode on, and off again. Leaving with changes the page - /// alone holds asks first. See ``updateEditButtonRole()``. + /// The pen: turns the mode on, and off again. Leaving with changes the + /// page alone holds asks first. /// - /// The button stands on the core's answer, and every edition opens what it - /// names. What Lite does not sell is the tool, which says so in the strip. + /// It stands on the core's answer, and every edition opens what it names. + /// What Lite does not sell is the tool - see ``editToolTapped(_:)``. @IBAction func toggleEdit(_ sender: UIBarButtonItem) { if isEditingDocument { leaveEdit() @@ -665,8 +660,7 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel saveAndStay() } - /// A pdf keeps its marks in the annotator, not in the editor, so the two - /// take back through different calls. + /// A pdf keeps its marks in the annotator, not in the editor. @objc func undoTapped(_ sender: UIBarButtonItem) { AnalyticsManager.shared.report("menu_edit_undo") @@ -731,9 +725,8 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel odr.onCellsStale = function (detail) { post({ type: 'cellsStale', count: detail && detail.cells ? detail.cells.length : 0 }); }; - // only a sheet carries the sheet editor, so this is asked of the - // page rather than taken for granted. The pointer is not asked - // either: a web view answers that one as a mouse in the simulator + // asked of the page, since only a sheet carries that editor. The + // pointer is no use: a web view answers it as a mouse if (odr.editing && odr.editing.setSheetOptions) { odr.editing.setSheetOptions({ editOnClick: true }); } @@ -799,11 +792,8 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel } } - /// Turns the mode on in the page already on screen and shows its tools. - /// A pdf needs no mode, only a marker that acts on a selection. - /// - /// A sheet or a plain text file takes no formatting, so it gets no strip at - /// all: the cells are the editor, and the bar holds the rest. + /// Turns the mode on in the page already on screen and shows its tools. A + /// sheet or a plain text file takes no formatting, so it gets no strip. private func beginEditSession() { hasOfferedProForThisEdit = false hasUnsavedEdits = false @@ -838,9 +828,8 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel } } - /// Whether the page has said what it is and the strip stands as it will. - /// A sheet answers this with no strip at all, so nothing can wait on the - /// strip itself. + /// Whether the page has said what it is. Not the strip itself: a sheet + /// answers with no strip at all. private(set) var isEditSessionReady = false /// The tools are up, which is what a screenshot of an edit waits for. @@ -860,9 +849,8 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel } } - /// The gate is on the tool, not on the mode: a tool Lite does not sell - /// offers Pro instead of doing its work, and the highlighter beside it - /// works in every edition. + /// The gate is on the tool, not the mode: what Lite does not sell offers + /// Pro, and the highlighter beside it works in every edition. private func editToolTapped(_ tool: EditToolBar.Tool) { if !tool.isFree, !Features.advancedEditing { offerPro(canMark ? .pdf : .formatting) @@ -1022,8 +1010,7 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel } } - /// What is done to the document goes after the pen: take back, put back, - /// write. The disc is last, as on the website. + /// What is done to the document goes after the pen: undo, redo, save. private func setUpEditButtons() { guard let pen = toolBarItems.firstIndex(where: { $0 === editButtonSpacer }) else { return } @@ -1083,9 +1070,8 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel private static let toolBarTitleGap: CGFloat = 16 /// While an edit is on the bar is the edit's: undo, redo and save join it, - /// and the magnifier and the document's name stand down to leave them the - /// width - six buttons and a name do not fit a phone's bar. A mark on a pdf - /// is taken back one at a time and never put back, so redo stays out. + /// and the magnifier and the name stand down - six buttons and a name do + /// not fit a phone's bar. A pdf mark is never put back, so redo stays out. private func updateToolBar() { toolBar.items = toolBarItems.filter { item in if item === editButton || item === editButtonSpacer { @@ -1118,10 +1104,9 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel isEditingDocument = document?.edit ?? false } - /// The pencil, for a document and for a pdf alike: the two never stand in - /// the bar together, so the label is what separates them. It is drawn - /// selected while the mode is on, and VoiceOver reads the label, not the - /// glyph. + /// The pencil, for a document and a pdf alike: the two never stand in the + /// bar together, so the label separates them. Selected while the mode is + /// on. private func updateEditButtonRole() { editButton.image = UIImage(systemName: "pencil") editButton.accessibilityLabel = NSLocalizedString(canMark ? "mark_pdf" : "menu_edit", comment: "") diff --git a/OpenDocumentReader/EditToolBar.swift b/OpenDocumentReader/EditToolBar.swift index ed7916c..b629845 100644 --- a/OpenDocumentReader/EditToolBar.swift +++ b/OpenDocumentReader/EditToolBar.swift @@ -1,17 +1,11 @@ import UIKit -/// The strip of tools under the bar: what changes the text, and nothing else. -/// Undo, redo and save are the bar's, see ``DocumentViewController``. +/// The strip of tools under the bar: what changes the text. Undo, redo and +/// save are the bar's. /// -/// Every tool is one square button, and a tap does the one thing the tool is -/// for. A tool that applies a colour shows it in the bar under its icon, and a -/// **long press** opens the colours - there is no second button beside it. -/// Which tool is on comes back from the page. -/// -/// Without ``advancedEditing`` the strip is locked: the highlighter still -/// works, every other tool is dimmed and offers Pro, and Pro's badge stands in -/// front of the row. One free tool of each kind is what makes the mode worth -/// opening - see ``Tool/isFree``. +/// A tap does the tool's one job, and a long press opens the colours it +/// applies. Without ``advancedEditing`` every tool but the highlighter is +/// dimmed and offers Pro instead. final class EditToolBar: UIView { /// One button of the strip. @@ -31,8 +25,7 @@ final class EditToolBar: UIView { case .fontSize: return "textformat.size" case .markUnderline: return "underline" case .markStrikeOut: return "strikethrough" - // no wavy underline in the system set, so the wave is drawn - see - // ``EditToolBar/squigglyImage`` + // not in the system set, so it is drawn - see ``squigglyImage`` case .markSquiggly: return "squiggly" case .markDraw: return "scribble.variable" } @@ -77,23 +70,19 @@ final class EditToolBar: UIView { } } - /// The one tool a locked strip still does the work of: the highlighter, - /// under both the names it has. It is the formatting style in a text - /// document and the marking tool on a pdf, so a reader of either kind - /// has the same free tool. + /// The one tool a locked strip still works, under both its names: the + /// formatting style, and the pdf's marking tool. var isFree: Bool { self == .highlight || self == .markHighlight } - /// Whether the tool's own colours open on a tap rather than on a long - /// press. The text colour has no state to turn off, so a tap is the - /// colours themselves; the size has no colour and opens its sizes. + /// Whether a tap opens the tool's choices. Neither of these two has + /// any state to turn off. var opensOnTap: Bool { self == .textColor || self == .fontSize } /// Whether a bar under the icon shows the colour the tool applies. - /// A toggle applies no colour, so its slot stays empty. var showsColor: Bool { defaultColor != nil } @@ -120,9 +109,8 @@ final class EditToolBar: UIView { } } - /// What the strip holds, by what the page is. A sheet or a plain text file - /// takes no formatting at all, so there is no strip over it: see - /// ``layout`` set to nil. + /// What the strip holds. A sheet or a plain text file takes no formatting, + /// so it gets no strip at all - ``layout`` is left nil. enum Layout { /// a text document or a presentation case text @@ -181,18 +169,15 @@ final class EditToolBar: UIView { static let height: CGFloat = 42 - /// One tool. The slot under the icon is there on every tool, holding - /// something or not, so the icons sit on one line, and it is just deep - /// enough for the caption - a tool carrying nothing reads as empty for - /// every point over that. + /// One tool: an icon over a slot. Every tool has the slot, holding + /// something or not, so the icons sit on one line. private static let toolWidth: CGFloat = 44 private static let toolHeight: CGFloat = 36 private static let iconSize: CGFloat = 22 private static let slotHeight: CGFloat = 10 - /// What the colour bar keeps clear of the icon. Close enough that the bar - /// belongs to the tool, and far enough that it is not read as part of the - /// glyph - `underline` draws a line of its own along its foot. + /// What the colour bar keeps clear of the icon: `underline` draws a line + /// of its own along its foot. private static let barGap: CGFloat = 3 /// What a tool that only offers Pro is drawn at, against the free one @@ -206,9 +191,8 @@ final class EditToolBar: UIView { } } - /// Whether the tools do their work. Without it every tool but the free one - /// is dimmed, a tap goes to `onTap`, which says what Pro is, and Pro's - /// badge stands in front of the row. + /// Whether the tools do their work. Without it every tool but the free + /// one is dimmed and its tap goes to `onTap`, which offers Pro. var advancedEditing = true { didSet { rebuild() @@ -222,13 +206,12 @@ final class EditToolBar: UIView { private let stack = UIStackView() private var buttons: [Tool: UIButton] = [:] private var bars: [Tool: UIView] = [:] - private var icons: [Tool: UIImageView] = [:] - /// The size the selection is in, under the size tool's icon. + /// The caption under the size tool's icon. private var sizeCaption: UILabel? /// The size the selection is in, in points, or nil where the runs - /// disagree. The menu marks it. + /// disagree. private var selectionSize: String? override init(frame: CGRect) { @@ -257,9 +240,8 @@ final class EditToolBar: UIView { stack.translatesAutoresizingMaskIntoConstraints = false scrollView.addSubview(stack) - // the row is as wide as the screen where the tools leave room, so that - // the spacers at its ends centre them; where they do not fit, it keeps - // its own width and scrolls + // as wide as the screen where the tools leave room, so the spacers at + // its ends centre them; where they do not, it scrolls let fills = stack.widthAnchor.constraint( greaterThanOrEqualTo: scrollView.frameLayoutGuide.widthAnchor, constant: -16) fills.priority = .required @@ -280,8 +262,7 @@ final class EditToolBar: UIView { ]) } - /// One end of the row, which takes what the tools leave and so centres - /// them. Two of equal width, one at each end. + /// One end of the row: two of equal width, which centres what is between. private func makeEdgeSpacer() -> UIView { let spacer = UIView() spacer.setContentHuggingPriority(.defaultLow - 1, for: .horizontal) @@ -303,7 +284,6 @@ final class EditToolBar: UIView { } buttons = [:] bars = [:] - icons = [:] sizeCaption = nil selectionSize = nil @@ -355,8 +335,8 @@ final class EditToolBar: UIView { return label } - /// The icon on one line with every other tool's, and under it the slot - /// that carries what the tool applies or what it reads on the selection. + /// The icon, and under it the slot: the colour the tool applies, or what + /// it reads on the selection. private func makeButton(for tool: Tool) -> UIButton { var configuration = UIButton.Configuration.plain() configuration.cornerStyle = .capsule @@ -371,10 +351,7 @@ final class EditToolBar: UIView { icon.tintColor = button.tintColor icon.translatesAutoresizingMaskIntoConstraints = false button.addSubview(icon) - icons[tool] = icon - // the slot is there on every tool, holding something or not, so that - // the icons sit on one line let slot = UILayoutGuide() button.addLayoutGuide(slot) @@ -418,8 +395,8 @@ final class EditToolBar: UIView { return button } - /// A tap does the tool's one job, and a long press opens what it applies. - /// Where the tool has nothing to turn off, the tap opens it instead. + /// A tap does the tool's one job; a long press opens the colours it + /// applies. A dimmed tool only offers Pro. private func addActions(to button: UIButton, for tool: Tool) { guard !isPro(tool) else { button.addAction( @@ -430,23 +407,27 @@ final class EditToolBar: UIView { return } - button.menu = makeMenu(for: tool) - button.showsMenuAsPrimaryAction = tool.opensOnTap + if tool.opensOnTap { + button.menu = makeMenu(for: tool) + button.showsMenuAsPrimaryAction = true - guard !tool.opensOnTap else { return } + return + } button.addAction( UIAction { [weak self] _ in self?.onTap?(tool) }, for: .touchUpInside) - // VoiceOver has no long press, so the colours behind it are offered as - // the button's own actions instead, each named "Highlight yellow" + // a toggle applies no colour, so its long press opens nothing + guard tool.showsColor else { return } + + button.menu = makeMenu(for: tool) + // VoiceOver has no long press, so the colours are offered as actions button.accessibilityCustomActions = colorActions(for: tool) } - /// What the long press opens, as one action per colour, for a reader who - /// cannot make that press. + /// What the long press opens, as one action per colour. private func colorActions(for tool: Tool) -> [UIAccessibilityCustomAction] { let of = String(format: NSLocalizedString("edit_color_of", comment: ""), tool.label) @@ -478,8 +459,7 @@ final class EditToolBar: UIView { return actions } - /// A bar in the slot, in the colour the tool applies, so that the bar is - /// what the next tap uses. + /// The colour the next tap applies, shown in the slot. private func addBar(to button: UIButton, in slot: UILayoutGuide, for tool: Tool) { let bar = UIView() bar.isUserInteractionEnabled = false @@ -500,8 +480,7 @@ final class EditToolBar: UIView { bars[tool] = bar } - /// What the tool reads on the selection, in the slot: the size the text is - /// in. The icon stays, so the tool is still the one it was. + /// What the tool reads on the selection, in the slot under its icon. private func addCaption(to button: UIButton, in slot: UILayoutGuide) { let caption = UILabel() caption.isUserInteractionEnabled = false @@ -570,12 +549,9 @@ final class EditToolBar: UIView { return UIMenu(title: tool.label, children: children) } - /// A letter over a wave, in the shape of the `underline` and - /// `strikethrough` glyphs beside it: the system set holds no wavy - /// underline, and `scribble` is the Draw tool's. - /// - /// The letter is the one Apple's own `underline` draws in English. It does - /// not follow the language, which the system glyph does. + /// A letter over a wave, shaped like the `underline` and `strikethrough` + /// beside it. The system set has no wavy underline. The letter is the + /// English one and does not follow the language, which a system glyph does. private static let squigglyImage: UIImage = { let size = CGSize(width: iconSize, height: iconSize) @@ -603,9 +579,8 @@ final class EditToolBar: UIView { wave.lineCapStyle = .round return UIGraphicsImageRenderer(size: size).image { _ in - // drawn from the letter's own baseline, which sits clear of the wave - // draw takes the top of the line box, and the baseline sits an - // ascent below that + // draw takes the top of the line box; the baseline is an ascent + // below it, and sits clear of the wave letter.draw( at: CGPoint(x: (iconSize - measured.width) / 2, y: 15.5 - measured.height - font.descender)) @@ -624,9 +599,8 @@ final class EditToolBar: UIView { }.withRenderingMode(.alwaysOriginal) } - /// Whether `tool` is drawn as the mode: a style the selection shows, or - /// the armed marker on a pdf. A locked strip shows none of it, since - /// nothing it dims is on. + /// Whether `tool` is drawn as on: a style the selection shows, or the + /// armed marker on a pdf. A dimmed tool never is. func setPressed(_ tool: Tool, _ pressed: Bool) { buttons[tool]?.isSelected = pressed && !isPro(tool) } @@ -636,8 +610,7 @@ final class EditToolBar: UIView { bars[tool]?.backgroundColor = color } - /// Captions the size tool with "12 pt", or with nothing where the runs - /// disagree. + /// Captions the size tool with "12 pt", or nothing where the runs disagree. func setFontSize(_ points: String?) { selectionSize = points @@ -661,8 +634,7 @@ final class EditToolBar: UIView { sizeCaption?.text } - /// For the tests: whether the row carries the Pro badge, in front of the - /// tools. + /// For the tests: whether the row carries the Pro badge. var showsProBadge: Bool { stack.arrangedSubviews.contains { $0.accessibilityIdentifier == "edit-tool-pro" } } @@ -672,8 +644,12 @@ final class EditToolBar: UIView { buttons[tool] != nil } - /// For the tests: whether `tool` is dimmed, which is how a locked strip - /// says the tool is Pro's. + /// For the tests: what a long press on `tool` opens, if anything. + func menu(of tool: Tool) -> UIMenu? { + buttons[tool]?.menu + } + + /// For the tests: whether `tool` is dimmed, which says it is Pro's. func isDimmed(_ tool: Tool) -> Bool { (buttons[tool]?.alpha ?? 1) < 1 } diff --git a/OpenDocumentReader/en.lproj/Localizable.strings b/OpenDocumentReader/en.lproj/Localizable.strings index b26b7be..dbb111d 100644 --- a/OpenDocumentReader/en.lproj/Localizable.strings +++ b/OpenDocumentReader/en.lproj/Localizable.strings @@ -107,7 +107,7 @@ /* Shown for a format odrcore does not read */ "toast_error_illegal_file_reopen" = "Unsupported file format. Try opening it in another app."; -/* The editing tools, read by VoiceOver. Undo and redo are the bar's, the rest the strip under it */ +/* The editing tools, read by VoiceOver. Undo and redo are the bar's */ "edit_bold" = "Bold"; "edit_italic" = "Italic"; "edit_underline" = "Underline"; @@ -145,8 +145,7 @@ "edit_refused_read_only" = "This document cannot be edited."; "edit_refused_range" = "An edit cannot reach over a picture or a table."; -/* What Pro adds, shown in the Lite app as the reader runs into it. The highlighter - is free of it in both kinds of document, so that the mode is worth opening */ +/* What Pro adds, shown in the Lite app. The highlighter is free in both kinds */ "pro_feature_title" = "Part of Pro"; "pro_feature_formatting" = "Bold, italic, colors and text size, and adding or joining paragraphs, are part of OpenDocument Reader Pro. The highlighter is in every edition."; "pro_feature_pdf" = "Underlining, striking out and drawing on a PDF are part of OpenDocument Reader Pro. The highlighter is in every edition."; diff --git a/OpenDocumentReaderTests/EditWorkflowTests.swift b/OpenDocumentReaderTests/EditWorkflowTests.swift index 76d3e8b..bb44f8b 100644 --- a/OpenDocumentReaderTests/EditWorkflowTests.swift +++ b/OpenDocumentReaderTests/EditWorkflowTests.swift @@ -69,8 +69,8 @@ class EditWorkflowTests: XCTestCase { XCTAssertEqual(controller.editButton.image, UIImage(systemName: "pencil")) } - /// The pencil is selected while editing. Undo, redo and save join the bar - /// only while editing, and each is enabled only once the page says so. + /// Undo, redo and save join the bar only while editing, and each is + /// enabled only once the page says so. func testTheBarTakesUndoRedoAndSaveWhileEditing() throws { openDocument() @@ -96,8 +96,7 @@ class EditWorkflowTests: XCTestCase { waitUntil { self.controller.undoButton.isEnabled } } - /// The magnifier stands down while an edit is on, to leave the three their - /// width. + /// The magnifier stands down while an edit is on. func testTheSearchButtonLeavesTheBarWhileEditing() throws { openDocument() waitUntil { self.barContains(self.controller.searchButton) } @@ -184,9 +183,8 @@ class EditWorkflowTests: XCTestCase { // MARK: - the tools - /// The strip under the bar: formatting for a text document, once the page - /// says it is editable, and gone again with the edit. Undo and redo are - /// the bar's, so the strip does not hold them. + /// The strip: formatting for a text document, and gone again with the + /// edit. func testATextDocumentShowsTheFormattingToolsWhileEditing() throws { openDocument() @@ -207,8 +205,7 @@ class EditWorkflowTests: XCTestCase { XCTAssertNil(controller.editToolBar.layout) } - /// The cells are the editor and there is nothing to format, so a - /// spreadsheet gets no strip at all - only the bar. + /// The cells are the editor, so a spreadsheet gets no strip - only the bar. func testASpreadsheetShowsNoToolStrip() throws { documentURL = try copyFixture(ofType: "ods") try present(documentURL) @@ -278,9 +275,8 @@ class EditWorkflowTests: XCTestCase { // MARK: - the gate - /// Lite dims what only offers Pro and leaves the highlighter working, in a - /// text document and on a pdf alike. The badge says whose the dimmed tools - /// are. Driven on the strip itself, since the test bundle is Pro's. + /// Lite dims what only offers Pro and leaves the highlighter working. + /// Driven on the strip itself, since the test bundle is Pro's. func testALockedStripKeepsTheHighlighterAndDimsTheRest() throws { let tools = EditToolBar() tools.advancedEditing = false @@ -300,6 +296,27 @@ class EditWorkflowTests: XCTestCase { XCTAssertFalse(tools.isDimmed(.markHighlight)) } + /// A toggle applies no colour, so its long press opens nothing. + func testOnlyTheToolsThatCarryAColourOpenOne() throws { + let tools = EditToolBar() + tools.layout = .text + + XCTAssertNil(tools.menu(of: .bold)) + XCTAssertNil(tools.menu(of: .italic)) + XCTAssertNil(tools.menu(of: .underline)) + XCTAssertNil(tools.menu(of: .strikethrough)) + + XCTAssertNotNil(tools.menu(of: .highlight)) + XCTAssertNotNil(tools.menu(of: .textColor)) + XCTAssertNotNil(tools.menu(of: .fontSize)) + + tools.layout = .pdf + + for mark in EditToolBar.Layout.pdf.tools { + XCTAssertNotNil(tools.menu(of: mark), "\(mark) carries a colour") + } + } + /// Pro dims nothing and wears no badge. func testAnUnlockedStripShowsNoBadge() throws { let tools = EditToolBar() @@ -311,8 +328,7 @@ class EditWorkflowTests: XCTestCase { // MARK: - a pdf - /// The pencil is the same on a pdf - the label is what separates them - - /// and the edit is a set of marks. + /// The pencil is the same on a pdf; the label separates them. func testAPdfOffersMarksAndSavesThem() throws { documentURL = try copyFixture(ofType: "pdf") try present(documentURL) @@ -477,8 +493,7 @@ class EditWorkflowTests: XCTestCase { waitForPage(where: condition) } - /// The strip stands as it will once the editable page has answered what it - /// is. Not the strip itself: a sheet answers with no strip at all. + /// Waits on the page's answer, not on the strip: a sheet has none. private func waitForTools(file: StaticString = #filePath, line: UInt = #line) { waitUntil(file: file, line: line) { self.controller.isEditSessionReady } } diff --git a/README.md b/README.md index 7a6cd61..13507f0 100644 --- a/README.md +++ b/README.md @@ -64,12 +64,9 @@ Pro. **The gate is on the tool, not on the mode.** Both editions open every kind the core calls editable, a PDF included. A locked `EditToolBar` dims what only -offers Pro, puts the "Pro" badge in front of the row, and leaves the -**highlighter** working under both the names it has - `highlight` is the -formatting style and the PDF's marking tool alike. A mode nobody can do -anything in is not worth opening, and a reader who has highlighted a paragraph -knows what the rest would do. Do not put the whole-mode gate back. -`OpenDocument.droid` draws the same line with the same flag. +offers Pro and leaves the **highlighter** working under both its names - +`highlight` is the formatting style and the PDF's marking tool alike. Do not put +the whole-mode gate back. `OpenDocument.droid` draws the same line. `configs/full` and `configs/lite` hold each bundle's `Info.plist` and privacy manifest, out of the synchronized folder, since anything left in there would be @@ -112,38 +109,28 @@ in App Store review. Every document is rendered editable, so the pencil only calls `odr.editing.enable()` and the page stays where it is. The same pencil edits a document and marks up a PDF: the two never stand in the bar together, so the -label is what separates them. +label separates them. **The bar holds what is done to the document, the strip what is done to the -text.** Undo, redo and save are bar buttons, so they do not scroll away, and the -page's own report enables them. Redo stays out over a PDF, where a mark is taken -back one at a time and never put back. The magnifier stands down while an edit is -on, to leave the three their width. - -`EditToolBar` under the bar is the formatting alone, which is why a sheet or a -plain text file shows no strip at all. A tap does the tool's one job, and a -**long press** opens the colours it applies - there is no second button beside a -tool, and the bar under its icon is what the next tap uses, not what the -selection is. The text colour and the text size are the exception and open on a -tap, having no state to turn off. Do not put the chevrons back. VoiceOver has no -long press, so the colours are the button's `accessibilityCustomActions` as -well. Every tool is an icon over a slot, and the slot is there whether it holds -anything or not, so the icons sit on one line and no colour bar runs into the -glyph above it. The row is centred where the tools leave room, and scrolls where -they do not. - -The glyphs are the system set's, bar one: it holds no wavy underline, so the -squiggly mark is drawn - a letter over a wave, shaped like the `underline` and -`strikethrough` beside it. Its letter is the English one and does not follow the -language, which the system glyph does. `scribble` is the Draw tool's, which is -what it reads as. The tools match the website and OpenDocument.droid, and the page reports -to the app through one `WKScriptMessageHandler`. +text.** Undo, redo and save are bar buttons, so they do not scroll away. Redo +stays out over a PDF, where a mark is never put back, and the magnifier stands +down while an edit is on to leave the three their width. + +`EditToolBar` is the formatting alone, so a sheet or a plain text file shows no +strip. A tap does the tool's one job and a **long press** opens the colours it +applies; the bar under the icon is what the next tap uses, not what the +selection is. The text colour and the text size open on a tap, having no state +to turn off. Do not put the chevrons back. The tools match the website and +OpenDocument.droid, and the page reports to the app through one +`WKScriptMessageHandler`. + +The glyphs are the system set's, bar one: it has no wavy underline, so the +squiggly mark is drawn. **A PDF's tools are the page's to arm.** `odr.annotation.press` marks a standing -selection, and arms where there is none - so a tap is one mark where the reader -has chosen the text already, and a mode where they have not. `markOnSelection` -then marks each selection as it is made. Do not disarm on the app's side: a tool -the reader turned on is theirs to turn off. +selection and arms where there is none, and `markOnSelection` marks each +selection as it is made. Do not disarm on the app's side: a tool the reader +turned on is theirs to turn off. A save reads the page's log (`odr.editing.getOperations()`, or `odr.annotation.getAnnotations()` for a PDF), and odrcore writes the file next