mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Label entry improvements for macos
This commit is contained in:
parent
50f1213e93
commit
d33c975ffb
6 changed files with 188 additions and 88 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: [])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -138,7 +138,7 @@ struct LinkedItemMetadataEditView: View {
|
|||
)
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 400, minHeight: 600)
|
||||
.frame(minWidth: 400, minHeight: 400)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue