diff --git a/CHANGELOG.md b/CHANGELOG.md index bc57da9..e333663 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,23 +23,34 @@ 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.2, 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. +- 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 627f082..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.1.0; + 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 8ed0f6c..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" : "cee4ef5e7dbc4e781bb8459dc5269cded98f824a", - "version" : "7.1.0" + "revision" : "c256685a77ff13d1af95821dfd0c041e3e5102ef", + "version" : "7.2.2" } }, { diff --git a/OpenDocumentReader/DocumentViewController.swift b/OpenDocumentReader/DocumentViewController.swift index 843efd7..f306bfa 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. @@ -69,7 +71,12 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel /// 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() } } + private var canMark = false { + didSet { + updateEditButtonRole() + updateToolBar() + } + } private var isEditingDocument = false { didSet { updateEditButtonRole() @@ -81,25 +88,41 @@ 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. Not 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 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: "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 +213,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 +452,9 @@ 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 + /// 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 toolBarBottomMargin: CGFloat { + private static var toolBarMargin: CGFloat { if #available(iOS 26.0, *) { return 8 } @@ -439,27 +462,42 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel return 0 } + /// 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 + } + + 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 - + // 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: 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 @@ -562,13 +600,14 @@ 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. + /// + /// 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() - } else if canMark, !Features.advancedEditing { - offerPro(.pdf) } else { editDocument() } @@ -621,6 +660,19 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel saveAndStay() } + /// A pdf keeps its marks in the annotator, not in the editor. + @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 +725,14 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel odr.onCellsStale = function (detail) { post({ type: 'cellsStale', count: detail && detail.cells ? detail.cells.length : 0 }); }; + // 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 }); + } 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 +746,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 @@ -735,17 +792,19 @@ 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. + /// 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 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 +814,28 @@ 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. 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. private func editSessionReady() { + isEditSessionReady = true + if ScreenshotMode.screen == .edit { ScreenshotMode.markReady(view) } @@ -784,18 +849,16 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel } } + /// 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.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 +1010,13 @@ 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: undo, redo, save. + 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 +1028,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 +1069,27 @@ 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 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 { 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 +1104,11 @@ 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 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: 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..b629845 100644 --- a/OpenDocumentReader/EditToolBar.swift +++ b/OpenDocumentReader/EditToolBar.swift @@ -1,14 +1,18 @@ 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. Undo, redo and +/// save are the bar's. +/// +/// 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 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 { @@ -21,13 +25,17 @@ 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" - case .undo: return "arrow.uturn.backward" - case .redo: return "arrow.uturn.forward" + // not in the system set, so it is drawn - see ``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: "") @@ -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,21 @@ 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 works, under both its names: the + /// formatting style, and the pdf's marking 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 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. 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 +109,20 @@ final class EditToolBar: UIView { } } - /// What the row holds, by what the page is. + /// 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 - /// 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 +167,32 @@ 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 = 42 + + /// 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 - /// The tools shown; nil shows none and hides the row. + /// 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 + /// 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 and its tap goes to `onTap`, which offers Pro. var advancedEditing = true { didSet { rebuild() @@ -208,6 +207,13 @@ final class EditToolBar: UIView { private var buttons: [Tool: UIButton] = [:] private var bars: [Tool: UIView] = [:] + /// 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. + private var selectionSize: String? + override init(frame: CGRect) { super.init(frame: frame) @@ -234,6 +240,12 @@ final class EditToolBar: UIView { stack.translatesAutoresizingMaskIntoConstraints = false scrollView.addSubview(stack) + // 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 + NSLayoutConstraint.activate([ scrollView.leadingAnchor.constraint(equalTo: leadingAnchor), scrollView.trailingAnchor.constraint(equalTo: trailingAnchor), @@ -246,9 +258,25 @@ 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: two of equal width, which centres what is between. + 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 +284,8 @@ final class EditToolBar: UIView { } buttons = [:] bars = [:] + sizeCaption = nil + selectionSize = nil guard let layout else { isHidden = true @@ -265,29 +295,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 +323,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, 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.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: tool.image) + icon.contentMode = .scaleAspectFit + icon.tintColor = button.tintColor + icon.translatesAutoresizingMaskIntoConstraints = false + button.addSubview(icon) + + 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; 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( UIAction { [weak self] _ in self?.onTap?(tool) }, for: .touchUpInside) + + return } - return button + if tool.opensOnTap { + button.menu = makeMenu(for: tool) + button.showsMenuAsPrimaryAction = true + + return + } + + button.addAction( + UIAction { [weak self] _ in + self?.onTap?(tool) + }, for: .touchUpInside) + + // 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) } - /// 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. + 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) { + /// 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 bar.layer.cornerRadius = 1 @@ -383,32 +471,54 @@ 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.topAnchor.constraint(equalTo: slot.topAnchor, constant: Self.barGap), ]) 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 under its icon. + 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 { @@ -439,6 +549,46 @@ final class EditToolBar: UIView { return UIMenu(title: tool.label, children: children) } + /// 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) + + 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 = 19.5 + // a quadratic curve reaches half of what its control point offers + let reach: CGFloat = 3.5 + + let wave = UIBezierPath() + wave.move(to: CGPoint(x: 3, y: baseline)) + for hump in 0.. UIImage { let size = CGSize(width: 20, height: 20) @@ -449,14 +599,10 @@ 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. + /// 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 - } - - 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 +610,18 @@ 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 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 +631,12 @@ 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. 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 +644,16 @@ final class EditToolBar: UIView { buttons[tool] != nil } + /// 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 + } + 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..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 under the bar, read by VoiceOver */ +/* The editing tools, read by VoiceOver. Undo and redo are the bar's */ "edit_bold" = "Bold"; "edit_italic" = "Italic"; "edit_underline" = "Underline"; @@ -145,19 +145,19 @@ "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. The highlighter is free in both kinds */ "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..bb44f8b 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 { + /// 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,31 @@ 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. + 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 +125,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 +183,8 @@ 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: formatting for a text document, and gone again with the + /// edit. func testATextDocumentShowsTheFormattingToolsWhileEditing() throws { openDocument() @@ -173,7 +196,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 +205,8 @@ 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, so a spreadsheet gets no strip - only the bar. + func testASpreadsheetShowsNoToolStrip() throws { documentURL = try copyFixture(ofType: "ods") try present(documentURL) openDocument(where: "document.querySelectorAll('td').length > 0") @@ -190,9 +214,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 +273,62 @@ 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. + /// 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)) + } + + /// 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() + 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 separates them. func testAPdfOffersMarksAndSavesThem() throws { documentURL = try copyFixture(ofType: "pdf") try present(documentURL) @@ -257,7 +336,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 +348,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 +493,9 @@ class EditWorkflowTests: XCTestCase { waitForPage(where: condition) } - /// The tools appear once the editable page has answered what it is. + /// 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.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..13507f0 100644 --- a/README.md +++ b/README.md @@ -59,8 +59,14 @@ 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 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 @@ -101,12 +107,31 @@ 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 +`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 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. 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, 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 to the open one before it moves into place. The page then renders again and