vOOice/VoiceInk/Views/Dictionary/WordReplacementView.swift
2025-12-27 20:19:33 +05:45

439 lines
17 KiB
Swift

import SwiftUI
extension String: Identifiable {
public var id: String { self }
}
enum SortMode: String {
case originalAsc = "originalAsc"
case originalDesc = "originalDesc"
case replacementAsc = "replacementAsc"
case replacementDesc = "replacementDesc"
}
enum SortColumn {
case original
case replacement
}
class WordReplacementManager: ObservableObject {
@Published var replacements: [String: String] {
didSet {
UserDefaults.standard.set(replacements, forKey: "wordReplacements")
}
}
init() {
self.replacements = UserDefaults.standard.dictionary(forKey: "wordReplacements") as? [String: String] ?? [:]
}
func addReplacement(original: String, replacement: String) -> (success: Bool, conflictingWord: String?) {
let trimmed = original.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return (false, nil) }
let newTokensPairs = trimmed
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.map { (original: $0, lowercased: $0.lowercased()) }
for existingKey in replacements.keys {
let existingTokens = existingKey
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
.filter { !$0.isEmpty }
for tokenPair in newTokensPairs {
if existingTokens.contains(tokenPair.lowercased) {
return (false, tokenPair.original)
}
}
}
replacements[trimmed] = replacement
return (true, nil)
}
func removeReplacement(original: String) {
replacements.removeValue(forKey: original)
}
func updateReplacement(oldOriginal: String, newOriginal: String, newReplacement: String) -> (success: Bool, conflictingWord: String?) {
let trimmed = newOriginal.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return (false, nil) }
let newTokensPairs = trimmed
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.map { (original: $0, lowercased: $0.lowercased()) }
for existingKey in replacements.keys {
if existingKey == oldOriginal {
continue
}
let existingTokens = existingKey
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
.filter { !$0.isEmpty }
for tokenPair in newTokensPairs {
if existingTokens.contains(tokenPair.lowercased) {
return (false, tokenPair.original)
}
}
}
replacements.removeValue(forKey: oldOriginal)
replacements[trimmed] = newReplacement
return (true, nil)
}
}
struct WordReplacementView: View {
@StateObject private var manager = WordReplacementManager()
@State private var showAlert = false
@State private var editingOriginal: String? = nil
@State private var alertMessage = ""
@State private var sortMode: SortMode = .originalAsc
@State private var originalWord = ""
@State private var replacementWord = ""
@State private var showInfoPopover = false
init() {
if let savedSort = UserDefaults.standard.string(forKey: "wordReplacementSortMode"),
let mode = SortMode(rawValue: savedSort) {
_sortMode = State(initialValue: mode)
}
}
private var sortedReplacements: [(key: String, value: String)] {
let pairs = Array(manager.replacements)
switch sortMode {
case .originalAsc:
return pairs.sorted { $0.key.localizedCaseInsensitiveCompare($1.key) == .orderedAscending }
case .originalDesc:
return pairs.sorted { $0.key.localizedCaseInsensitiveCompare($1.key) == .orderedDescending }
case .replacementAsc:
return pairs.sorted { $0.value.localizedCaseInsensitiveCompare($1.value) == .orderedAscending }
case .replacementDesc:
return pairs.sorted { $0.value.localizedCaseInsensitiveCompare($1.value) == .orderedDescending }
}
}
private func toggleSort(for column: SortColumn) {
switch column {
case .original:
sortMode = (sortMode == .originalAsc) ? .originalDesc : .originalAsc
case .replacement:
sortMode = (sortMode == .replacementAsc) ? .replacementDesc : .replacementAsc
}
UserDefaults.standard.set(sortMode.rawValue, forKey: "wordReplacementSortMode")
}
private var shouldShowAddButton: Bool {
!originalWord.isEmpty || !replacementWord.isEmpty
}
var body: some View {
VStack(alignment: .leading, spacing: 20) {
GroupBox {
Label {
Text("Define word replacements to automatically replace specific words or phrases")
.font(.system(size: 12))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
} icon: {
Button(action: { showInfoPopover.toggle() }) {
Image(systemName: "info.circle.fill")
.foregroundColor(.blue)
}
.buttonStyle(.plain)
.popover(isPresented: $showInfoPopover) {
WordReplacementInfoPopover()
}
}
}
HStack(spacing: 8) {
TextField("Original text (use commas for multiple)", text: $originalWord)
.textFieldStyle(.roundedBorder)
.font(.system(size: 13))
Image(systemName: "arrow.right")
.foregroundColor(.secondary)
.font(.system(size: 10))
.frame(width: 10)
TextField("Replacement text", text: $replacementWord)
.textFieldStyle(.roundedBorder)
.font(.system(size: 13))
.onSubmit { addReplacement() }
if shouldShowAddButton {
Button(action: addReplacement) {
Image(systemName: "plus.circle.fill")
.symbolRenderingMode(.hierarchical)
.foregroundStyle(.blue)
.font(.system(size: 16, weight: .semibold))
}
.buttonStyle(.borderless)
.disabled(originalWord.isEmpty || replacementWord.isEmpty)
.help("Add word replacement")
}
}
.animation(.easeInOut(duration: 0.2), value: shouldShowAddButton)
if !manager.replacements.isEmpty {
VStack(spacing: 0) {
HStack(spacing: 8) {
Button(action: { toggleSort(for: .original) }) {
HStack(spacing: 4) {
Text("Original")
.font(.system(size: 12, weight: .medium))
.foregroundColor(.secondary)
if sortMode == .originalAsc || sortMode == .originalDesc {
Image(systemName: sortMode == .originalAsc ? "chevron.up" : "chevron.down")
.font(.caption)
.foregroundColor(.accentColor)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.plain)
.help("Sort by original")
Image(systemName: "arrow.right")
.foregroundColor(.secondary)
.font(.system(size: 10))
.frame(width: 10)
Button(action: { toggleSort(for: .replacement) }) {
HStack(spacing: 4) {
Text("Replacement")
.font(.system(size: 12, weight: .medium))
.foregroundColor(.secondary)
if sortMode == .replacementAsc || sortMode == .replacementDesc {
Image(systemName: sortMode == .replacementAsc ? "chevron.up" : "chevron.down")
.font(.caption)
.foregroundColor(.accentColor)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.plain)
.help("Sort by replacement")
}
.padding(.horizontal, 4)
.padding(.vertical, 8)
Divider()
ScrollView {
LazyVStack(spacing: 0) {
ForEach(sortedReplacements, id: \.key) { pair in
ReplacementRow(
original: pair.key,
replacement: pair.value,
onDelete: { manager.removeReplacement(original: pair.key) },
onEdit: { editingOriginal = pair.key }
)
if pair.key != sortedReplacements.last?.key {
Divider()
}
}
}
}
.frame(maxHeight: 300)
}
.padding(.top, 4)
}
}
.padding()
.sheet(item: $editingOriginal) { original in
EditReplacementSheet(manager: manager, originalKey: original)
}
.alert("Word Replacement", isPresented: $showAlert) {
Button("OK", role: .cancel) {}
} message: {
Text(alertMessage)
}
}
private func addReplacement() {
let original = originalWord.trimmingCharacters(in: .whitespacesAndNewlines)
let replacement = replacementWord.trimmingCharacters(in: .whitespacesAndNewlines)
let tokens = original
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
guard !tokens.isEmpty && !replacement.isEmpty else { return }
let result = manager.addReplacement(original: original, replacement: replacement)
if result.success {
originalWord = ""
replacementWord = ""
} else {
if let conflictingWord = result.conflictingWord {
alertMessage = "'\(conflictingWord)' already exists in word replacements"
} else {
alertMessage = "This word replacement already exists"
}
showAlert = true
}
}
}
struct WordReplacementInfoPopover: View {
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("How to use Word Replacements")
.font(.headline)
VStack(alignment: .leading, spacing: 8) {
Text("Separate multiple originals with commas:")
.font(.subheadline)
.foregroundColor(.secondary)
Text("Voicing, Voice ink, Voiceing")
.font(.callout)
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(.textBackgroundColor))
.cornerRadius(6)
}
Divider()
Text("Examples")
.font(.subheadline)
.foregroundColor(.secondary)
VStack(spacing: 12) {
HStack(spacing: 8) {
VStack(alignment: .leading, spacing: 4) {
Text("Original:")
.font(.caption)
.foregroundColor(.secondary)
Text("my website link")
.font(.callout)
}
Image(systemName: "arrow.right")
.font(.caption)
.foregroundColor(.secondary)
VStack(alignment: .leading, spacing: 4) {
Text("Replacement:")
.font(.caption)
.foregroundColor(.secondary)
Text("https://tryvoiceink.com")
.font(.callout)
}
}
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(.textBackgroundColor))
.cornerRadius(6)
HStack(spacing: 8) {
VStack(alignment: .leading, spacing: 4) {
Text("Original:")
.font(.caption)
.foregroundColor(.secondary)
Text("Voicing, Voice ink")
.font(.callout)
}
Image(systemName: "arrow.right")
.font(.caption)
.foregroundColor(.secondary)
VStack(alignment: .leading, spacing: 4) {
Text("Replacement:")
.font(.caption)
.foregroundColor(.secondary)
Text("VoiceInk")
.font(.callout)
}
}
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(.textBackgroundColor))
.cornerRadius(6)
}
}
.padding()
.frame(width: 380)
}
}
struct ReplacementRow: View {
let original: String
let replacement: String
let onDelete: () -> Void
let onEdit: () -> Void
@State private var isEditHovered = false
@State private var isDeleteHovered = false
var body: some View {
HStack(spacing: 8) {
Text(original)
.font(.system(size: 13))
.lineLimit(2)
.frame(maxWidth: .infinity, alignment: .leading)
Image(systemName: "arrow.right")
.foregroundColor(.secondary)
.font(.system(size: 10))
.frame(width: 10)
ZStack(alignment: .trailing) {
Text(replacement)
.font(.system(size: 13))
.lineLimit(2)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.trailing, 50)
HStack(spacing: 6) {
Button(action: onEdit) {
Image(systemName: "pencil.circle.fill")
.symbolRenderingMode(.hierarchical)
.foregroundColor(isEditHovered ? .accentColor : .secondary)
.contentTransition(.symbolEffect(.replace))
}
.buttonStyle(.borderless)
.help("Edit replacement")
.onHover { hover in
withAnimation(.easeInOut(duration: 0.2)) {
isEditHovered = hover
}
}
Button(action: onDelete) {
Image(systemName: "xmark.circle.fill")
.symbolRenderingMode(.hierarchical)
.foregroundStyle(isDeleteHovered ? .red : .secondary)
.contentTransition(.symbolEffect(.replace))
}
.buttonStyle(.borderless)
.help("Remove replacement")
.onHover { hover in
withAnimation(.easeInOut(duration: 0.2)) {
isDeleteHovered = hover
}
}
}
}
.frame(maxWidth: .infinity)
}
.padding(.vertical, 8)
.padding(.horizontal, 4)
}
}