feat(keyboard): add clipboard history and undo pastes

Ship optional keyboard clipboard history with settings, suggestion strip, and paste-permission guidance; let undo roll back clipboard inserts; bump build to 64.
This commit is contained in:
Rocky
2026-08-11 02:49:54 +08:00
parent 3de665d254
commit fd6e0d3e7e
30 changed files with 1478 additions and 118 deletions
+77 -21
View File
@@ -7,12 +7,18 @@ import SwiftUI
import OSGKeyboardShared
struct KeyboardSurfaceRoot: View {
@Environment(\.colorScheme) private var colorScheme
@ObservedObject var state: KeyboardState
@ObservedObject var typing: TypingSessionController
var onInsert: (String) -> Void
var onDeleteBackward: () -> Void
private var palette: ThemePalette {
colorScheme == .dark ? Palette.dark : Palette.light
}
/// Height is deliberately independent of the surface: the voice surface
/// adopts the typing surface's content-driven height and parks the surplus
/// above its action cluster, so switching surfaces never resizes the
@@ -26,29 +32,42 @@ struct KeyboardSurfaceRoot: View {
}
var body: some View {
Group {
switch state.surface {
case .voice:
KeyboardRootView(
state: state,
typing: typing,
onInsert: onInsert
)
case .typing:
TypingRootView(
state: state,
typing: typing,
onInsert: onInsert,
onDeleteBackward: onDeleteBackward
)
case .ai:
AIKeyboardView(
state: state,
typing: typing,
onInsert: onInsert
)
ZStack {
Group {
switch state.surface {
case .voice:
KeyboardRootView(
state: state,
typing: typing,
onInsert: wrappedInsert
)
case .typing:
TypingRootView(
state: state,
typing: typing,
onInsert: wrappedInsert,
onDeleteBackward: wrappedDeleteBackward
)
case .ai:
AIKeyboardView(
state: state,
typing: typing,
onInsert: wrappedInsert
)
}
}
.opacity(state.clipboardOverlay == .none ? 1 : 0)
.allowsHitTesting(state.clipboardOverlay == .none)
// Match every surface's outer chrome so the panel title / X sit in
// the same slot as the logo + clipboard chip (not 4 pt higher).
clipboardOverlayLayer
.padding(.top, TypingSurfaceMetrics.outerPaddingTop)
.padding(.bottom, TypingSurfaceMetrics.outerPaddingBottom)
}
// Overlays are siblings of the surfaces, so the palette has to be
// injected here or they fall back to the environment's dark default.
.environment(\.themePalette, palette)
.animation(.easeInOut(duration: 0.15), value: state.surface)
.onChange(of: state.surface) { _, newSurface in
if newSurface != .typing {
@@ -56,4 +75,41 @@ struct KeyboardSurfaceRoot: View {
}
}
}
private func wrappedInsert(_ text: String) {
if !text.isEmpty {
state.noteUserDidInputText()
}
onInsert(text)
}
private func wrappedDeleteBackward() {
state.noteUserDidInputText()
onDeleteBackward()
}
@ViewBuilder
private var clipboardOverlayLayer: some View {
switch state.clipboardOverlay {
case .none:
EmptyView()
case .enableGuide:
ClipboardEnableGuideView(
onClose: state.dismissClipboardOverlay,
onOpenSettings: {
state.dismissClipboardOverlay()
state.openClipboardSettings()
}
)
case .historyPanel:
ClipboardHistoryPanelView(
history: ClipboardHistoryStore.shared,
onClose: state.dismissClipboardOverlay,
onClear: state.clearClipboardHistory,
onInsert: { state.insertClipboardText($0) },
onDelete: { state.deleteClipboardHistoryEntry($0) },
pastePermissionHint: nil
)
}
}
}
@@ -120,7 +120,16 @@ struct TypingRootView: View {
@ViewBuilder
private var topRegion: some View {
if hasCandidateContent {
// Composing Chinese/English candidates hide the clipboard strip.
candidateBar
} else if let suggestion = state.clipboardSuggestionText, !suggestion.isEmpty {
// Same slot as logo + capsule tabs hide chrome until dismissed.
ClipboardSuggestionBar(
text: suggestion,
onInsert: { state.insertClipboardText(suggestion) },
onDismiss: state.dismissClipboardSuggestion
)
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
} else {
idleTopBar
}