From d33c975ffb68eae7f136d7034e213553c8423b71 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Mon, 13 Nov 2023 20:14:31 +0800 Subject: [PATCH] Label entry improvements for macos --- .../Share/Views/EditInfoSheet.swift | 8 - .../Share/Views/ShareExtensionView.swift | 64 +++--- .../App/Views/Labels/ApplyLabelsView.swift | 9 +- .../Sources/App/Views/LabelsEntryView.swift | 191 +++++++++++++----- .../Views/LinkedItemMetadataEditView.swift | 2 +- .../Views/FeedItem/LibraryItemCard.swift | 2 +- 6 files changed, 188 insertions(+), 88 deletions(-) diff --git a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/EditInfoSheet.swift b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/EditInfoSheet.swift index 66ad351a1..c7d315a85 100644 --- a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/EditInfoSheet.swift +++ b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/EditInfoSheet.swift @@ -31,14 +31,6 @@ public struct EditInfoSheet: View { #endif } -// func saveInfo() { -// if let linkedItem = viewModel.linkedItem { -// _ = viewModel.services.dataService.updateLinkedItemTitleAndDescription(itemID: linkedItem.unwrappedID, title: title, description: description, author: author) -// } else { -// // Maybe we shouldn't even allow this UI without linkeditem existing -// } -// } - public var body: some View { if let item = viewModel.linkedItem { LinkedItemMetadataEditView(item: item) { title, _ in diff --git a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/ShareExtensionView.swift b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/ShareExtensionView.swift index 97ed18cc4..f223f08ed 100644 --- a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/ShareExtensionView.swift +++ b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/ShareExtensionView.swift @@ -60,13 +60,12 @@ public struct ShareExtensionView: View { } var isSynced: Bool { - true -// switch viewModel.status { -// case .synced: -// return true -// default: -// return false -// } + switch viewModel.status { + case .synced: + return true + default: + return false + } } var articleInfoBox: some View { @@ -166,14 +165,16 @@ public struct ShareExtensionView: View { var moreMenuButton: some View { Menu { - Button(action: { - NotificationCenter.default.post(name: Notification.Name("ShowEditInfoSheet"), object: nil) - }, label: { - Label( - "Edit Info", - systemImage: "info.circle" - ) - }) + #if os(iOS) + Button(action: { + NotificationCenter.default.post(name: Notification.Name("ShowEditInfoSheet"), object: nil) + }, label: { + Label( + "Edit Info", + systemImage: "info.circle" + ) + }) + #endif Button(action: { if let linkedItem = self.viewModel.linkedItem { self.viewModel.setLinkArchived(dataService: self.viewModel.services.dataService, @@ -263,8 +264,10 @@ public struct ShareExtensionView: View { public var body: some View { #if os(iOS) iOSBody + .environmentObject(viewModel.services.dataService) #else macOSBody + .environmentObject(viewModel.services.dataService) #endif } @@ -324,10 +327,18 @@ public struct ShareExtensionView: View { } } - @State var notes = "" @State var labelsSearch = ZWSP @State var isLabelsEntryFocused = false + func save() { + if !viewModel.noteText.isEmpty { + viewModel.saveNote() + } + if let itemID = viewModel.linkedItem?.id { + labelsViewModel.saveItemLabelChanges(itemID: itemID, dataService: viewModel.services.dataService) + } + } + var macOSBody: some View { VStack(alignment: .leading, spacing: 0) { HStack(spacing: 10) { @@ -342,7 +353,7 @@ public struct ShareExtensionView: View { Divider() ZStack(alignment: .topLeading) { - TextEditor(text: $notes) + TextEditor(text: $viewModel.noteText) .frame(maxWidth: .infinity) .font(Font.system(size: 14)) .accentColor(.blue) @@ -350,7 +361,7 @@ public struct ShareExtensionView: View { textView.textContainerInset = NSSize(width: 10, height: 10) } .focused($focusedField, equals: .noteEditor) - if notes.isEmpty { + if viewModel.noteText.isEmpty { Text("Notes") .fontWeight(.light) .font(Font.system(size: 14)) @@ -366,7 +377,7 @@ public struct ShareExtensionView: View { ZStack(alignment: .topLeading) { LabelsEntryView(searchTerm: $labelsSearch, isFocused: $isLabelsEntryFocused, viewModel: labelsViewModel) .frame(maxWidth: .infinity) - .padding(.horizontal, 10) + .padding(.horizontal, 8) .focused($focusedField, equals: .labelEditor) .onHover { isHovered in DispatchQueue.main.async { @@ -377,8 +388,8 @@ public struct ShareExtensionView: View { } } } - if !isLabelsEntryFocused, labelsViewModel.selectedLabels.isEmpty, labelsSearch == ZWSP { - Text("Add Labels") + if labelsViewModel.selectedLabels.isEmpty, labelsSearch == ZWSP { + Text("Type to add labels") .fontWeight(.light) .font(Font.system(size: 14)) .foregroundColor(.black.opacity(0.25)) @@ -395,12 +406,14 @@ public struct ShareExtensionView: View { .padding(.bottom, 15) Spacer() Button(action: { + save() extensionContext?.completeRequest(returningItems: [], completionHandler: nil) }, label: { Text("Dismiss") }) .padding(.bottom, 15) Button(action: { + save() viewModel.handleReadNowAction(extensionContext: extensionContext) }, label: { Text("Read Now") @@ -412,9 +425,14 @@ public struct ShareExtensionView: View { }.frame(maxWidth: .infinity) .background(Color.isDarkMode ? Color.systemBackground : Color.white) .onAppear { - viewModel.savePage(extensionContext: extensionContext) + if let extensionContext = extensionContext { + viewModel.savePage(extensionContext: extensionContext) + } DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { - focusedField = .noteEditor + focusedField = .labelEditor + } + Task { + await labelsViewModel.loadLabels(dataService: viewModel.services.dataService, initiallySelectedLabels: []) } } } diff --git a/apple/OmnivoreKit/Sources/App/Views/Labels/ApplyLabelsView.swift b/apple/OmnivoreKit/Sources/App/Views/Labels/ApplyLabelsView.swift index d1bfbced1..4d5c54d66 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Labels/ApplyLabelsView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Labels/ApplyLabelsView.swift @@ -212,8 +212,11 @@ extension Sequence where Element == LinkedItemLabel { if searchFilter.isEmpty || searchFilter == ZWSP { return map { $0 } // return the identity of the sequence } - let index = searchFilter.index(searchFilter.startIndex, offsetBy: 1) - let trimmed = searchFilter.suffix(from: index).lowercased() - return filter { ($0.name ?? "").lowercased().contains(trimmed) } + if searchFilter.starts(with: ZWSP) { + let index = searchFilter.index(searchFilter.startIndex, offsetBy: 1) + let trimmed = searchFilter.suffix(from: index).lowercased() + return filter { ($0.name ?? "").lowercased().contains(trimmed) } + } + return filter { ($0.name ?? "").lowercased().contains(searchFilter.lowercased()) } } } diff --git a/apple/OmnivoreKit/Sources/App/Views/LabelsEntryView.swift b/apple/OmnivoreKit/Sources/App/Views/LabelsEntryView.swift index 841aa32b7..093ea7a75 100644 --- a/apple/OmnivoreKit/Sources/App/Views/LabelsEntryView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/LabelsEntryView.swift @@ -31,6 +31,11 @@ public struct LabelsEntryView: View { let entries: [Entry] + #if os(macOS) + @State var popoverIndex = -1 + @State var presentPopover = false + #endif + @State private var totalHeight = CGFloat.zero @FocusState private var textFieldFocused: Bool @@ -46,20 +51,28 @@ public struct LabelsEntryView: View { self.entries = Array(viewModel.selectedLabels.map { LabelEntry(label: $0) }) } - func onTextSubmit() { - let index = searchTerm.index(searchTerm.startIndex, offsetBy: 1) - let trimmed = searchTerm.suffix(from: index).lowercased() + func getSearchTermText() -> String { + if searchTerm.starts(with: ZWSP) { + let index = searchTerm.index(searchTerm.startIndex, offsetBy: 1) + let trimmed = searchTerm.suffix(from: index) + return String(trimmed) + } + return searchTerm + } + func onTextSubmit() { + let trimmed = getSearchTermText() if trimmed.count < 1 { return } - if let label = viewModel.labels.first(where: { $0.name?.lowercased() == trimmed }) { + let lowercased = trimmed.lowercased() + if let label = viewModel.labels.first(where: { $0.name?.lowercased() == lowercased }) { if !viewModel.selectedLabels.contains(label) { viewModel.selectedLabels.append(label) } - searchTerm = ZWSP + reset() DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { textFieldFocused = true } @@ -70,7 +83,7 @@ public struct LabelsEntryView: View { color: Gradient.randomColor(str: trimmed, offset: 1), description: nil ) - searchTerm = ZWSP + reset() DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { textFieldFocused = true } @@ -95,17 +108,31 @@ public struct LabelsEntryView: View { #endif } + func reset() { + searchTerm = ZWSP + #if os(macOS) + popoverIndex = -1 + presentPopover = false + #endif + } + var deletableTextField: some View { // Round it up to avoid jitter when typing - let textWidth = max(25.0, Double(Int(textFieldString.size().width + 1))) - let result = TextField("", text: $searchTerm) + let textWidth = max(25.0, Double(Int(textFieldString.size().width + 28))) + var result = AnyView(TextField("", text: $searchTerm) + .id("deletableTextField") .frame(alignment: .topLeading) .frame(height: 25) .frame(width: textWidth) - .padding(5) + .padding(.trailing, 5) + .padding(.vertical, 5) .accentColor(.blue) .font(Font.system(size: 14)) .multilineTextAlignment(.leading) + #if os(macOS) + .textFieldStyle(.plain) + .background(Color.clear) + #endif .onChange(of: searchTerm, perform: { _ in if searchTerm.count >= 64 { searchTerm = String(searchTerm.prefix(64)) @@ -113,59 +140,89 @@ public struct LabelsEntryView: View { if searchTerm.isEmpty { if viewModel.selectedLabels.count > 0 { viewModel.selectedLabels.removeLast() - searchTerm = ZWSP + reset() DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { textFieldFocused = true } } else { - searchTerm = ZWSP + reset() } } }) - .onSubmit { - onTextSubmit() - } - #if os(macOS) - .textFieldStyle(.plain) - .background(Color.clear) - #endif - return result + .onSubmit { + if popoverIndex == -1 || popoverIndex >= partialMatches.count { + onTextSubmit() + } else if popoverIndex >= 0, popoverIndex < partialMatches.count { + let matched = partialMatches[popoverIndex] + viewModel.selectedLabels.append(matched) + reset() + } + } + .submitScope() + ) + + if #available(macOS 14.0, *) { + result = AnyView(result + .onKeyPress(.downArrow) { + popoverIndex = ((popoverIndex + 1) % (partialMatches.count + 1)) + return .handled + } + .onKeyPress(.upArrow) { + popoverIndex -= 1 + return .handled + } + .onKeyPress(.tab) { + popoverIndex = ((popoverIndex + 1) % (partialMatches.count + 1)) + return .handled + }) + } + + return AnyView(result) } -// func onTextDelete() -> Bool { if searchTerm.isEmpty { -// if lastSelected { -// if viewModel.selectedLabels.count > 0 { -// viewModel.selectedLabels.removeLast() -// DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(500)) { -// textFieldFocused = true -// } -// } -// } else { -// lastSelected = true -// } -// return true -// } -// return false -// } - public var body: some View { - // HStack(spacing: 0) { - VStack { - GeometryReader { geometry in - self.generateLabelsContent(in: geometry) - } - }.padding(0) - .frame(height: totalHeight) - .background(Color.extensionPanelBackground) - .cornerRadius(8) - .onAppear { - textFieldFocused = true - } - .onTapGesture { - textFieldFocused = true - } - .transaction { $0.animation = nil } - .onChange(of: textFieldFocused) { self.isFocused = $0 } + Form { + VStack { + GeometryReader { geometry in + self.generateLabelsContent(in: geometry) + } + }.padding(0) + .frame(height: totalHeight) + .background(Color.extensionPanelBackground) + .cornerRadius(8) + .onAppear { + textFieldFocused = true + } + .onTapGesture { + textFieldFocused = true + } + .onChange(of: textFieldFocused) { self.isFocused = $0 } + } + } + + var partialMatches: [LinkedItemLabel] { + viewModel.labels.applySearchFilter(searchTerm) + } + + private var createLabelButton: some View { + let count = partialMatches.count + return Button { + viewModel.createLabel( + dataService: dataService, + name: searchTerm, + color: Gradient.randomColor(str: searchTerm, offset: 1), + description: nil + ) + reset() + } label: { + Text("Create new label") + .padding(6) + } + .background(popoverIndex == count ? Color.blue : Color.clear) + .frame(maxWidth: .infinity, alignment: .leading) + .cornerRadius(4) + .buttonStyle(.borderless) + .cornerRadius(4) } private func generateLabelsContent(in geom: GeometryProxy) -> some View { @@ -206,6 +263,36 @@ public struct LabelsEntryView: View { height = 0 return result }).focused($textFieldFocused) + #if os(macOS) + .onChange(of: searchTerm) { _ in + presentPopover = !searchTerm.isEmpty && searchTerm != ZWSP && partialMatches.count < 14 + if popoverIndex >= partialMatches.count + 1 { + popoverIndex = partialMatches.count + 1 + } + } + .popover(isPresented: $presentPopover, arrowEdge: .top) { + VStack(alignment: .leading, spacing: 4) { + ForEach(Array(partialMatches.enumerated()), id: \.offset) { idx, label in + if let name = label.name { + Button { + reset() + viewModel.selectedLabels.append(label) + } label: { + Text(name) + .padding(6) + .frame(maxWidth: .infinity, alignment: .leading) + } + .background(idx == popoverIndex ? Color.blue : Color.clear) + .frame(maxWidth: .infinity, alignment: .leading) + .cornerRadius(4) + .buttonStyle(.borderless) + .cornerRadius(4) + } + } + createLabelButton + }.padding(4) + } + #endif }.background(viewHeightReader($totalHeight)) } diff --git a/apple/OmnivoreKit/Sources/App/Views/LinkedItemMetadataEditView.swift b/apple/OmnivoreKit/Sources/App/Views/LinkedItemMetadataEditView.swift index b3477a748..39065ef6d 100644 --- a/apple/OmnivoreKit/Sources/App/Views/LinkedItemMetadataEditView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/LinkedItemMetadataEditView.swift @@ -138,7 +138,7 @@ struct LinkedItemMetadataEditView: View { ) } } - .frame(minWidth: 400, minHeight: 600) + .frame(minWidth: 400, minHeight: 400) } #endif } diff --git a/apple/OmnivoreKit/Sources/Views/FeedItem/LibraryItemCard.swift b/apple/OmnivoreKit/Sources/Views/FeedItem/LibraryItemCard.swift index 2034eea85..ae6feb607 100644 --- a/apple/OmnivoreKit/Sources/Views/FeedItem/LibraryItemCard.swift +++ b/apple/OmnivoreKit/Sources/Views/FeedItem/LibraryItemCard.swift @@ -66,7 +66,7 @@ public struct LibraryItemCard: View { labels } - if let note = item.noteText { + if let note = item.noteText, !note.isEmpty { HStack(alignment: .top, spacing: 10) { avatarImage .frame(width: 20, height: 20)