Compare commits

...
17 Commits
Author SHA1 Message Date
marcoandClaude Opus 4.6 188b952be7 Fix mirrored image export with CGBitmapContext renderer
Draw base CGImage without coordinate flip, apply flip+scale only for
annotations. Save via direct NSBitmapImageRep access instead of
tiffRepresentation to avoid transform issues. Also redirect clipboard
images to saveFolder instead of temp directory.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-30 18:52:49 +02:00
marcoandClaude Opus 4.6 8d6c2213ad Fix arrows not drawing when dragged left or upward
CGRect.maxX/maxY standardize to positive values, collapsing
the arrow endpoint onto the start point for negative width/height.
Use origin + size instead to preserve direction.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-22 07:39:56 +02:00
marcoandClaude Opus 4.6 519706dd41 Fix annotation position shift in editor and export
Use flipped container with Auto Layout for correct coordinate
mapping. Use lockFocusFlipped(true) in renderer so exported
annotations match canvas positions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-22 07:21:05 +02:00
marcoandClaude Opus 4.6 0fce3e6d28 Add Gatekeeper workaround to installation instructions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-02 09:22:52 +02:00
marcoandClaude Opus 4.6 4267650739 Add launch at login option, bump to v1.0.2
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-02 09:08:34 +02:00
marcoandClaude Opus 4.6 052663b356 Remove Logitech Options+ setup section from docs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-01 22:58:14 +02:00
marcoandClaude Opus 4.6 827af0b239 Add README with documentation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-01 22:54:56 +02:00
marcoandClaude Opus 4.6 d878e8e333 Rename app from Snipping Tool to SnipMC
Changes bundle ID, URL scheme, display name, project name,
and all user-facing references.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-01 22:49:38 +02:00
marcoandClaude Opus 4.6 b55947e536 Add clipboard image editing
Open the editor with an image from the clipboard via menu
("Aus Zwischenablage bearbeiten") or URL (snippingtool://clipboard).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-01 18:57:30 +02:00
marcoandClaude Opus 4.6 d50a1d550a Add documentation tab to About window
Two-tab layout: Info (version, creator) and Dokumentation (capture
modes, editor tools, URL schema reference, Logitech Options+ setup
guide, permission hints).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-01 18:52:47 +02:00
marcoandClaude Opus 4.6 e2c3ef7f37 Add snippingtool://editlast URL to edit last capture
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-01 18:48:16 +02:00
marcoandClaude Opus 4.6 7a78176183 Fix flipped image in editor canvas
Use respectFlipped: true when drawing NSImage so it renders correctly
in the flipped NSView coordinate system.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-01 18:42:12 +02:00
marcoandClaude Opus 4.6 8c73c5fbc5 Fix editor integration and add snippingtool://edit URL
- Fix menu button by deferring NSOpenPanel to next run loop
- Add snippingtool://edit URL to open file editor directly
- Add app activation before showing open panel (LSUIElement fix)
- Remove debug logging

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-01 18:25:07 +02:00
marcoandClaude Opus 4.6 78d6809445 Bump version to 1.0.1 (build 2)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-01 17:53:17 +02:00
marcoandClaude Opus 4.6 59b3f8d2a1 Add edit parameter to URL scheme for capture-then-edit workflow
snippingtool://region?edit=true now captures a region screenshot and
immediately opens the editor. Works with all modes: fullscreen, window,
region. Without ?edit=true, behavior is unchanged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-01 17:51:44 +02:00
marcoandClaude Opus 4.6 f6e90047a8 Add About window with creator and version info
Shows app icon, version, "Freeware" badge, creator (Marco Wende —
Wende.IT), and link to wende.it. Accessible via "Über Snipping Tool…"
in the menu bar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-01 17:47:27 +02:00
marcoandClaude Opus 4.6 062285abbf Add image editor with annotation tools
Integrates an editor accessible via "Letzte Aufnahme bearbeiten…" or
"Bild bearbeiten…" in the menu bar. Supports arrow, rectangle, ellipse,
and text annotations with color picker, line width, undo/redo, and
export to file or clipboard.

New files in Sources/Editor/:
- AnnotationItem/Document: data model and state management
- AnnotationCanvasView: NSView with CG rendering and mouse interaction
- AnnotationRenderer: shared rendering for canvas and image export
- EditorToolbarView: SwiftUI toolbar with tool selection and actions
- EditorWindowController: window lifecycle following SettingsWindowController pattern

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-01 17:44:20 +02:00
17 changed files with 1769 additions and 38 deletions
+119
View File
@@ -0,0 +1,119 @@
# SnipMC
**macOS Menu Bar Screenshot Tool mit integriertem Bildeditor**
![macOS](https://img.shields.io/badge/macOS-14.0+-blue)
![Swift](https://img.shields.io/badge/Swift-5-orange)
![License](https://img.shields.io/badge/Lizenz-Freeware-green)
SnipMC ist ein schlankes Screenshot-Tool für macOS, das in der Menüleiste lebt. Es bietet drei Aufnahme-Modi, einen integrierten Bildeditor für Annotationen und ein URL-Schema zur Steuerung über externe Tools wie Logitech Options+, Kurzbefehle oder Stream Deck.
## Features
- **Screenshot-Modi**: Ganzer Bildschirm, Fenster oder frei wählbarer Bereich
- **Bildeditor**: Pfeile, Rechtecke, Ellipsen und Text-Annotationen
- **Flexibler Zugriff**: Letzte Aufnahme bearbeiten, Bild aus Zwischenablage, Datei öffnen
- **URL-Schema** (`snipmc://`): Externe Steuerung per Tastenkürzel, Stream Deck etc.
- **Einstellungen**: Speicherort, Bildformat (PNG/JPG), konfigurierbare Tastenkürzel
- **Menüleisten-App**: Kein Dock-Icon, läuft dezent im Hintergrund
## Installation
1. `SnipMC.dmg` aus dem [aktuellen Release](https://github.com/mwende/SnipMC/releases/latest) herunterladen
2. DMG öffnen und `SnipMC.app` nach `/Programme` ziehen
3. Gatekeeper-Sperre aufheben (da die App nicht mit einem Apple Developer Zertifikat signiert ist):
```bash
xattr -cr /Applications/SnipMC.app
```
4. App starten — das Kamera-Icon erscheint in der Menüleiste
5. Beim ersten Start die Berechtigung **Bildschirmaufnahme** erteilen:
*Systemeinstellungen → Datenschutz & Sicherheit → Bildschirmaufnahme*
## Screenshot-Modi
| Modus | Beschreibung |
|---|---|
| Ganzer Bildschirm | Nimmt den gesamten Bildschirm auf |
| Fenster | Klicke auf ein Fenster, um es aufzunehmen |
| Bereich auswählen | Ziehe einen Rahmen um den gewünschten Bereich |
## Bildeditor
Der integrierte Editor öffnet sich nicht automatisch nach jeder Aufnahme, sondern nur auf expliziten Wunsch — über das Menü, per URL-Schema oder über die Tastenkombination.
### Werkzeuge
| Werkzeug | Beschreibung |
|---|---|
| Pfeil | Linie mit Pfeilspitze zeichnen |
| Rechteck | Rahmen um einen Bereich zeichnen |
| Ellipse | Kreis oder Oval zeichnen |
| Text | Klicke auf eine Stelle und tippe Text ein |
### Tastenkürzel im Editor
| Kürzel | Aktion |
|---|---|
| ⌘Z | Rückgängig |
| ⌘⇧Z | Wiederholen |
| ⌫ / Entf | Ausgewählte Annotation löschen |
| ⌘S | Speichern |
## URL-Schema
SnipMC registriert das URL-Schema `snipmc://` und kann so von externen Tools gesteuert werden.
| URL | Aktion |
|---|---|
| `snipmc://fullscreen` | Vollbild-Screenshot |
| `snipmc://window` | Fenster-Screenshot |
| `snipmc://region` | Bereich-Screenshot |
| `snipmc://region?edit=true` | Bereich aufnehmen + Editor öffnen |
| `snipmc://fullscreen?edit=true` | Vollbild aufnehmen + Editor öffnen |
| `snipmc://edit` | Bild aus Datei im Editor öffnen |
| `snipmc://editlast` | Letzten Screenshot im Editor bearbeiten |
| `snipmc://clipboard` | Bild aus Zwischenablage bearbeiten |
### Einrichtung per macOS Kurzbefehle
1. Kurzbefehle-App öffnen
2. Neuer Kurzbefehl → Aktion **URL öffnen**
3. URL eintragen, z. B.: `snipmc://region`
4. Optional: Tastaturkurzbefehl zuweisen
### Einrichtung per Terminal
```bash
open snipmc://region
open "snipmc://region?edit=true"
open snipmc://editlast
```
## Einstellungen
- **Ausgabe**: Nur speichern, nur Zwischenablage, oder beides
- **Bildformat**: PNG oder JPG
- **Speicherort**: Standard `~/Bilder/Screenshots`, frei wählbar
- **Tastenkürzel**: Für jeden Aufnahme-Modus individuell konfigurierbar
## Systemvoraussetzungen
- macOS 14.0 (Sonoma) oder neuer
- Berechtigung **Bildschirmaufnahme** erforderlich
## Entwicklung
```bash
# Voraussetzung: Xcode + XcodeGen
brew install xcodegen
# Projekt generieren und bauen
xcodegen generate
xcodebuild -project SnipMC.xcodeproj -scheme SnipMC -configuration Release build
```
## Autor
**Marco Wende — [Wende.IT](https://wende.it)**
Freeware — kostenlos nutzbar.
+395
View File
@@ -0,0 +1,395 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
130D45C3838D4526C57BAE83 /* EditorWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35857E1B1439529A1C6932B7 /* EditorWindowController.swift */; };
1BDB0D69E351CA5C221B94EA /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 081CCD02ACD35FC227278E48 /* SettingsView.swift */; };
1D8FC537A5AC329C446C4218 /* SnippingToolApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = E00A19A40DF5025E03B556FD /* SnippingToolApp.swift */; };
24C10B8D3D9AD133DE9920BC /* HotKeyCombo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 275CE51E9539509ACD429507 /* HotKeyCombo.swift */; };
2919075053BD6EBD40F49085 /* KeyCaptureRepresentable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 829BB71510BB50F5C8CDB09B /* KeyCaptureRepresentable.swift */; };
30D358DCC80875B5AE8E7CC9 /* CaptureMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83137FC977A1F59EDC7C3DE3 /* CaptureMode.swift */; };
34615E753CFF584C1561040F /* SettingsWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF14927359614F91B42F70A0 /* SettingsWindowController.swift */; };
449C4A7698D1DA7F8CD8B6BC /* AppCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F9EEA6BED296D9B4406E764 /* AppCoordinator.swift */; };
475DE4CC16A7A1EA786348F0 /* AnnotationItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2048886E3C9DB924A3F618C /* AnnotationItem.swift */; };
4B8591A347CDB31A5866A532 /* AboutWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6120576595AA8DB5C96DD665 /* AboutWindowController.swift */; };
54D578ADAA0EA4B1B4E977BB /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4CE7E1EFC83123C2285157A /* AppDelegate.swift */; };
6FBABC7A889F4B1C0553ECEB /* KeyCodeSymbols.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47883B3A344C0BD1143DDD27 /* KeyCodeSymbols.swift */; };
735EDBCCACD86A435C087FCA /* MenuBarContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7510AE1AF6818D4FE6428E9F /* MenuBarContentView.swift */; };
7CF577EBE926EBE85AD18CFF /* AnnotationDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1DCECF6E7B7AD8486238262 /* AnnotationDocument.swift */; };
7FD8CD2B11ECC27CF23976B1 /* ImageFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61F76602A1109742C19A7F88 /* ImageFormat.swift */; };
8DCD5BBF7C77AD494D4296F4 /* OutputAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56887E8312DA185054D0C9B2 /* OutputAction.swift */; };
AC0CAFD6D28E8B65BDB17976 /* HotKeyRecorderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 415C4A01A46C8F4A35C88ADB /* HotKeyRecorderView.swift */; };
B68ACECC44BB1E5907336177 /* AnnotationRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD3AD2AE94290AF996796300 /* AnnotationRenderer.swift */; };
BE94EAB3F172FA6B73752298 /* EditorToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1E46DCD8FA452BBCA2F0A47 /* EditorToolbarView.swift */; };
CB4219E3C65E1700F5D38149 /* HotKeyManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27FAE65DDB9045BF6E0CE619 /* HotKeyManager.swift */; };
E0DDAD12E91158BF18D77A94 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0122BC6EC2818009EA7F3D53 /* Assets.xcassets */; };
EE6381E9BC6F06BAA563A376 /* AnnotationCanvasView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97BAF28ACE4ACF8932BE7C1C /* AnnotationCanvasView.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
0122BC6EC2818009EA7F3D53 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
081CCD02ACD35FC227278E48 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
275CE51E9539509ACD429507 /* HotKeyCombo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotKeyCombo.swift; sourceTree = "<group>"; };
27FAE65DDB9045BF6E0CE619 /* HotKeyManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotKeyManager.swift; sourceTree = "<group>"; };
35857E1B1439529A1C6932B7 /* EditorWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorWindowController.swift; sourceTree = "<group>"; };
415C4A01A46C8F4A35C88ADB /* HotKeyRecorderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotKeyRecorderView.swift; sourceTree = "<group>"; };
47883B3A344C0BD1143DDD27 /* KeyCodeSymbols.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyCodeSymbols.swift; sourceTree = "<group>"; };
56887E8312DA185054D0C9B2 /* OutputAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutputAction.swift; sourceTree = "<group>"; };
5F9EEA6BED296D9B4406E764 /* AppCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCoordinator.swift; sourceTree = "<group>"; };
6120576595AA8DB5C96DD665 /* AboutWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutWindowController.swift; sourceTree = "<group>"; };
61F76602A1109742C19A7F88 /* ImageFormat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageFormat.swift; sourceTree = "<group>"; };
7510AE1AF6818D4FE6428E9F /* MenuBarContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarContentView.swift; sourceTree = "<group>"; };
829BB71510BB50F5C8CDB09B /* KeyCaptureRepresentable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyCaptureRepresentable.swift; sourceTree = "<group>"; };
83137FC977A1F59EDC7C3DE3 /* CaptureMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaptureMode.swift; sourceTree = "<group>"; };
97BAF28ACE4ACF8932BE7C1C /* AnnotationCanvasView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnotationCanvasView.swift; sourceTree = "<group>"; };
A1DCECF6E7B7AD8486238262 /* AnnotationDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnotationDocument.swift; sourceTree = "<group>"; };
A2048886E3C9DB924A3F618C /* AnnotationItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnotationItem.swift; sourceTree = "<group>"; };
B4CE7E1EFC83123C2285157A /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
DC94AA0BA2B93F0EC38D151B /* SnipMC.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SnipMC.app; sourceTree = BUILT_PRODUCTS_DIR; };
DD3AD2AE94290AF996796300 /* AnnotationRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnotationRenderer.swift; sourceTree = "<group>"; };
DF14927359614F91B42F70A0 /* SettingsWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsWindowController.swift; sourceTree = "<group>"; };
E00A19A40DF5025E03B556FD /* SnippingToolApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnippingToolApp.swift; sourceTree = "<group>"; };
F1E46DCD8FA452BBCA2F0A47 /* EditorToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorToolbarView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXGroup section */
27F119FCBEB53FC15BA69005 = {
isa = PBXGroup;
children = (
B94195006D6A63C034170E68 /* Sources */,
627FB11FD8CF42D3297E06E1 /* Products */,
);
sourceTree = "<group>";
};
627FB11FD8CF42D3297E06E1 /* Products */ = {
isa = PBXGroup;
children = (
DC94AA0BA2B93F0EC38D151B /* SnipMC.app */,
);
name = Products;
sourceTree = "<group>";
};
68AAE9FD7418A399448CE045 /* Editor */ = {
isa = PBXGroup;
children = (
97BAF28ACE4ACF8932BE7C1C /* AnnotationCanvasView.swift */,
A1DCECF6E7B7AD8486238262 /* AnnotationDocument.swift */,
A2048886E3C9DB924A3F618C /* AnnotationItem.swift */,
DD3AD2AE94290AF996796300 /* AnnotationRenderer.swift */,
F1E46DCD8FA452BBCA2F0A47 /* EditorToolbarView.swift */,
35857E1B1439529A1C6932B7 /* EditorWindowController.swift */,
);
path = Editor;
sourceTree = "<group>";
};
B94195006D6A63C034170E68 /* Sources */ = {
isa = PBXGroup;
children = (
6120576595AA8DB5C96DD665 /* AboutWindowController.swift */,
5F9EEA6BED296D9B4406E764 /* AppCoordinator.swift */,
B4CE7E1EFC83123C2285157A /* AppDelegate.swift */,
0122BC6EC2818009EA7F3D53 /* Assets.xcassets */,
83137FC977A1F59EDC7C3DE3 /* CaptureMode.swift */,
275CE51E9539509ACD429507 /* HotKeyCombo.swift */,
27FAE65DDB9045BF6E0CE619 /* HotKeyManager.swift */,
415C4A01A46C8F4A35C88ADB /* HotKeyRecorderView.swift */,
61F76602A1109742C19A7F88 /* ImageFormat.swift */,
829BB71510BB50F5C8CDB09B /* KeyCaptureRepresentable.swift */,
47883B3A344C0BD1143DDD27 /* KeyCodeSymbols.swift */,
7510AE1AF6818D4FE6428E9F /* MenuBarContentView.swift */,
56887E8312DA185054D0C9B2 /* OutputAction.swift */,
081CCD02ACD35FC227278E48 /* SettingsView.swift */,
DF14927359614F91B42F70A0 /* SettingsWindowController.swift */,
E00A19A40DF5025E03B556FD /* SnippingToolApp.swift */,
68AAE9FD7418A399448CE045 /* Editor */,
);
path = Sources;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
EAED69AFD4EF2FC3C57FA1B8 /* SnipMC */ = {
isa = PBXNativeTarget;
buildConfigurationList = 99C7A42A282B29A64B2B7688 /* Build configuration list for PBXNativeTarget "SnipMC" */;
buildPhases = (
117C9BBC9123725FE6AD71C0 /* Sources */,
C24E14B384D8A0D263EED133 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = SnipMC;
packageProductDependencies = (
);
productName = SnipMC;
productReference = DC94AA0BA2B93F0EC38D151B /* SnipMC.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
E5D0ACF0D9F5E10B2B3D9BB2 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1430;
TargetAttributes = {
};
};
buildConfigurationList = 7F1515B2EDB59F08F4421C1E /* Build configuration list for PBXProject "SnipMC" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
Base,
en,
);
mainGroup = 27F119FCBEB53FC15BA69005;
minimizedProjectReferenceProxies = 1;
preferredProjectObjectVersion = 77;
productRefGroup = 627FB11FD8CF42D3297E06E1 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
EAED69AFD4EF2FC3C57FA1B8 /* SnipMC */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
C24E14B384D8A0D263EED133 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
E0DDAD12E91158BF18D77A94 /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
117C9BBC9123725FE6AD71C0 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
4B8591A347CDB31A5866A532 /* AboutWindowController.swift in Sources */,
EE6381E9BC6F06BAA563A376 /* AnnotationCanvasView.swift in Sources */,
7CF577EBE926EBE85AD18CFF /* AnnotationDocument.swift in Sources */,
475DE4CC16A7A1EA786348F0 /* AnnotationItem.swift in Sources */,
B68ACECC44BB1E5907336177 /* AnnotationRenderer.swift in Sources */,
449C4A7698D1DA7F8CD8B6BC /* AppCoordinator.swift in Sources */,
54D578ADAA0EA4B1B4E977BB /* AppDelegate.swift in Sources */,
30D358DCC80875B5AE8E7CC9 /* CaptureMode.swift in Sources */,
BE94EAB3F172FA6B73752298 /* EditorToolbarView.swift in Sources */,
130D45C3838D4526C57BAE83 /* EditorWindowController.swift in Sources */,
24C10B8D3D9AD133DE9920BC /* HotKeyCombo.swift in Sources */,
CB4219E3C65E1700F5D38149 /* HotKeyManager.swift in Sources */,
AC0CAFD6D28E8B65BDB17976 /* HotKeyRecorderView.swift in Sources */,
7FD8CD2B11ECC27CF23976B1 /* ImageFormat.swift in Sources */,
2919075053BD6EBD40F49085 /* KeyCaptureRepresentable.swift in Sources */,
6FBABC7A889F4B1C0553ECEB /* KeyCodeSymbols.swift in Sources */,
735EDBCCACD86A435C087FCA /* MenuBarContentView.swift in Sources */,
8DCD5BBF7C77AD494D4296F4 /* OutputAction.swift in Sources */,
1BDB0D69E351CA5C221B94EA /* SettingsView.swift in Sources */,
34615E753CFF584C1561040F /* SettingsWindowController.swift in Sources */,
1D8FC537A5AC329C446C4218 /* SnippingToolApp.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
A8AD90B7DF485B4FE48C4DA2 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"$(inherited)",
"DEBUG=1",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = macosx;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
};
name = Debug;
};
AAF2F2D5D59F12E7F040E326 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_IDENTITY = "-";
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 3;
ENABLE_HARDENED_RUNTIME = NO;
INFOPLIST_FILE = Generated/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.0.2;
PRODUCT_BUNDLE_IDENTIFIER = it.wende.snipmc;
PRODUCT_NAME = SnipMC;
SDKROOT = macosx;
SWIFT_VERSION = 5.0;
};
name = Release;
};
BF982956FBC79F78D1D42A71 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = 5.0;
};
name = Release;
};
EB9AB62157886891881D482F /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_IDENTITY = "-";
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 3;
ENABLE_HARDENED_RUNTIME = NO;
INFOPLIST_FILE = Generated/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.0.2;
PRODUCT_BUNDLE_IDENTIFIER = it.wende.snipmc;
PRODUCT_NAME = SnipMC;
SDKROOT = macosx;
SWIFT_VERSION = 5.0;
};
name = Debug;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
7F1515B2EDB59F08F4421C1E /* Build configuration list for PBXProject "SnipMC" */ = {
isa = XCConfigurationList;
buildConfigurations = (
A8AD90B7DF485B4FE48C4DA2 /* Debug */,
BF982956FBC79F78D1D42A71 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
99C7A42A282B29A64B2B7688 /* Build configuration list for PBXNativeTarget "SnipMC" */ = {
isa = XCConfigurationList;
buildConfigurations = (
EB9AB62157886891881D482F /* Debug */,
AAF2F2D5D59F12E7F040E326 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
/* End XCConfigurationList section */
};
rootObject = E5D0ACF0D9F5E10B2B3D9BB2 /* Project object */;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -0,0 +1,91 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1430"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
runPostActionsOnFailure = "NO">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "EAED69AFD4EF2FC3C57FA1B8"
BuildableName = "SnipMC.app"
BlueprintName = "SnipMC"
ReferencedContainer = "container:SnipMC.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
onlyGenerateCoverageForSpecifiedTargets = "NO">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "EAED69AFD4EF2FC3C57FA1B8"
BuildableName = "SnipMC.app"
BlueprintName = "SnipMC"
ReferencedContainer = "container:SnipMC.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "EAED69AFD4EF2FC3C57FA1B8"
BuildableName = "SnipMC.app"
BlueprintName = "SnipMC"
ReferencedContainer = "container:SnipMC.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "EAED69AFD4EF2FC3C57FA1B8"
BuildableName = "SnipMC.app"
BlueprintName = "SnipMC"
ReferencedContainer = "container:SnipMC.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
+168
View File
@@ -0,0 +1,168 @@
import AppKit
import SwiftUI
final class AboutWindowController: NSWindowController, NSWindowDelegate {
static let shared = AboutWindowController()
private convenience init() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 480, height: 520),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
window.title = "SnipMC"
window.isReleasedWhenClosed = false
window.center()
self.init(window: window)
window.delegate = self
}
func show() {
window?.contentView = NSHostingView(rootView: AboutAndHelpView())
NSApplication.shared.setActivationPolicy(.regular)
NSApplication.shared.activate(ignoringOtherApps: true)
window?.makeKeyAndOrderFront(nil)
}
func windowWillClose(_ notification: Notification) {
let otherVisible = NSApplication.shared.windows.filter { $0.isVisible && $0 != window }
if otherVisible.isEmpty {
NSApplication.shared.setActivationPolicy(.accessory)
}
}
}
struct AboutAndHelpView: View {
@State private var selectedTab = 0
var body: some View {
TabView(selection: $selectedTab) {
AboutTab()
.tabItem { Label("Info", systemImage: "info.circle") }
.tag(0)
HelpTab()
.tabItem { Label("Dokumentation", systemImage: "book") }
.tag(1)
}
.frame(width: 480, height: 500)
}
}
struct AboutTab: View {
private let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0"
private let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1"
var body: some View {
VStack(spacing: 16) {
Spacer()
if let appIcon = NSImage(named: NSImage.applicationIconName) {
Image(nsImage: appIcon)
.resizable()
.frame(width: 80, height: 80)
}
Text("SnipMC")
.font(.title.bold())
Text("Version \(version) (\(build))")
.foregroundStyle(.secondary)
.font(.callout)
Text("Freeware")
.font(.callout)
.padding(.horizontal, 12)
.padding(.vertical, 4)
.background(.quaternary)
.clipShape(Capsule())
VStack(spacing: 4) {
Text("Marco Wende \u{2014} Wende.IT")
.font(.body)
Link("wende.it", destination: URL(string: "https://wende.it")!)
.font(.body)
}
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
struct HelpTab: View {
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
helpSection("Aufnahme-Modi") {
helpRow("Ganzer Bildschirm", "Nimmt den gesamten Bildschirm auf")
helpRow("Fenster", "Klicke auf ein Fenster, um es aufzunehmen")
helpRow("Bereich", "Ziehe einen Rahmen um den gew\u{00fc}nschten Bereich")
}
helpSection("Bildeditor") {
helpRow("Pfeil", "Linie mit Pfeilspitze zeichnen")
helpRow("Rechteck", "Rahmen um einen Bereich zeichnen")
helpRow("Ellipse", "Kreis oder Oval zeichnen")
helpRow("Text", "Klicke auf eine Stelle und tippe Text ein")
helpRow("\u{2318}Z / \u{2318}\u{21e7}Z", "R\u{00fc}ckg\u{00e4}ngig / Wiederholen")
helpRow("\u{232b} / Entf", "Ausgew\u{00e4}hlte Annotation l\u{00f6}schen")
}
helpSection("URL-Schema") {
Text("Zum Ausl\u{00f6}sen per Logitech Options+, Kurzb efehle, Stream Deck o.\u{202f}\u{00e4}.")
.font(.callout)
.foregroundStyle(.secondary)
.padding(.bottom, 4)
urlRow("snipmc://fullscreen", "Vollbild-Screenshot")
urlRow("snipmc://window", "Fenster-Screenshot")
urlRow("snipmc://region", "Bereich-Screenshot")
urlRow("snipmc://region?edit=true", "Bereich + Editor")
urlRow("snipmc://fullscreen?edit=true", "Vollbild + Editor")
urlRow("snipmc://edit", "Bild aus Datei bearbeiten")
urlRow("snipmc://editlast", "Letzten Screenshot bearbeiten")
urlRow("snipmc://clipboard", "Zwischenablage bearbeiten")
}
helpSection("Hinweise") {
VStack(alignment: .leading, spacing: 4) {
Text("\u{2022} Berechtigung Bildschirmaufnahme erforderlich (Systemeinstellungen \u{2192} Datenschutz & Sicherheit)")
Text("\u{2022} Speicherort: ~/Bilder/Screenshots (\u{00e4}nderbar in Einstellungen)")
Text("\u{2022} Tastenk\u{00fc}rzel frei konfigurierbar in den Einstellungen")
}
.font(.callout)
}
}
.padding(20)
}
}
private func helpSection(_ title: String, @ViewBuilder content: () -> some View) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(title)
.font(.headline)
content()
}
}
private func helpRow(_ label: String, _ description: String) -> some View {
HStack(alignment: .top, spacing: 8) {
Text(label)
.fontWeight(.medium)
.frame(width: 120, alignment: .leading)
Text(description)
.foregroundStyle(.secondary)
}
.font(.callout)
}
private func urlRow(_ url: String, _ description: String) -> some View {
HStack(alignment: .top, spacing: 8) {
Text(url)
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
.frame(width: 260, alignment: .leading)
Text(description)
.font(.callout)
.foregroundStyle(.secondary)
}
}
}
+137 -22
View File
@@ -36,6 +36,9 @@ final class AppCoordinator: ObservableObject {
didSet { persistAndRegister(mode: .region, combo: hotKeyRegion) }
}
@Published var lastCapturedURL: URL?
private var editorController: EditorWindowController?
init() {
let defaults = UserDefaults.standard
@@ -94,42 +97,62 @@ final class AppCoordinator: ObservableObject {
// MARK: - External triggers (URL scheme)
/// Handles `snippingtool://<mode>` URLs so external tools (Logitech
/// Options, Shortcuts, Stream Deck, `open` from the command line, …)
/// can trigger a specific capture mode instead of just launching the app.
/// Accepts the mode either as the host (`snippingtool://region`) or as
/// a query item (`snippingtool://capture?mode=region`).
func handle(url: URL) {
guard url.scheme?.caseInsensitiveCompare("snippingtool") == .orderedSame else { return }
guard url.scheme?.caseInsensitiveCompare("snipmc") == .orderedSame else { return }
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
let queryItems = components?.queryItems ?? []
let modeString = url.host?.lowercased()
?? URLComponents(url: url, resolvingAgainstBaseURL: false)?
.queryItems?
.first(where: { $0.name.caseInsensitiveCompare("mode") == .orderedSame })?
.value?
.lowercased()
?? queryItems.first(where: { $0.name.caseInsensitiveCompare("mode") == .orderedSame })?
.value?.lowercased()
let wantsEditor = queryItems.first(where: { $0.name.caseInsensitiveCompare("edit") == .orderedSame })?
.value?.lowercased() ?? "false"
let openEditor = ["true", "1", "yes"].contains(wantsEditor)
if modeString == "edit" {
openEditorForFile()
return
}
if modeString == "editlast" || modeString == "edit-last" || modeString == "lastedit" {
openEditorForLastCapture()
return
}
if modeString == "paste" || modeString == "clipboard" || modeString == "editclipboard" {
openEditorFromClipboard()
return
}
let mode: CaptureMode?
switch modeString {
case "fullscreen", "full", "full-screen", "screen":
capture(mode: .fullScreen)
mode = .fullScreen
case "window":
capture(mode: .window)
mode = .window
case "region", "area", "selection":
capture(mode: .region)
mode = .region
default:
NSLog("SnippingTool: unrecognized URL trigger \(url.absoluteString)")
mode = nil
NSLog("SnipMC: unrecognized URL trigger \(url.absoluteString)")
}
if let mode {
capture(mode: mode, openEditor: openEditor)
}
}
// MARK: - Capture
func capture(mode: CaptureMode) {
func capture(mode: CaptureMode, openEditor: Bool = false) {
let saves = outputAction.savesToFile
let copies = outputAction.copiesToClipboard
let ext = imageFormat.fileExtension
let targetURL: URL
if saves {
if saves || openEditor {
try? FileManager.default.createDirectory(at: saveFolder, withIntermediateDirectories: true)
targetURL = saveFolder.appendingPathComponent(Self.filename(ext: ext))
} else {
@@ -145,20 +168,24 @@ final class AppCoordinator: ObservableObject {
process.arguments = arguments
process.terminationHandler = { [weak self] _ in
DispatchQueue.main.async {
self?.handleCaptured(at: targetURL, saves: saves, copies: copies)
self?.handleCaptured(at: targetURL, saves: saves, copies: copies, openEditor: openEditor)
}
}
do {
try process.run()
} catch {
NSLog("SnippingTool: failed to launch screencapture: \(error)")
NSLog("SnipMC: failed to launch screencapture: \(error)")
}
}
private func handleCaptured(at url: URL, saves: Bool, copies: Bool) {
guard FileManager.default.fileExists(atPath: url.path) else {
// User cancelled the interactive capture (Esc).
private func handleCaptured(at url: URL, saves: Bool, copies: Bool, openEditor: Bool = false) {
guard FileManager.default.fileExists(atPath: url.path) else { return }
lastCapturedURL = url
if openEditor, let image = NSImage(contentsOf: url) {
self.openEditor(image: image, sourceURL: url)
return
}
@@ -173,6 +200,94 @@ final class AppCoordinator: ObservableObject {
}
}
// MARK: - Editor
func openEditorForLastCapture() {
guard let url = lastCapturedURL,
FileManager.default.fileExists(atPath: url.path),
let image = NSImage(contentsOf: url) else { return }
DispatchQueue.main.async {
self.openEditor(image: image, sourceURL: url)
}
}
func openEditorFromClipboard() {
guard let pasteboard = NSPasteboard.general.readObjects(forClasses: [NSImage.self], options: nil),
let image = pasteboard.first as? NSImage else { return }
let tempURL = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent(Self.filename(ext: imageFormat.fileExtension))
DispatchQueue.main.async {
self.openEditor(image: image, sourceURL: tempURL)
}
}
func openEditorForFile() {
DispatchQueue.main.async {
NSApplication.shared.setActivationPolicy(.regular)
NSApplication.shared.activate(ignoringOtherApps: true)
let panel = NSOpenPanel()
panel.allowedContentTypes = [.png, .jpeg, .tiff, .bmp]
panel.canChooseDirectories = false
panel.allowsMultipleSelection = false
if panel.runModal() == .OK, let url = panel.url,
let image = NSImage(contentsOf: url) {
self.openEditor(image: image, sourceURL: url)
} else {
NSApplication.shared.setActivationPolicy(.accessory)
}
}
}
private func openEditor(image: NSImage, sourceURL: URL) {
let controller = EditorWindowController(image: image, sourceURL: sourceURL) { [weak self] annotatedImage in
guard let self, let annotatedImage else { return }
let targetURL: URL
if sourceURL.path.hasPrefix(NSTemporaryDirectory()) {
try? FileManager.default.createDirectory(at: saveFolder, withIntermediateDirectories: true)
targetURL = saveFolder.appendingPathComponent(Self.filename(ext: imageFormat.fileExtension))
} else {
targetURL = sourceURL
}
self.saveAnnotatedImage(annotatedImage, to: targetURL)
}
editorController = controller
controller.showEditor()
}
private func saveAnnotatedImage(_ image: NSImage, to url: URL) {
let bitmap: NSBitmapImageRep
if let rep = image.representations.first as? NSBitmapImageRep {
bitmap = rep
} else if let cgRef = image.cgImage(forProposedRect: nil, context: nil, hints: nil) {
bitmap = NSBitmapImageRep(cgImage: cgRef)
} else {
NSLog("SnipMC: could not create bitmap for saving")
return
}
let fileType: NSBitmapImageRep.FileType = imageFormat == .jpg ? .jpeg : .png
let properties: [NSBitmapImageRep.PropertyKey: Any] = fileType == .jpeg
? [.compressionFactor: 0.9] : [:]
guard let data = bitmap.representation(using: fileType, properties: properties) else {
NSLog("SnipMC: could not encode image as \(imageFormat.rawValue)")
return
}
do {
try data.write(to: url, options: .atomic)
NSLog("SnipMC: saved to \(url.path)")
} catch {
NSLog("SnipMC: failed to save annotated image: \(error)")
}
if outputAction.copiesToClipboard {
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.writeObjects([image])
}
}
private static func filename(ext: String) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd 'um' HH.mm.ss"
+311
View File
@@ -0,0 +1,311 @@
import AppKit
import Foundation
final class AnnotationCanvasView: NSView {
var baseImage: NSImage? { didSet { needsDisplay = true } }
var document: AnnotationDocument? { didSet { needsDisplay = true } }
var onDocumentChanged: (() -> Void)?
private enum Interaction {
case idle
case creating(AnnotationKind, startPoint: CGPoint)
case dragging(UUID, lastPoint: CGPoint)
}
private var interaction: Interaction = .idle
private var inProgressItem: AnnotationItem?
private var textField: NSTextField?
override var isFlipped: Bool { true }
override var acceptsFirstResponder: Bool { true }
// MARK: - Coordinate transform
private var imageRect: CGRect {
guard let image = baseImage else { return bounds }
let imageSize = image.size
let scale = min(bounds.width / imageSize.width, bounds.height / imageSize.height)
let scaledSize = CGSize(width: imageSize.width * scale, height: imageSize.height * scale)
return CGRect(
x: (bounds.width - scaledSize.width) / 2,
y: (bounds.height - scaledSize.height) / 2,
width: scaledSize.width,
height: scaledSize.height
)
}
private var viewToImageScale: CGFloat {
guard let image = baseImage else { return 1 }
return image.size.width / imageRect.width
}
private func viewToImage(_ point: CGPoint) -> CGPoint {
let rect = imageRect
let scale = viewToImageScale
return CGPoint(
x: (point.x - rect.origin.x) * scale,
y: (point.y - rect.origin.y) * scale
)
}
private func imageToView(_ point: CGPoint) -> CGPoint {
let rect = imageRect
let scale = viewToImageScale
return CGPoint(
x: point.x / scale + rect.origin.x,
y: point.y / scale + rect.origin.y
)
}
private func imageRectToView(_ r: CGRect) -> CGRect {
let origin = imageToView(r.origin)
let scale = viewToImageScale
return CGRect(x: origin.x, y: origin.y,
width: r.width / scale, height: r.height / scale)
}
// MARK: - Drawing
override func draw(_ dirtyRect: NSRect) {
guard let context = NSGraphicsContext.current?.cgContext else { return }
let bgColor: CGColor = NSColor.windowBackgroundColor.cgColor
context.setFillColor(bgColor)
context.fill(bounds)
guard let image = baseImage else { return }
let imgRect = imageRect
NSGraphicsContext.current?.imageInterpolation = .high
image.draw(in: imgRect, from: .zero, operation: .sourceOver, fraction: 1, respectFlipped: true, hints: nil)
context.saveGState()
context.translateBy(x: imgRect.origin.x, y: imgRect.origin.y)
let scale = 1.0 / viewToImageScale
context.scaleBy(x: scale, y: scale)
guard let doc = document else {
context.restoreGState()
return
}
for item in doc.items {
AnnotationRenderer.draw(item, in: context)
if item.id == doc.selectedItemID {
drawSelectionIndicator(for: item, in: context)
}
}
if let inProgress = inProgressItem {
AnnotationRenderer.draw(inProgress, in: context)
}
context.restoreGState()
}
private func drawSelectionIndicator(for item: AnnotationItem, in context: CGContext) {
let handleRect: CGRect
if item.kind == .arrow {
let start = item.rect.origin
let end = CGPoint(x: item.rect.origin.x + item.rect.size.width,
y: item.rect.origin.y + item.rect.size.height)
handleRect = CGRect(x: min(start.x, end.x), y: min(start.y, end.y),
width: abs(end.x - start.x), height: abs(end.y - start.y))
} else if item.kind == .text {
guard let text = item.text, !text.isEmpty else { return }
let font = NSFont.systemFont(ofSize: item.fontSize, weight: .semibold)
let size = (text as NSString).size(withAttributes: [.font: font])
handleRect = CGRect(origin: item.rect.origin, size: size)
} else {
handleRect = item.rect
}
context.saveGState()
let dashPattern: [CGFloat] = [6, 4]
context.setLineDash(phase: 0, lengths: dashPattern)
context.setStrokeColor(NSColor.controlAccentColor.cgColor)
context.setLineWidth(1.5)
context.stroke(handleRect.insetBy(dx: -4, dy: -4))
context.restoreGState()
}
// MARK: - Mouse events
override func mouseDown(with event: NSEvent) {
commitTextField()
window?.makeFirstResponder(self)
let viewPoint = convert(event.locationInWindow, from: nil)
let imgPoint = viewToImage(viewPoint)
guard let doc = document else { return }
// Hit-test existing items (reverse order = topmost first)
for item in doc.items.reversed() {
if AnnotationRenderer.hitTest(point: imgPoint, item: item) {
doc.selectedItemID = item.id
interaction = .dragging(item.id, lastPoint: imgPoint)
needsDisplay = true
return
}
}
doc.selectedItemID = nil
interaction = .creating(doc.currentTool, startPoint: imgPoint)
needsDisplay = true
}
override func mouseDragged(with event: NSEvent) {
let viewPoint = convert(event.locationInWindow, from: nil)
let imgPoint = viewToImage(viewPoint)
guard let doc = document else { return }
switch interaction {
case .creating(let kind, let start):
if kind == .text { return }
let rect = CGRect(x: start.x, y: start.y,
width: imgPoint.x - start.x,
height: imgPoint.y - start.y)
inProgressItem = AnnotationItem(
kind: kind,
rect: rect,
color: doc.currentColor,
lineWidth: doc.currentLineWidth
)
needsDisplay = true
case .dragging(let id, let lastPoint):
let delta = CGSize(width: imgPoint.x - lastPoint.x,
height: imgPoint.y - lastPoint.y)
doc.moveSelected(by: delta)
interaction = .dragging(id, lastPoint: imgPoint)
needsDisplay = true
case .idle:
break
}
}
override func mouseUp(with event: NSEvent) {
let viewPoint = convert(event.locationInWindow, from: nil)
let imgPoint = viewToImage(viewPoint)
guard let doc = document else { return }
switch interaction {
case .creating(let kind, let start):
if kind == .text {
showTextField(at: viewPoint, imagePoint: imgPoint)
} else if let item = inProgressItem {
let dx = abs(item.rect.width)
let dy = abs(item.rect.height)
if dx > 4 || dy > 4 {
let normalized = normalizedRect(from: start, to: imgPoint, kind: kind)
var finalItem = item
finalItem.rect = normalized
doc.addItem(finalItem)
onDocumentChanged?()
}
}
inProgressItem = nil
case .dragging(_, _):
doc.pushUndo()
onDocumentChanged?()
case .idle:
break
}
interaction = .idle
needsDisplay = true
}
private func normalizedRect(from start: CGPoint, to end: CGPoint, kind: AnnotationKind) -> CGRect {
if kind == .arrow {
return CGRect(x: start.x, y: start.y,
width: end.x - start.x, height: end.y - start.y)
}
return CGRect(
x: min(start.x, end.x), y: min(start.y, end.y),
width: abs(end.x - start.x), height: abs(end.y - start.y)
)
}
// MARK: - Text editing
private func showTextField(at viewPoint: CGPoint, imagePoint: CGPoint) {
let field = NSTextField(frame: NSRect(x: viewPoint.x, y: viewPoint.y, width: 200, height: 30))
field.font = .systemFont(ofSize: 16, weight: .semibold)
field.textColor = document?.currentColor ?? .systemRed
field.backgroundColor = .white.withAlphaComponent(0.9)
field.isBordered = true
field.focusRingType = .exterior
field.placeholderString = "Text eingeben…"
field.target = self
field.action = #selector(textFieldCommitted(_:))
field.tag = Int(imagePoint.x) // store image coordinates in tag
addSubview(field)
window?.makeFirstResponder(field)
// Store image Y in field's identifier
field.identifier = NSUserInterfaceItemIdentifier("\(imagePoint.x),\(imagePoint.y)")
textField = field
}
@objc private func textFieldCommitted(_ sender: NSTextField) {
commitTextField()
}
private func commitTextField() {
guard let field = textField, let doc = document else { return }
let text = field.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
if !text.isEmpty, let identifier = field.identifier?.rawValue {
let parts = identifier.split(separator: ",")
if parts.count == 2,
let x = Double(parts[0]),
let y = Double(parts[1]) {
let item = AnnotationItem(
kind: .text,
rect: CGRect(x: x, y: y, width: 0, height: 0),
color: doc.currentColor,
lineWidth: doc.currentLineWidth,
text: text,
fontSize: 24 * viewToImageScale
)
doc.addItem(item)
onDocumentChanged?()
needsDisplay = true
}
}
field.removeFromSuperview()
textField = nil
}
// MARK: - Keyboard
override func keyDown(with event: NSEvent) {
if event.modifierFlags.contains(.command) {
if event.charactersIgnoringModifiers == "z" {
if event.modifierFlags.contains(.shift) {
document?.redo()
} else {
document?.undo()
}
needsDisplay = true
onDocumentChanged?()
return
}
}
if event.keyCode == 51 || event.keyCode == 117 { // Backspace or Delete
document?.deleteSelected()
needsDisplay = true
onDocumentChanged?()
return
}
super.keyDown(with: event)
}
}
+56
View File
@@ -0,0 +1,56 @@
import AppKit
import Foundation
@Observable
final class AnnotationDocument {
var items: [AnnotationItem] = []
var selectedItemID: UUID?
var currentTool: AnnotationKind = .arrow
var currentColor: NSColor = .systemRed
var currentLineWidth: CGFloat = 3
private var undoStack: [[AnnotationItem]] = []
private var redoStack: [[AnnotationItem]] = []
var canUndo: Bool { !undoStack.isEmpty }
var canRedo: Bool { !redoStack.isEmpty }
func pushUndo() {
undoStack.append(items)
redoStack.removeAll()
}
func undo() {
guard let previous = undoStack.popLast() else { return }
redoStack.append(items)
items = previous
selectedItemID = nil
}
func redo() {
guard let next = redoStack.popLast() else { return }
undoStack.append(items)
items = next
selectedItemID = nil
}
func addItem(_ item: AnnotationItem) {
pushUndo()
items.append(item)
selectedItemID = item.id
}
func deleteSelected() {
guard let id = selectedItemID else { return }
pushUndo()
items.removeAll { $0.id == id }
selectedItemID = nil
}
func moveSelected(by delta: CGSize) {
guard let id = selectedItemID,
let index = items.firstIndex(where: { $0.id == id }) else { return }
items[index].rect.origin.x += delta.width
items[index].rect.origin.y += delta.height
}
}
+36
View File
@@ -0,0 +1,36 @@
import AppKit
import Foundation
enum AnnotationKind: String, CaseIterable, Codable {
case arrow
case rectangle
case ellipse
case text
}
struct AnnotationItem: Identifiable {
let id: UUID
var kind: AnnotationKind
var rect: CGRect
var color: NSColor
var lineWidth: CGFloat
var text: String?
var fontSize: CGFloat
init(
kind: AnnotationKind,
rect: CGRect,
color: NSColor = .systemRed,
lineWidth: CGFloat = 3,
text: String? = nil,
fontSize: CGFloat = 24
) {
self.id = UUID()
self.kind = kind
self.rect = rect
self.color = color
self.lineWidth = lineWidth
self.text = text
self.fontSize = fontSize
}
}
+162
View File
@@ -0,0 +1,162 @@
import AppKit
import Foundation
enum AnnotationRenderer {
static func draw(_ item: AnnotationItem, in context: CGContext) {
context.saveGState()
context.setStrokeColor(item.color.cgColor)
context.setFillColor(item.color.cgColor)
context.setLineWidth(item.lineWidth)
context.setLineCap(.round)
context.setLineJoin(.round)
switch item.kind {
case .arrow:
drawArrow(item, in: context)
case .rectangle:
drawRectangle(item, in: context)
case .ellipse:
drawEllipse(item, in: context)
case .text:
drawText(item)
}
context.restoreGState()
}
static func render(annotations: [AnnotationItem], onto image: NSImage) -> NSImage {
guard let srcCG = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { return image }
let w = srcCG.width
let h = srcCG.height
let size = image.size
guard let cs = srcCG.colorSpace ?? CGColorSpace(name: CGColorSpace.sRGB),
let ctx = CGContext(
data: nil, width: w, height: h,
bitsPerComponent: 8, bytesPerRow: 0, space: cs,
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else { return image }
ctx.draw(srcCG, in: CGRect(x: 0, y: 0, width: w, height: h))
ctx.translateBy(x: 0, y: CGFloat(h))
let sx = CGFloat(w) / size.width
let sy = CGFloat(h) / size.height
ctx.scaleBy(x: sx, y: -sy)
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = NSGraphicsContext(cgContext: ctx, flipped: true)
for item in annotations {
draw(item, in: ctx)
}
NSGraphicsContext.restoreGraphicsState()
guard let resultCG = ctx.makeImage() else { return image }
let bitmapRep = NSBitmapImageRep(cgImage: resultCG)
bitmapRep.size = size
let result = NSImage(size: size)
result.addRepresentation(bitmapRep)
return result
}
// MARK: - Shape drawing
private static func drawArrow(_ item: AnnotationItem, in context: CGContext) {
let start = item.rect.origin
let end = CGPoint(x: item.rect.origin.x + item.rect.size.width,
y: item.rect.origin.y + item.rect.size.height)
context.move(to: start)
context.addLine(to: end)
context.strokePath()
let angle = atan2(end.y - start.y, end.x - start.x)
let headLength = max(item.lineWidth * 4, 14)
let headAngle: CGFloat = .pi / 6
let left = CGPoint(
x: end.x - headLength * cos(angle - headAngle),
y: end.y - headLength * sin(angle - headAngle)
)
let right = CGPoint(
x: end.x - headLength * cos(angle + headAngle),
y: end.y - headLength * sin(angle + headAngle)
)
context.move(to: end)
context.addLine(to: left)
context.addLine(to: right)
context.closePath()
context.fillPath()
}
private static func drawRectangle(_ item: AnnotationItem, in context: CGContext) {
context.stroke(item.rect, width: item.lineWidth)
}
private static func drawEllipse(_ item: AnnotationItem, in context: CGContext) {
context.strokeEllipse(in: item.rect)
}
private static func drawText(_ item: AnnotationItem) {
guard let text = item.text, !text.isEmpty else { return }
let font = NSFont.systemFont(ofSize: item.fontSize, weight: .semibold)
let attributes: [NSAttributedString.Key: Any] = [
.font: font,
.foregroundColor: item.color,
]
let string = NSAttributedString(string: text, attributes: attributes)
string.draw(at: item.rect.origin)
}
// MARK: - Hit testing
static func hitTest(point: CGPoint, item: AnnotationItem, tolerance: CGFloat = 6) -> Bool {
switch item.kind {
case .arrow:
let arrowEnd = CGPoint(x: item.rect.origin.x + item.rect.size.width,
y: item.rect.origin.y + item.rect.size.height)
return distanceToLine(point: point,
from: item.rect.origin,
to: arrowEnd) < tolerance + item.lineWidth
case .rectangle:
let outer = item.rect.insetBy(dx: -(tolerance + item.lineWidth),
dy: -(tolerance + item.lineWidth))
let inner = item.rect.insetBy(dx: tolerance + item.lineWidth,
dy: tolerance + item.lineWidth)
return outer.contains(point) && !inner.contains(point)
case .ellipse:
let center = CGPoint(x: item.rect.midX, y: item.rect.midY)
let rx = item.rect.width / 2
let ry = item.rect.height / 2
guard rx > 0, ry > 0 else { return false }
let dx = (point.x - center.x) / rx
let dy = (point.y - center.y) / ry
let dist = sqrt(dx * dx + dy * dy)
return abs(dist - 1.0) < (tolerance + item.lineWidth) / min(rx, ry)
case .text:
guard let text = item.text, !text.isEmpty else { return false }
let font = NSFont.systemFont(ofSize: item.fontSize, weight: .semibold)
let size = (text as NSString).size(withAttributes: [.font: font])
let textRect = CGRect(origin: item.rect.origin, size: size)
return textRect.insetBy(dx: -tolerance, dy: -tolerance).contains(point)
}
}
private static func distanceToLine(point: CGPoint, from a: CGPoint, to b: CGPoint) -> CGFloat {
let dx = b.x - a.x
let dy = b.y - a.y
let lengthSq = dx * dx + dy * dy
guard lengthSq > 0 else { return hypot(point.x - a.x, point.y - a.y) }
var t = ((point.x - a.x) * dx + (point.y - a.y) * dy) / lengthSq
t = max(0, min(1, t))
let projX = a.x + t * dx
let projY = a.y + t * dy
return hypot(point.x - projX, point.y - projY)
}
}
+115
View File
@@ -0,0 +1,115 @@
import SwiftUI
struct EditorToolbarView: View {
@Bindable var document: AnnotationDocument
var onSave: () -> Void
var onCopyToClipboard: () -> Void
var onCancel: () -> Void
var body: some View {
HStack(spacing: 12) {
toolButtons
Divider().frame(height: 24)
colorPicker
lineWidthPicker
Divider().frame(height: 24)
undoRedoButtons
Spacer()
actionButtons
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(.bar)
}
private var toolButtons: some View {
HStack(spacing: 4) {
toolButton(.arrow, icon: "arrow.up.right", label: "Pfeil")
toolButton(.rectangle, icon: "rectangle", label: "Rechteck")
toolButton(.ellipse, icon: "circle", label: "Ellipse")
toolButton(.text, icon: "textformat", label: "Text")
}
}
private func toolButton(_ kind: AnnotationKind, icon: String, label: String) -> some View {
Button {
document.currentTool = kind
} label: {
Image(systemName: icon)
.frame(width: 28, height: 28)
}
.buttonStyle(.bordered)
.tint(document.currentTool == kind ? .accentColor : nil)
.help(label)
}
private var colorPicker: some View {
HStack(spacing: 4) {
ForEach(presetColors, id: \.self) { color in
Circle()
.fill(Color(nsColor: color))
.frame(width: 20, height: 20)
.overlay {
if document.currentColor == color {
Circle().strokeBorder(.white, lineWidth: 2)
}
}
.shadow(radius: document.currentColor == color ? 2 : 0)
.onTapGesture { document.currentColor = color }
}
ColorPicker("", selection: Binding(
get: { Color(nsColor: document.currentColor) },
set: { document.currentColor = NSColor($0) }
))
.labelsHidden()
.frame(width: 28)
}
}
private var presetColors: [NSColor] {
[.systemRed, .systemBlue, .systemGreen, .systemYellow, .black, .white]
}
private var lineWidthPicker: some View {
Picker("", selection: $document.currentLineWidth) {
Text("Dünn").tag(CGFloat(2))
Text("Mittel").tag(CGFloat(3))
Text("Dick").tag(CGFloat(5))
}
.pickerStyle(.segmented)
.frame(width: 160)
.labelsHidden()
}
private var undoRedoButtons: some View {
HStack(spacing: 4) {
Button {
document.undo()
} label: {
Image(systemName: "arrow.uturn.backward")
}
.disabled(!document.canUndo)
.help("Widerrufen (⌘Z)")
Button {
document.redo()
} label: {
Image(systemName: "arrow.uturn.forward")
}
.disabled(!document.canRedo)
.help("Wiederholen (⌘⇧Z)")
}
}
private var actionButtons: some View {
HStack(spacing: 8) {
Button("Abbrechen", role: .cancel) { onCancel() }
.keyboardShortcut(.escape)
Button("In Zwischenablage") { onCopyToClipboard() }
Button("Speichern") { onSave() }
.keyboardShortcut("s")
.buttonStyle(.borderedProminent)
}
}
}
+118
View File
@@ -0,0 +1,118 @@
import AppKit
import SwiftUI
private final class FlippedContainerView: NSView {
override var isFlipped: Bool { true }
}
final class EditorWindowController: NSWindowController, NSWindowDelegate {
private let annotationDoc = AnnotationDocument()
private let baseImage: NSImage
private let sourceURL: URL?
private let completion: (NSImage?) -> Void
private let canvasView = AnnotationCanvasView()
init(image: NSImage, sourceURL: URL?, completion: @escaping (NSImage?) -> Void) {
self.baseImage = image
self.sourceURL = sourceURL
self.completion = completion
let screenFrame = NSScreen.main?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1280, height: 800)
let maxWidth = screenFrame.width * 0.8
let maxHeight = screenFrame.height * 0.8
let scale = min(maxWidth / image.size.width, maxHeight / image.size.height, 1.0)
let windowWidth = max(image.size.width * scale, 600)
let windowHeight = max(image.size.height * scale, 400) + 52
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: windowWidth, height: windowHeight),
styleMask: [.titled, .closable, .miniaturizable, .resizable],
backing: .buffered,
defer: false
)
window.title = "Bildeditor"
window.isReleasedWhenClosed = false
window.center()
window.minSize = NSSize(width: 500, height: 400)
super.init(window: window)
window.delegate = self
canvasView.baseImage = image
canvasView.document = annotationDoc
canvasView.onDocumentChanged = { [weak self] in
self?.canvasView.needsDisplay = true
}
let toolbarHosting = NSHostingView(rootView: EditorToolbarView(
document: annotationDoc,
onSave: { [weak self] in self?.save() },
onCopyToClipboard: { [weak self] in self?.copyToClipboard() },
onCancel: { [weak self] in self?.cancel() }
))
toolbarHosting.translatesAutoresizingMaskIntoConstraints = false
canvasView.translatesAutoresizingMaskIntoConstraints = false
let container = FlippedContainerView()
container.addSubview(toolbarHosting)
container.addSubview(canvasView)
window.contentView = container
NSLayoutConstraint.activate([
toolbarHosting.topAnchor.constraint(equalTo: container.topAnchor),
toolbarHosting.leadingAnchor.constraint(equalTo: container.leadingAnchor),
toolbarHosting.trailingAnchor.constraint(equalTo: container.trailingAnchor),
toolbarHosting.heightAnchor.constraint(equalToConstant: 52),
canvasView.topAnchor.constraint(equalTo: toolbarHosting.bottomAnchor),
canvasView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
canvasView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
canvasView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
])
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
func showEditor() {
NSApplication.shared.setActivationPolicy(.regular)
NSApplication.shared.activate(ignoringOtherApps: true)
window?.makeKeyAndOrderFront(nil)
window?.makeFirstResponder(canvasView)
}
// MARK: - Actions
private func renderFinal() -> NSImage {
AnnotationRenderer.render(annotations: annotationDoc.items, onto: baseImage)
}
private func save() {
let image = renderFinal()
completion(image)
close()
}
private func copyToClipboard() {
let image = renderFinal()
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.writeObjects([image])
close()
}
private func cancel() {
completion(nil)
close()
}
// MARK: - NSWindowDelegate
func windowWillClose(_ notification: Notification) {
let visibleWindows = NSApplication.shared.windows.filter { $0.isVisible && $0 != window }
if visibleWindows.isEmpty {
NSApplication.shared.setActivationPolicy(.accessory)
}
}
}
+19
View File
@@ -11,11 +11,30 @@ struct MenuBarContentView: View {
Divider()
Button("Letzte Aufnahme bearbeiten…") {
coordinator.openEditorForLastCapture()
}
.disabled(coordinator.lastCapturedURL == nil)
Button("Aus Zwischenablage bearbeiten") {
coordinator.openEditorFromClipboard()
}
Button("Bild bearbeiten…") {
coordinator.openEditorForFile()
}
Divider()
Button("Einstellungen…") {
SettingsWindowController.shared.show(coordinator: coordinator)
}
.keyboardShortcut(",")
Button("Über SnipMC…") {
AboutWindowController.shared.show()
}
Divider()
Button("Beenden") {
+17
View File
@@ -1,11 +1,28 @@
import AppKit
import ServiceManagement
import SwiftUI
struct SettingsView: View {
@EnvironmentObject var coordinator: AppCoordinator
@State private var launchAtLogin = SMAppService.mainApp.status == .enabled
var body: some View {
Form {
Section("Allgemein") {
Toggle("Mit dem System starten", isOn: $launchAtLogin)
.onChange(of: launchAtLogin) { _, newValue in
do {
if newValue {
try SMAppService.mainApp.register()
} else {
try SMAppService.mainApp.unregister()
}
} catch {
launchAtLogin = SMAppService.mainApp.status == .enabled
}
}
}
Section("Nach der Aufnahme") {
Picker("Aktion:", selection: $coordinator.outputAction) {
ForEach(OutputAction.allCases) { action in
+3 -1
View File
@@ -32,7 +32,9 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
}
func windowWillClose(_ notification: Notification) {
// Menu bar-only apps should not keep a Dock icon after the window closes.
let otherVisible = NSApplication.shared.windows.filter { $0.isVisible && $0 != window }
if otherVisible.isEmpty {
NSApplication.shared.setActivationPolicy(.accessory)
}
}
}
+2 -2
View File
@@ -1,11 +1,11 @@
import SwiftUI
@main
struct SnippingToolApp: App {
struct SnipMCApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
var body: some Scene {
MenuBarExtra("Snipping Tool", systemImage: "camera.viewfinder") {
MenuBarExtra("SnipMC", systemImage: "camera.viewfinder") {
MenuBarContentView()
.environmentObject(appDelegate.coordinator)
}
+12 -12
View File
@@ -1,11 +1,11 @@
name: SnippingTool
name: SnipMC
options:
bundleIdPrefix: it.wende
deploymentTarget:
macOS: "14.0"
createIntermediateGroups: true
targets:
SnippingTool:
SnipMC:
type: application
platform: macOS
deploymentTarget: "14.0"
@@ -14,29 +14,29 @@ targets:
info:
path: Generated/Info.plist
properties:
CFBundleDisplayName: Snipping Tool
CFBundleName: Snipping Tool
CFBundleDisplayName: SnipMC
CFBundleName: SnipMC
LSUIElement: true
NSHumanReadableCopyright: "Copyright © 2026"
CFBundleURLTypes:
- CFBundleURLName: it.wende.snippingtool
- CFBundleURLName: it.wende.snipmc
CFBundleURLSchemes:
- snippingtool
- snipmc
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: it.wende.snippingtool
PRODUCT_NAME: Snipping Tool
MARKETING_VERSION: "1.0"
CURRENT_PROJECT_VERSION: "1"
PRODUCT_BUNDLE_IDENTIFIER: it.wende.snipmc
PRODUCT_NAME: SnipMC
MARKETING_VERSION: "1.0.2"
CURRENT_PROJECT_VERSION: "3"
SWIFT_VERSION: "5.0"
MACOSX_DEPLOYMENT_TARGET: "14.0"
ENABLE_HARDENED_RUNTIME: false
CODE_SIGN_IDENTITY: "-"
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
schemes:
SnippingTool:
SnipMC:
build:
targets:
SnippingTool: all
SnipMC: all
run:
config: Debug