| | |
| | | @State private var selectedTagNames: Set<String> = [] |
| | | @State private var successToast: String? = nil |
| | | @State private var draggedTagNames: [String] = [] // live drag order |
| | | @State private var dragItem: String? = nil // currently dragged tag |
| | | |
| | | // Configurable defaults |
| | | @AppStorage("defaultGroupName") private var defaultGroupName = "Other" |
| | |
| | | HStack(spacing: 0) { |
| | | VStack(alignment: .leading, spacing: 4) { |
| | | Text("Select tags:").font(.caption).foregroundStyle(.secondary).padding(.bottom, 4) |
| | | List { |
| | | Text("Drag to reorder").font(.caption2).foregroundStyle(.tertiary).padding(.bottom, 2) |
| | | ScrollView(.vertical, showsIndicators: false) { |
| | | VStack(spacing: 4) { |
| | | ForEach(sortedTagNames, id: \.self) { tagName in |
| | | selectableTagItem(tagName) |
| | | .onDrag { |
| | | dragItem = tagName |
| | | return NSItemProvider(object: tagName as NSString) |
| | | } |
| | | .onMove { from, to in |
| | | var names = sortedTagNames |
| | | names.move(fromOffsets: from, toOffset: to) |
| | | // Persist the new order |
| | | let fullOrder = TagEditor.orderedTagNames() |
| | | let otherTags = fullOrder.filter { !names.contains($0) } |
| | | draggedTagNames = names + otherTags |
| | | TagEditor.reorderTags(draggedTagNames) |
| | | .onDrop(of: [.text], isTargeted: nil) { providers, _ in |
| | | guard let fromName = dragItem, |
| | | var names = draggedTagNames as [String]?, |
| | | let fromIdx = names.firstIndex(of: fromName), |
| | | let toIdx = names.firstIndex(of: tagName), |
| | | fromIdx != toIdx |
| | | else { return false } |
| | | let toOffset = toIdx > fromIdx ? toIdx + 1 : toIdx |
| | | names.move(fromOffsets: [fromIdx], toOffset: toOffset) |
| | | draggedTagNames = names |
| | | TagEditor.reorderTags(names) |
| | | dragItem = nil |
| | | return true |
| | | } |
| | | } |
| | | .listStyle(.plain) |
| | | .scrollContentBackground(.hidden) |
| | | .frame(width: 170) |
| | | }.padding(12) |
| | | }.frame(width: 155) |
| | | } |
| | | Rectangle().fill(.secondary.opacity(0.12)).frame(width: 1) |
| | | |
| | |
| | | |
| | | private func selectableTagItem(_ tagName: String) -> some View { |
| | | let isSelected = selectedTagNames.contains(tagName) |
| | | return Button { |
| | | if isSelected { selectedTagNames.remove(tagName) } else { selectedTagNames.insert(tagName) } |
| | | } label: { |
| | | HStack(spacing: 6) { |
| | | return HStack(spacing: 6) { |
| | | Circle() |
| | | .fill(isSelected ? Color.accentColor : Color.secondary.opacity(0.3)) |
| | | .frame(width: 16, height: 16) |
| | |
| | | .frame(maxWidth: .infinity, alignment: .leading) |
| | | .background(RoundedRectangle(cornerRadius: 6) |
| | | .fill(Color(nsColor: TagColor.nsColor(for: tagColors[tagName] ?? 0).withAlphaComponent(0.3)))) |
| | | .contentShape(RoundedRectangle(cornerRadius: 6)) |
| | | .onTapGesture { |
| | | if isSelected { selectedTagNames.remove(tagName) } else { selectedTagNames.insert(tagName) } |
| | | } |
| | | .buttonStyle(.plain) |
| | | } |
| | | |
| | | private func confirmAssign() { |