Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 17 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,19 +67,21 @@ The contributor build is named `ZoomIt (Dev).app` (bundle id `com.sysinternals.z

## Default Hotkeys

| Action | Shortcut |
| --- | --- |
| Static zoom | `Control+1` |
| Draw without zoom | `Control+2` |
| Break timer | `Control+3` |
| Live zoom | `Control+4` |
| Record screen | `Control+5` |
| Record region | `Control+Shift+5` |
| Snip region to clipboard | `Control+6` |
| Snip region to file | `Control+Shift+6` |
| OCR region to clipboard | `Control+Option+6` |
| Panorama to clipboard | `Control+8` |
| Panorama to file | `Control+Shift+8` |
| Action | Shortcut |
| ------------------------------------ | ------------------ |
| Static zoom | `Control+1` |
| Draw without zoom | `Control+2` |
| Break timer | `Control+3` |
| Live zoom | `Control+4` |
| Record screen | `Control+5` |
| Record region | `Control+Shift+5` |
| Snip region to clipboard | `Control+6` |
| Snip region to file | `Control+Shift+6` |
| OCR region to clipboard | `Control+Option+6` |
| Panorama to clipboard | `Control+8` |
| Panorama to file | `Control+Shift+8` |
| Capture previous region to clipboard | `Control+9` |
| Capture previous region to file | `Control+Shift+9` |

All global hotkeys are configurable in Settings. While zoomed, use `Option+Up` and `Option+Down` to change zoom level, mouse wheel to zoom or resize tools depending on mode, `Command+S` / `Command+C` to save or copy the viewport, and `Esc` or right click to exit the active overlay mode.

Expand All @@ -97,6 +99,8 @@ Press the snip shortcut (`Control+6`) and drag a rectangle to copy that region o

Press the OCR shortcut (`Control+Option+6`) and drag a rectangle to recognize the text inside it and copy that text to the clipboard. OCR uses Apple's on-device Vision text recognition, runs entirely on-device, and needs no extra permissions. Both shortcuts are configurable on the Snip tab in Settings.

ZoomItMac also remembers the last successful region snip. Use the menu command **Capture Previous Region** or press `Control+9` to repeat that capture without reopening the region selector. Hold `Shift` (`Control+Shift+9`) to save to a file instead of copying.

## Recording

Recording uses ScreenCaptureKit and AVAssetWriter. Static zoom and drawing overlays are captured even when ScreenCaptureKit omits ZoomIt's own windows; live zoom remains excluded from its own capture to avoid feedback. Webcam picture-in-picture stays fixed in the recorded viewport and is composited into overlay recordings instead of being zoomed into the source image.
Expand Down
4 changes: 4 additions & 0 deletions Sources/ZoomItMacCore/App/AppController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ final class AppController: NSObject {
modeCoordinator.handle(.startPanorama(save: false))
}

@objc func capturePreviousRegion() {
modeCoordinator.handle(.snipPreviousRegion(save: false))
}
Comment on lines +55 to +57

@objc func toggleBreakTimer() {
modeCoordinator.handle(.toggleBreakTimer)
}
Expand Down
89 changes: 82 additions & 7 deletions Sources/ZoomItMacCore/Capture/SnipController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,25 @@ enum SnipAction {
/// overlay, and copies or saves the chosen region when the drag is released.
@MainActor
final class SnipController {
private struct PreviousSelection {
let displayID: CGDirectDisplayID
let displaySize: CGSize
let rect: CGRect

func rectForCurrentDisplay(_ display: DisplayDescriptor) -> CGRect {
guard displaySize.width > 0, displaySize.height > 0 else { return rect }

let widthScale = display.frame.width / displaySize.width
let heightScale = display.frame.height / displaySize.height
return CGRect(
x: rect.minX * widthScale,
y: rect.minY * heightScale,
width: rect.width * widthScale,
height: rect.height * heightScale
)
}
Comment on lines +206 to +217
}

private let captureService: ScreenCaptureService
private let displayManager: DisplayManager
private let permissionService: PermissionService
Expand All @@ -208,6 +227,7 @@ final class SnipController {
private var action: SnipAction = .copyImage
private var onFinished: (() -> Void)?
private var cursorLease: CrosshairCursorLease?
private var previousSelection: PreviousSelection?

init(
captureService: ScreenCaptureService,
Expand Down Expand Up @@ -257,6 +277,41 @@ final class SnipController {
}
}

/// Repeats the last successful region snip with no selection UI.
/// Returns false if there is no previous region to reuse.
@discardableResult
func capturePrevious(action: SnipAction, onFinished: @escaping () -> Void) -> Bool {
guard let previousSelection else {
return false
}

self.action = action
self.onFinished = onFinished

guard ScreenRecordingPrompt.ensureGranted(permissionService) else {
finish()
return true
}
guard let display = displayManager.activeDisplay() else {
NSSound.beep()
finish()
return true
}

Task { @MainActor in
do {
let frame = try await captureService.captureDisplay(display)
let rect = previousSelection.rectForCurrentDisplay(frame.display)
_ = self.handleSelection(rect, in: frame)
self.finish()
} catch {
NSSound.beep()
self.finish()
}
}
return true
}

private func show(frame: CapturedFrame) {
capturedFrame = frame

Expand Down Expand Up @@ -299,19 +354,39 @@ final class SnipController {
return
}

_ = handleSelection(rect, in: frame)
finish()
}

@discardableResult
private func handleSelection(_ rect: CGRect, in frame: CapturedFrame) -> Bool {
guard rect.width >= 3, rect.height >= 3 else {
return false
}

let boundedRect = rect.intersection(CGRect(origin: .zero, size: frame.display.frame.size))
guard boundedRect.width >= 3, boundedRect.height >= 3 else {
return false
}

let scale = frame.display.scaleFactor
let pixelRect = CGRect(
x: rect.minX * scale,
y: rect.minY * scale,
width: rect.width * scale,
height: rect.height * scale
x: boundedRect.minX * scale,
y: boundedRect.minY * scale,
width: boundedRect.width * scale,
height: boundedRect.height * scale
).integral

guard let cropped = frame.image.cropping(to: pixelRect) else {
finish()
return
return false
}

previousSelection = PreviousSelection(
displayID: frame.display.id,
displaySize: frame.display.frame.size,
rect: boundedRect
)

switch action {
case .saveImage:
ImageExporter.saveImage(cropped, settings: settingsStore.load())
Expand All @@ -320,7 +395,7 @@ final class SnipController {
case .recognizeText:
OcrService.recognizeAndCopy(cropped)
}
finish()
return true
}

private func closeWindow() {
Expand Down
1 change: 1 addition & 0 deletions Sources/ZoomItMacCore/Core/AppCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ enum AppCommand: Equatable {
case undo
case clear
case snipRegion(save: Bool)
case snipPreviousRegion(save: Bool)
case snipOcr
case startPanorama(save: Bool)
case toggleRecording(region: Bool)
Expand Down
28 changes: 26 additions & 2 deletions Sources/ZoomItMacCore/Core/ModeCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ final class ModeCoordinator {
// Ignore activation and snip hotkeys while a region selection is on
// screen so a second trigger can't stack overlays.
switch command {
case .activateStaticZoom, .activateLiveZoom, .activateDrawWithoutZoom, .snipRegion, .zoomIn, .zoomOutOrExit:
case .activateStaticZoom, .activateLiveZoom, .activateDrawWithoutZoom, .snipRegion, .snipPreviousRegion, .zoomIn, .zoomOutOrExit:
return
default:
break
Expand Down Expand Up @@ -115,6 +115,8 @@ final class ModeCoordinator {
overlayController.requestRedraw()
case .snipRegion(let save):
startSnip(action: save ? .saveImage : .copyImage)
case .snipPreviousRegion(let save):
startSnip(action: save ? .saveImage : .copyImage, reusePreviousRegion: true)
case .snipOcr:
startSnip(action: .recognizeText)
case .toggleRecording(let region):
Expand Down Expand Up @@ -412,18 +414,40 @@ final class ModeCoordinator {
/// Starts a region snip. From idle it captures the screen; while zoomed it
/// selects within the current viewport so ZoomIt's own overlay is reused
/// rather than captured.
private func startSnip(action: SnipAction) {
private func startSnip(action: SnipAction, reusePreviousRegion: Bool = false) {
guard !isSnipping else {
NSSound.beep()
return
}
switch mode {
case .idle:
if reusePreviousRegion {
isSnipping = true
let started = snipController.capturePrevious(action: action) { [weak self] in
self?.isSnipping = false
}
if !started {
isSnipping = false
NSSound.beep()
}
return
Comment on lines +424 to +433
}
isSnipping = true
snipController.begin(action: action) { [weak self] in
self?.isSnipping = false
}
case .staticZoom, .liveZoom, .drawOnly, .typing:
if reusePreviousRegion {
isSnipping = true
let started = overlayController.capturePreviousRegionSnip(action: action) { [weak self] in
self?.isSnipping = false
}
if !started {
isSnipping = false
NSSound.beep()
}
return
}
isSnipping = true
overlayController.beginRegionSnip(action: action) { [weak self] in
self?.isSnipping = false
Expand Down
36 changes: 36 additions & 0 deletions Sources/ZoomItMacCore/Hotkeys/HotkeyService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ final class HotkeyService {
private var snipCopyHotKeyRef: EventHotKeyRef?
private var snipSaveHotKeyRef: EventHotKeyRef?
private var snipOcrHotKeyRef: EventHotKeyRef?
private var snipPreviousHotKeyRef: EventHotKeyRef?
private var snipPreviousSaveHotKeyRef: EventHotKeyRef?
private var recordHotKeyRef: EventHotKeyRef?
private var recordRegionHotKeyRef: EventHotKeyRef?
private var demoTypeHotKeyRef: EventHotKeyRef?
Expand Down Expand Up @@ -131,6 +133,8 @@ final class HotkeyService {
case 13: command = .snipOcr
case 14: command = .startDemoType
case 15: command = .resetDemoType
case 16: command = .snipPreviousRegion(save: false)
case 17: command = .snipPreviousRegion(save: true)
default: return noErr
}

Expand Down Expand Up @@ -220,6 +224,30 @@ final class HotkeyService {
)
}

// Capture Previous Region: reuses the last successful region snip.
// The base shortcut copies; the same shortcut with Shift toggled saves.
if settings.snipPreviousHotKeyCode != 0 {
let snipPreviousModifiers = NSEvent.ModifierFlags(rawValue: settings.snipPreviousHotKeyModifiers)
RegisterEventHotKey(
UInt32(settings.snipPreviousHotKeyCode),
carbonModifiers(from: snipPreviousModifiers),
EventHotKeyID(signature: signature, id: 16),
target,
0,
&snipPreviousHotKeyRef
)

let snipPreviousSaveModifiers = NSEvent.ModifierFlags(rawValue: settings.snipPreviousHotKeyModifiers ^ NSEvent.ModifierFlags.shift.rawValue)
RegisterEventHotKey(
UInt32(settings.snipPreviousHotKeyCode),
carbonModifiers(from: snipPreviousSaveModifiers),
EventHotKeyID(signature: signature, id: 17),
target,
0,
&snipPreviousSaveHotKeyRef
)
}

// Recording: the base shortcut records the whole screen; the same
// shortcut with Shift toggled records a selected region.
let recordModifiers = NSEvent.ModifierFlags(rawValue: settings.recordHotKeyModifiers)
Expand Down Expand Up @@ -322,6 +350,14 @@ final class HotkeyService {
UnregisterEventHotKey(snipOcrHotKeyRef)
}
snipOcrHotKeyRef = nil
if let snipPreviousHotKeyRef {
UnregisterEventHotKey(snipPreviousHotKeyRef)
}
snipPreviousHotKeyRef = nil
if let snipPreviousSaveHotKeyRef {
UnregisterEventHotKey(snipPreviousSaveHotKeyRef)
}
snipPreviousSaveHotKeyRef = nil
if let recordHotKeyRef {
UnregisterEventHotKey(recordHotKeyRef)
}
Expand Down
12 changes: 12 additions & 0 deletions Sources/ZoomItMacCore/Overlay/OverlayWindowController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,18 @@ final class OverlayWindowController {
}
canvasView.beginRegionSnip(action: action, onFinished: onFinished)
}

/// Captures using the last successful viewport-region snip, without showing
/// the drag selection UI. Returns false if no previous region exists.
func capturePreviousRegionSnip(action: SnipAction, onFinished: @escaping () -> Void) -> Bool {
guard let canvasView else {
onFinished()
return false
}
let captured = canvasView.capturePreviousRegionSnip(action: action)
onFinished()
return captured
}
/// The overlay's window number, used to exclude it from live screen capture
/// so the magnified overlay is never captured back into itself.
var overlayWindowNumber: Int? { window?.windowNumber }
Expand Down
Loading