| 1 | import SwiftUI |
| 2 | @MainActor struct EditorToolbar: View { |
| 3 | @Bindable var model: DocsModel |
| 4 | let content: NSAttributedString |
| 5 | @State private var settledSelection = DocumentToolbarSelection() |
| 6 | private var selection: DocumentToolbarSelection { currentSelection ?? settledSelection } |
| 7 | private var currentSelection: DocumentToolbarSelection? { DocumentToolbarSelection(commands: actions) } |
| 8 | var body: some View { |
| 9 | let selection = self.selection |
| 10 | ScrollView(.horizontal) { |
| 11 | HStack(spacing: 12) { |
| 12 | command("docs.editor.toolbar.undo", icon: "arrow.uturn.backward", op: "undo", enabled: selection.canUndo) |
| 13 | command("docs.editor.toolbar.redo", icon: "arrow.uturn.forward", op: "redo", enabled: selection.canRedo) |
| 14 | Divider().frame(height: 24) |
| 15 | Menu { |
| 16 | Button { block("paragraph") } label: { Text("docs.editor.toolbar.block.paragraph", bundle: DocsResources.bundle) } |
| 17 | ForEach(1...3, id: \.self) { level in |
| 18 | Button { block("heading", level: level) } label: { Text(LocalizedStringKey("docs.editor.toolbar.block.heading\(level)"), bundle: DocsResources.bundle) } |
| 19 | } |
| 20 | } label: { Text(selection.styleLabel).frame(minWidth: 100, alignment: .leading) }.accessibilityLabel(Text("native.style", bundle: DocsResources.bundle)) |
| 21 | Menu { |
| 22 | ForEach(["sans", "serif", "mono"], id: \.self) { family in |
| 23 | Button { format("font_" + family, exclusive: ["font_sans", "font_serif", "font_mono"]) } label: { Text(LocalizedStringKey("docs.editor.toolbar.font." + family), bundle: DocsResources.bundle) } |
| 24 | } |
| 25 | } label: { Text(LocalizedStringKey("docs.editor.toolbar.font." + selection.font), bundle: DocsResources.bundle).frame(minWidth: 80, alignment: .leading) }.accessibilityLabel(Text("native.font", bundle: DocsResources.bundle)) |
| 26 | Menu { |
| 27 | ForEach([12, 8, 9, 10, 11, 14, 18, 24, 36, 48, 72], id: \.self) { size in |
| 28 | Button(size.formatted()) { format("size_\(size)", exclusive: [8,9,10,11,12,14,18,24,36,48,72].map { "size_\($0)" } + ["size_sm", "size_lg", "size_xl"]) } |
| 29 | } |
| 30 | } label: { Text(selection.size.formatted()).frame(minWidth: 32) }.accessibilityLabel(Text("native.size", bundle: DocsResources.bundle)) |
| 31 | Divider().frame(height: 24) |
| 32 | mark("docs.editor.toolbar.bold", icon: "bold", name: "strong", active: selection.marks.contains("strong")) |
| 33 | mark("docs.editor.toolbar.italic", icon: "italic", name: "em", active: selection.marks.contains("em")) |
| 34 | mark("docs.editor.toolbar.code", icon: "chevron.left.forwardslash.chevron.right", name: "code", active: selection.marks.contains("code")) |
| 35 | Divider().frame(height: 24) |
| 36 | ForEach(["left", "center", "right"], id: \.self) { alignment in |
| 37 | Button { blockAttribute("align", value: alignment) } label: { Image(systemName: "text.align" + alignment) } |
| 38 | .accessibilityLabel(Text(LocalizedStringKey("docs.editor.toolbar.align." + alignment), bundle: DocsResources.bundle)) |
| 39 | .foregroundStyle(selection.alignment == alignment ? Color.accentColor : Color.primary) |
| 40 | .accessibilityAddTraits(selection.alignment == alignment ? .isSelected : []) |
| 41 | } |
| 42 | Divider().frame(height: 24) |
| 43 | ForEach(["bullet_list", "ordered_list", "blockquote"], id: \.self) { type in |
| 44 | Button { block(type) } label: { Image(systemName: type == "bullet_list" ? "list.bullet" : type == "ordered_list" ? "list.number" : "text.quote") } |
| 45 | .accessibilityLabel(Text(LocalizedStringKey("native." + type), bundle: DocsResources.bundle)) |
| 46 | .foregroundStyle(selection.blocks.contains(type) ? Color.accentColor : Color.primary) |
| 47 | .accessibilityAddTraits(selection.blocks.contains(type) ? .isSelected : []) |
| 48 | } |
| 49 | command("docs.editor.menu.format.indent", icon: "increase.indent", op: "indent", enabled: true) |
| 50 | command("docs.editor.menu.format.outdent", icon: "decrease.indent", op: "outdent", enabled: true) |
| 51 | command("docs.editor.menu.format.clear", icon: "eraser", op: "clear-format", enabled: selection.hasSelection) |
| 52 | }.buttonStyle(.borderless).padding(.horizontal, 12).padding(.vertical, 8) |
| 53 | } |
| 54 | .scrollIndicators(.hidden) |
| 55 | .fixedSize(horizontal: false, vertical: true) |
| 56 | .accessibilityIdentifier("docs.formatting-toolbar") |
| 57 | // Replica work briefly gates commands on every keystroke. Keep that |
| 58 | // synchronization gate out of disabled styling so the bar does not blink. |
| 59 | .disabled(!actions.isAvailable) |
| 60 | .allowsHitTesting(actions.isEnabled) |
| 61 | .onChange(of: currentSelection, initial: true) { _, value in |
| 62 | if let value { settledSelection = value } |
| 63 | } |
| 64 | } |
| 65 | private func command(_ key: String, icon: String, op: String, enabled: Bool) -> some View { |
| 66 | Button { actions.perform(op) } label: { Image(systemName: icon) } |
| 67 | .accessibilityLabel(Text(LocalizedStringKey(key), bundle: DocsResources.bundle)).disabled(!enabled) |
| 68 | } |
| 69 | private func mark(_ key: String, icon: String, name: String, active: Bool) -> some View { |
| 70 | Button { format(name) } label: { Image(systemName: icon) }.accessibilityLabel(Text(LocalizedStringKey(key), bundle: DocsResources.bundle)) |
| 71 | .foregroundStyle(active ? Color.accentColor : Color.primary) |
| 72 | .accessibilityAddTraits(active ? .isSelected : []) |
| 73 | } |
| 74 | private var actions: DocumentCommands { DocumentCommands(model: model, content: content) } |
| 75 | private func format(_ name: String, exclusive: [String] = []) { actions.format(name, exclusive: exclusive) } |
| 76 | private func block(_ type: String, level: Int = 1) { actions.block(type, level: level) } |
| 77 | private func blockAttribute(_ name: String, value: String) { actions.blockAttribute(name, value: value == "left" ? nil : value) } |
| 78 | } |
| 79 | |