20 Commits
Author SHA1 Message Date
rogier 561ea57d3e 2.0.0 2026-11-10 23:54:21 +01:00
rogierandCursor 1f3912ef57 1.1.9
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 16:12:43 +01:00
rogier c2f8063518 1.1.8 2026-11-10 15:33:49 +01:00
rogierandCursor 36c55c034c 1.1.8
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 10:36:25 +01:00
rogier 136eda40ba systeemvak + icoon grootte 2026-11-09 22:42:35 +01:00
rogier 465ec9837b 1.1.6 2026-11-09 20:23:46 +01:00
rogier 2f5c434623 Update README.md 2026-11-09 18:14:33 +01:00
rogier 89b84d8479 1.1.5 2026-11-09 17:57:31 +01:00
rogierandCursor 6d19b91963 1.1.5
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-09 17:32:50 +01:00
rogier 43a2c4f57b plans 2026-11-09 17:06:35 +01:00
rogierandCursor b780c6a116 1.1.4
Update-melding, rechtermuisknopmenu in de editor.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-09 17:00:58 +01:00
rogierandCursor 67c98c7c0a 1.1.4
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-09 16:36:28 +01:00
rogier 5bdc851fa0 1.1.3 2026-11-09 13:02:24 +01:00
rogierandCursor 9b9ac36261 1.1.2
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-09 00:38:41 +01:00
rogier ceec9ecdc0 Toon alleen de laatste versie. 2026-11-09 00:26:57 +01:00
rogierandCursor eb1fc40534 1.1.1
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-09 00:18:46 +01:00
rogier cc08b00e3b 1.1.0 2026-11-09 00:07:44 +01:00
rogier e7ed05922e remove-autostart 2026-11-07 12:41:54 +01:00
rogier 2d80538377 screenshot knipp app 2026-11-07 12:31:12 +01:00
rogier c2673493dc screenshot app 2026-11-07 12:27:10 +01:00
93 changed files with 9286 additions and 724 deletions

No files matched your search

@@ -0,0 +1,52 @@
---
name: Drie-split werkbalk
overview: "Een derde editorindeling toevoegen: gereedschappen boven, opslaan/kopiëren/zoom onder, en Nieuw + Screencast in een verticale zijbalk links of rechts van het canvas. De zijde is te kiezen in Instellingen > Weergave en via rechtsklik op de werkbalk."
todos:
- id: settings-model
content: EditorLayout.ThreeSplit en CaptureBarPosition toevoegen in AppSettings
status: pending
- id: editor-xaml
content: CaptureBar/CaptureItems toevoegen in EditorWindow.xaml
status: pending
- id: editor-logic
content: ApplyToolbarLayout, positie, lock, Ctrl+H, contextmenu en reset aanpassen in EditorWindow.xaml.cs
status: pending
- id: settings-ui
content: Derde optie plus Links/Rechts in SettingsWindow (xaml + laden/opslaan/standaard)
status: pending
- id: verify
content: Bouwen en alle indelingen handmatig testen
status: pending
isProject: false
---
# Drie-split werkbalk met zijbalk links of rechts
## Gedrag
- Nieuwe indeling "Drie-split": bovenbalk (gereedschappen), onderbalk (Opslaan, Uploaden, Kopiëren, zoom) en een smalle verticale zijbalk naast het canvas met **Nieuw** en **Screencast**.
- De zijbalk staat links of rechts (standaard links). In de zijbalk staan alleen iconen; de bestaande logica in `UpdateActionLabels` verbergt labels al bij `Orientation.Vertical`.
- De zijbalk staat in de `DockPanel` na `Toolbar`/`PropsBar`, zodat hij alleen naast het canvas loopt (zoals op positie 2 en 3 in je screenshot) en niet naast de boven- of onderbalk.
## Wijzigingen
**[src/Kniip/Settings/AppSettings.cs](src/Kniip/Settings/AppSettings.cs)**
- `EditorLayout` uitbreiden met `ThreeSplit` (achteraan, zodat bestaande instellingen geldig blijven).
- Nieuwe instelling `public Dock CaptureBarPosition { get; set; } = Dock.Left;` (alleen `Left`/`Right` gebruikt).
**[src/Kniip/Editor/EditorWindow.xaml](src/Kniip/Editor/EditorWindow.xaml)**
- Nieuwe `Border x:Name="CaptureBar"` (standaard `Collapsed`) met `local:ToolbarPanel x:Name="CaptureItems"` (verticaal), met dezelfde `ContextMenuOpening`/`OrderChanged`-handlers als `ActionBar`. Plaats hem na `PropsBar` en vóór het canvas.
**[src/Kniip/Editor/EditorWindow.xaml.cs](src/Kniip/Editor/EditorWindow.xaml.cs)**
- `IsSplitLayout` true maken voor zowel `Split` als `ThreeSplit` (beide gebruiken `SplitToolbarOrder` en `SplitBottomIds`), plus `IsThreeSplit`.
- `SideIds = { "New", "Screencast" }`; in `ApplyToolbarLayout` gaan die items bij `ThreeSplit` naar `CaptureItems` in plaats van `ActionItems`. `CaptureItems.Children` ook leegmaken.
- `CaptureBar.Visibility` instellen op dezelfde manier als `ActionBar` (ook in `ToggleToolbars`, Ctrl+H). `DockPanel.SetDock(CaptureBar, _settings.CaptureBarPosition)` en de rand aan de canvaszijde (`0,0,1,0` links, `1,0,0,0` rechts).
- `AllToolbarItems` uitbreiden met `CaptureItems.Items`; `ApplyToolbarLock` ook voor `CaptureItems`/`CaptureBar`.
- Contextmenu: in "Indeling" een derde optie "Drie-split: zijbalk met Nieuw en Screencast". Bij `ThreeSplit` een submenu "Zijbalk" met Links/Rechts (zelfde patroon als het bestaande "Positie"-menu). `Toolbar_ContextMenuOpening` het juiste paneel laten kiezen voor `CaptureBar`.
- `ResetToolbar`: `CaptureBarPosition = Dock.Left`.
**[src/Kniip/Settings/SettingsWindow.xaml](src/Kniip/Settings/SettingsWindow.xaml)** en **[.xaml.cs](src/Kniip/Settings/SettingsWindow.xaml.cs)**
- Derde radiobutton `LayoutThreeSplitRadio` "Drie-split: gereedschappen boven, opslaan onder, Nieuw en Screencast opzij", met daaronder (ingesprongen, alleen actief bij deze keuze) twee radiobuttons "Links" en "Rechts".
- Laden (r. 104-105) en opslaan (r. 454) aanpassen voor drie waarden plus `CaptureBarPosition`; standaardinstellingen (r. ~481) ook terugzetten.
## Controle
- Bouwen, editor openen, tussen alle drie de indelingen wisselen via Instellingen en rechtsklik. Controleren dat Links/Rechts direct werkt, Ctrl+H alle balken verbergt en terughaalt, en dat versleepte volgorde en verborgen knoppen bewaard blijven.
@@ -0,0 +1,51 @@
---
name: Gebogen lijnen
overview: Lijnen en pijlen krijgen buigpunten waardoor een vloeiende kromme door alle punten loopt. Bij een geselecteerde lijn staat tussen elk paar punten een spook-greep; slepen daarvan maakt een nieuw buigpunt, dubbelklikken op een buigpunt verwijdert het.
todos:
- id: model
content: "LineAnnotation: Bends-lijst, Catmull-Rom-naar-Bezier helper, Render/Bounds/HitTest/Move/Clone aanpassen, pijlpunt langs raaklijn"
status: completed
- id: handles
content: "Grepen: buigpunten + spook-grepen, BeginHandleDrag voegt buigpunt in, RemoveHandle; IsGhostHandle/RemoveHandle in Annotation"
status: completed
- id: surface
content: "AnnotationSurface: spook-grepen anders tekenen, echte grepen voorrang bij HitHandle"
status: completed
- id: editor
content: "EditorWindow: dubbelklik op buigpunt verwijdert het (met undo), hint-tekst bijwerken"
status: completed
isProject: false
---
# Gebogen lijnen en pijlen
## Gedrag
- Selecteer een lijn of pijl: je ziet de begin- en eindgreep, de buigpunten en, halverwege elk stuk van de kromme, een kleine halfdoorzichtige spook-greep.
- Sleep je een spook-greep, dan wordt die een echt buigpunt en volgt de lijn de muis (zoals in je screenshot).
- Dubbelklik op een buigpunt verwijdert het. Beide acties kun je ongedaan maken.
- De kromme loopt precies door alle punten (Catmull-Rom, omgezet naar kubische Beziers). De pijlpunt volgt de richting van het laatste stuk.
## Wijzigingen
**[src/Kniip/Editor/Annotations/LineAnnotation.cs](src/Kniip/Editor/Annotations/LineAnnotation.cs)**
- Nieuw: `List<Point> Bends`, met `Start + Bends + End` als doorlooppunten.
- Een helper bouwt de Bezier-segmenten. Voor `Bounds`, `HitTest` en de posities van de spook-grepen gebruikt hij bemonsterde punten (ongeveer 16 per segment). Zonder buigpunten blijft alles gelijk aan nu.
- `Render`: een `StreamGeometry` met `BezierTo`. Bij een pijl wordt de richting bepaald door `End` min het laatste controlepunt. Het laatste segment eindigt op het basispunt van de pijlpunt.
- Grepen: index 0 is `Start` en 1 is `End`, zodat bestaande code blijft werken. Daarna volgen de buigpunten en dan de spook-grepen (het Bezier-punt bij t=0,5 van elk segment).
- `BeginHandleDrag`: bij een spook-greep wordt een buigpunt op de juiste plek in `Bends` ingevoegd en het actieve buigpunt onthouden. `MoveHandle` verplaatst daarna dat punt.
- Met Shift blijft `SnapAngle` werken voor begin en eind, maar dan ten opzichte van het naastliggende punt.
- `Move` verschuift ook alle buigpunten. `Clone` maakt een diepe kopie van `Bends`, nodig voor ongedaan maken (zelfde patroon als in `HighlightAnnotation`).
**[src/Kniip/Editor/Annotations/Annotation.cs](src/Kniip/Editor/Annotations/Annotation.cs)**
- `public virtual bool IsGhostHandle(int index) => false;`
- `public virtual bool RemoveHandle(int index) => false;` (LineAnnotation verwijdert hiermee een buigpunt)
**[src/Kniip/Editor/AnnotationSurface.cs](src/Kniip/Editor/AnnotationSurface.cs)**
- `DrawSelection` tekent spook-grepen als kleinere, halfdoorzichtige cirkels. `HitHandle` controleert eerst de echte grepen en daarna pas de spook-grepen, zodat een echte greep voorrang krijgt als ze overlappen.
**[src/Kniip/Editor/EditorWindow.xaml.cs](src/Kniip/Editor/EditorWindow.xaml.cs)** (rond regel 452)
- In `Surface_MouseLeftButtonDown`: als op een greep geklikt wordt met `e.ClickCount == 2` en `_selected.RemoveHandle(handle)` slaagt, dan volgen `Commit(_pending)` en `InvalidateVisual`, en stopt de verwerking daar. Anders start het bestaande sleepgedrag.
- Optioneel: in `UpdateHint` een korte tip toevoegen voor lijnen ("Sleep het middenpunt om te buigen, dubbelklik om een punt te verwijderen").
## Niet nodig
Exporteren gaat via `Render` in [src/Kniip/Editor/DocumentRenderer.cs](src/Kniip/Editor/DocumentRenderer.cs) en werkt dus automatisch. Ook het aanmaken van lijnen (`UpdateCreating`) blijft ongewijzigd: nieuwe lijnen zijn recht.
@@ -0,0 +1,90 @@
---
name: Meertaligheid kniip
overview: kniip meertalig maken (Nederlands, Engels, Duits, Frans, Spaans, Italiaans, Portugees) met een taalinstelling die standaard de Windows-taal volgt en direct wisselt zonder herstart, net als de kleurmodus. Daarnaast de installer en de README vertalen.
todos:
- id: infra
content: LanguageManager, AppLanguage-instelling, Strings.*.xaml-skelet en opstart-aanroep
status: pending
- id: settings-ui
content: Taalkeuze op tab Weergave in SettingsWindow met direct wisselen
status: pending
- id: xaml
content: Alle XAML-teksten omzetten naar DynamicResource (nl-bron)
status: pending
- id: csharp
content: C#-teksten omzetten naar LanguageManager.T, inclusief tray en verversen bij Changed
status: pending
- id: translate
content: Vertalingen en, de, fr, es, it, pt invullen en controlescript voor sleutels in tools/
status: pending
- id: installer
content: "Inno Setup: zeven talen, CustomMessages, eerste taal doorgeven aan de app"
status: pending
- id: docs
content: README in zeven talen, CHANGELOG-entry
status: pending
- id: verify
content: Bouwen, elke taal doorlopen, breedtes van lange teksten (de, fr, es, it, pt) nalopen
status: pending
isProject: false
---
# Meertaligheid kniip (nl, en, de, fr, es, it, pt)
## Aanpak
Zelfde patroon als [src/Kniip/Infrastructure/ThemeManager.cs](src/Kniip/Infrastructure/ThemeManager.cs): per taal een `ResourceDictionary` met strings, die in `Application.Current.Resources.MergedDictionaries` wordt gewisseld. XAML gebruikt `DynamicResource`, dus alle open vensters wisselen direct mee.
```mermaid
flowchart LR
Settings["AppSettings.Language"] --> LangMgr["LanguageManager.Apply"]
LangMgr --> Dict["Strings.xx.xaml in MergedDictionaries"]
Dict --> Xaml["XAML: DynamicResource"]
LangMgr --> Changed["LanguageManager.Changed"]
Changed --> CodeTexts["C#-teksten, tray, open menu's verversen"]
LangMgr --> Culture["CurrentUICulture en CurrentCulture"]
```
## Onderdelen
**1. Taalbestanden** in de nieuwe map `src/Kniip/Languages/`:
- `Strings.nl.xaml` (bron, huidige teksten), `Strings.en.xaml`, `Strings.de.xaml`, `Strings.fr.xaml`, `Strings.es.xaml`, `Strings.it.xaml`, `Strings.pt.xaml`
- Portugees is Braziliaans Portugees (de grootste groep gebruikers) en wordt ook gebruikt voor Windows in pt-PT
- Sleutels in de vorm `Settings.Hotkeys.Title`, `Editor.Menu.Undo`, `Tray.PauseCapture`; teksten met variabelen gebruiken `{0}` voor `string.Format`
- Een ontbrekende sleutel in een taal valt terug op Engels (Engels wordt onder de gekozen taal gemerged)
**2. `LanguageManager`** (nieuw, `src/Kniip/Infrastructure/LanguageManager.cs`):
- `Apply(AppLanguage)`: wisselt de dictionary, zet `CultureInfo` (datums, getallen) en het `Language`-attribuut van open vensters, en roept daarna `Changed` aan
- `T(string key)` en `T(string key, params object[] args)` voor C#-code
- Bij `System` de Windows-UI-taal (`CultureInfo.InstalledUICulture`) nemen; staat die niet in de lijst, dan Engels
**3. Instelling** in [src/Kniip/Settings/AppSettings.cs](src/Kniip/Settings/AppSettings.cs):
- `enum AppLanguage { System, Dutch, English, German, French, Spanish, Italian, Portuguese }`, met `public AppLanguage Language { get; set; } = AppLanguage.System;`
- In [src/Kniip/Settings/SettingsWindow.xaml](src/Kniip/Settings/SettingsWindow.xaml), tab Weergave, een kopje "Taal" boven Kleurmodus met een ComboBox. Taalnamen staan altijd in de eigen taal (Nederlands, English, Deutsch, Français, Español, Italiano, Português), en een wissel geldt direct, net als bij de kleurmodus
- Aanroep van `LanguageManager.Apply` bij het opstarten in [src/Kniip/App.xaml.cs](src/Kniip/App.xaml.cs), vóór `ThemeManager.Apply` en vóór het eerste venster
**4. XAML omzetten**: elke `Text`, `Content`, `ToolTip`, `Header` en `Title` wordt `{DynamicResource ...}`. Op volgorde van omvang:
- `SettingsWindow.xaml` (ongeveer 110 teksten), `EditorWindow.xaml` (ongeveer 100), `ScreencastWindow.xaml`, `HistoryWindow.xaml`, `RecordingToolbar.xaml`, `SnipToast.xaml`, `CountdownWindow.xaml`, `RegionSelectorWindow.xaml`, `RegionCaptureWindow.xaml`, `ColorPickerButton.xaml`, `EmojiPickerButton.xaml`, `App.xaml`
**5. C#-teksten omzetten** naar `LanguageManager.T(...)`:
- Menu's die bij openen worden opgebouwd (contextmenu's in `EditorWindow.xaml.cs`, werkbalkmenu) zijn daarmee meteen goed
- Teksten die langer blijven staan (bijvoorbeeld `HintText`, statusteksten, labels in `SettingsWindow.xaml.cs` en `ScreencastWindow.xaml.cs`) krijgen `SetResourceReference` of verversen bij `LanguageManager.Changed`
- Tray ([src/Kniip/Infrastructure/TrayIcon.cs](src/Kniip/Infrastructure/TrayIcon.cs), WinForms) bouwt de menu-teksten opnieuw op bij `Changed`
- Overige bestanden: `App.xaml.cs`, `HistoryActions.cs`, `HistoryWindow.xaml.cs`, `UploaderImporter.cs`, `ZiplineUploader.cs`, `RecordingController.cs`, `UpdateInstaller.cs`, `UpdateChecker.cs`, `ShellIntegration.cs`, `HotkeyGesture.cs` (namen zoals "Geen"), `EmojiCatalog.cs` (categorienamen), `MarkdownRenderer.cs`, `RulerControl.cs`, `VideoExporter.cs`, `GlobalHotkeyHook.cs`, `AppIdentity.cs`
- Standaardbestandsnamen "Knipsel" en "Screencast" volgen de taal (Knipsel, Snip, Ausschnitt, Capture, Recorte, Ritaglio, Recorte). Een eigen naamsjabloon blijft ongewijzigd
- Release notes van GitHub blijven in de taal waarin ze geschreven zijn
**6. Project**: in [src/Kniip/Kniip.csproj](src/Kniip/Kniip.csproj) wordt `SatelliteResourceLanguages` `nl;en;de;fr;es;it;pt-BR` (voor de WPF-systeemteksten), en de `Description` wordt Engels.
**7. Installer** ([installer/Kniip.iss](installer/Kniip.iss)):
- In `[Languages]` komen `dutch`, `english` (`Default.isl`), `german`, `french`, `spanish`, `italian` en `brazilianportuguese` (`BrazilianPortuguese.isl`), met `ShowLanguageDialog=auto`
- De eigen teksten (taken, "kniip nu starten", groepnamen) gaan naar `[CustomMessages]` per taal en worden aangeroepen met `{cm:...}`
- De gekozen taal geeft de installer als eerste taal door aan de app, via `HKCU\Software\kniip\Language`, alleen als er nog geen instelling bestaat. Komt dit in de praktijk niet goed over, dan blijft `System` de standaard
**8. Documentatie**:
- `README.md` wordt Engels (internationaal standaard) met taallinks bovenaan; daarnaast `README.nl.md` (huidige tekst), `README.de.md`, `README.fr.md`, `README.es.md`, `README.it.md` en `README.pt.md`
- `CHANGELOG.md`: vanaf nu in het Engels; de bestaande geschiedenis blijft Nederlands. Een nieuwe entry voor deze versie meldt de taalondersteuning
## Controle
- Bouwen met `build.ps1`, daarna elke taal instellen en alle vensters doorlopen op ontbrekende sleutels (die zijn zichtbaar als lege tekst) en op afgekapte lange teksten (vooral Duits, Frans, Spaans, Italiaans en Portugees) in vaste breedtes (bijvoorbeeld `Width="150"` in de instellingen). Waar nodig wordt de breedte aangepast
- Een klein script in `tools/` vergelijkt de sleutels van de zeven taalbestanden met elkaar
@@ -0,0 +1,64 @@
---
name: Naamsjabloon opslaan
overview: In de tab Opslaan komt per soort (knipsels en screencasts) een eigen naamsjabloon met %J, %M, %D, %V, %Rn en %Nn. Mappen worden automatisch aangemaakt en kniip plakt zelf de extensie (.png, .mp4 of .gif) erachter.
todos:
- id: template
content: FileNameTemplate.cs maken (tokens, submappen, opschonen, %N per map, fallback)
status: completed
- id: settings
content: SnipFileNameTemplate en VideoFileNameTemplate toevoegen aan AppSettings
status: completed
- id: windowname
content: Programmanaam van het venster bepalen in SnapshotWindows en doorgeven via RegionCaptureWindow, App, RecordingController
status: completed
- id: save
content: EditorWindow.Save/SaveAs en ScreencastWindow.FixedTarget/DefaultFileName op het sjabloon laten draaien
status: completed
- id: ui
content: Naamvelden met live voorbeeld en tokenuitleg toevoegen in de tab Opslaan
status: completed
- id: build
content: Bouwen en testen met het voorbeeld %J/%M-%D/%V_%N3
status: completed
isProject: false
---
# Naamsjabloon voor opslaan
## Gedrag
- In de tab **Opslaan** komt onder elke map (Knipsels en Screencasts) een veld **Naam**, met een live voorbeeld eronder. Bijvoorbeeld: `%J/%M-%D/%V_%N3` wordt `2026/11-08/Cursor_001.png`.
- Tokens:
- `%J` is het jaar (2026).
- `%M` is de maand en `%D` de dag, allebei met twee cijfers (11, 08), zodat de mappen goed sorteren.
- `%V` is de programmanaam van het venster, bijvoorbeeld `Cursor` of `chrome`.
- `%Rn` zijn n willekeurige letters en cijfers.
- `%Nn` is het eerste vrije nummer in de doelmap, met n cijfers (001, 002, ...). In een nieuwe map begint de teller weer bij 001.
- Met `/` of `\` maak je submappen. Die maakt kniip zelf aan, onder de ingestelde map.
- kniip plakt de extensie er zelf achter: `.png` voor knipsels, `.mp4` of `.gif` voor screencasts.
- Is het veld leeg, dan blijft de huidige naam staan (`Knipsel 2026-11-08 234500`), zodat bestaande instellingen gewoon blijven werken.
- Ongeldige tekens zoals `: * ? " < > |` worden `_`. Zit er geen `%N` in en bestaat het bestand al, dan valt kniip terug op de bestaande `(2)`-nummering uit `SaveLocation.UniquePath`.
- Tokens beginnen altijd met `%`. In jouw voorbeeld moet `_N3` dus `_%N3` zijn. Het live voorbeeld laat meteen zien of het klopt.
## Waar komt %V vandaan
```mermaid
flowchart LR
WinSnip["Alt+PrintScreen: aangeklikt venster"] --> Name[Programmanaam]
RegionSnip["Rechthoekknipsel: venster onder het midden van de selectie"] --> Name
Screencast["Screencast: venster onder het midden van het opnamegebied"] --> Name
Other["Knipprogramma, klembord, Openen met"] --> Fallback["'Knipsel' of 'Screencast'"]
```
Het venster wordt bepaald uit de vensterlijst die al vóór de overlay wordt vastgelegd (`PhysicalWindow.SnapshotWindows`). Zo staat kniip's eigen overlay er nooit tussen.
## Wijzigingen
- **Nieuw** [src/Kniip/Settings/FileNameTemplate.cs](src/Kniip/Settings/FileNameTemplate.cs): `Resolve(folder, template, windowName, extension)` geeft het volledige pad terug, inclusief submappen, opschoning en `%N`-telling. Daarnaast `Preview(...)` voor het voorbeeld in de instellingen.
- [src/Kniip/Settings/AppSettings.cs](src/Kniip/Settings/AppSettings.cs): twee nieuwe instellingen, `SnipFileNameTemplate` en `VideoFileNameTemplate`. Leeg betekent de huidige standaardnaam.
- [src/Kniip/Recording/PhysicalWindow.cs](src/Kniip/Recording/PhysicalWindow.cs): `SnapshotWindows` geeft naast de rechthoek ook de handle van elk venster terug. Een nieuwe helper zoekt via `GetWindowThreadProcessId` en `Process.ProcessName` de programmanaam op.
- [src/Kniip/Capture/RegionCaptureWindow.xaml.cs](src/Kniip/Capture/RegionCaptureWindow.xaml.cs) en [src/Kniip/App.xaml.cs](src/Kniip/App.xaml.cs): `Finished` levert naast de afbeelding ook de vensternaam. `OpenEditor(image, windowName)` geeft die door aan `EditorWindow`.
- [src/Kniip/Recording/RecordingController.cs](src/Kniip/Recording/RecordingController.cs): bij het kiezen van het gebied wordt de vensternaam onthouden. Die gaat via `RecordingFinished` naar `ScreencastWindow`.
- [src/Kniip/Editor/EditorWindow.xaml.cs](src/Kniip/Editor/EditorWindow.xaml.cs): `Save()` gebruikt `FileNameTemplate.Resolve` in plaats van de vaste `Knipsel {DateTime.Now:...}.png`. `SaveAs()` stelt de gegenereerde bestandsnaam voor.
- [src/Kniip/Recording/ScreencastWindow.xaml.cs](src/Kniip/Recording/ScreencastWindow.xaml.cs): `FixedTarget()` en `DefaultFileName` gaan via het sjabloon, met `.gif` of `.mp4` afhankelijk van het formaat.
- [src/Kniip/Settings/SettingsWindow.xaml](src/Kniip/Settings/SettingsWindow.xaml) en [.xaml.cs](src/Kniip/Settings/SettingsWindow.xaml.cs): per soort een `TextBox` **Naam** met een voorbeeldregel, een uitleg van de tokens als hint, en opslaan en herstellen samen met de mapinstellingen. De bestaande hint over "datum en tijd in de naam" wordt aangepast.
@@ -0,0 +1,72 @@
---
name: Schermafdruk vertraging
overview: "Tray-submenu \"Schermafdruk vertraging: Xs\" (0-60 s, slider plus getalveld) dat rechthoek- en vensterknipsels uitstelt, met een klein aftelvenster bij de muis dat met Esc te annuleren is."
todos:
- id: setting
content: CaptureDelaySeconds toevoegen aan AppSettings (0-60, standaard 0)
status: completed
- id: tray
content: Tray-submenu met TrackBar + NumericUpDown, SetCaptureDelay en CaptureDelayChanged in TrayIcon
status: completed
- id: countdown
content: CountdownWindow (niet-activerend, volgt muis, Elapsed/Cancelled) maken
status: completed
- id: app
content: BeginDelayedSnip in App.xaml.cs, Esc-binding tijdelijk, StartNewSnip onvertraagd, instelling opslaan
status: completed
- id: changelog
content: CHANGELOG.md bijwerken
status: completed
- id: release
content: CHANGELOG 1.1.5-item, build.ps1 draaien, commit/tag 1.1.5 en pushen
status: completed
isProject: false
---
# Schermafdruk vertraging (0-60 s)
## Gedrag
- In het tray-menu komt direct onder "Venster knippen" een item `Schermafdruk vertraging: 0s` met een submenu.
- In het submenu staan een slider (0-60) en een getalveld (`NumericUpDown`, 0-60) die met elkaar meelopen. De tekst van het hoofditem werkt meteen bij, bijvoorbeeld `Schermafdruk vertraging: 5s`.
- 0 betekent uit: dan werkt alles zoals nu.
- De vertraging geldt voor "Rechthoek knippen" en "Venster knippen", via de tray en via de sneltoetsen. Screencasts en "Nieuw knipsel" na het sluiten van de editor (`StartNewSnip`) gaan zonder vertraging.
- Tijdens het aftellen staat een klein, altijd-bovenop, niet-focusstelend venstertje bij de muis met de resterende seconden. Het scherm wordt pas vastgelegd nadat dat venster weg is, zodat het niet in de schermafdruk komt. Met Esc (of een klik op het venster) annuleer je.
```mermaid
flowchart LR
trigger["Tray of sneltoets"] --> check{"Delay groter dan 0?"}
check -->|nee| snip["BeginSnip: scherm bevriezen"]
check -->|ja| countdown["CountdownWindow bij muis"]
countdown -->|"Esc of klik"| cancel[Geannuleerd]
countdown -->|"tijd om"| closeWin["Venster sluiten, korte pauze"] --> snip
```
## Wijzigingen
**[src/Kniip/Settings/AppSettings.cs](src/Kniip/Settings/AppSettings.cs)**
- Nieuwe instelling `public int CaptureDelaySeconds { get; set; }` (standaard 0), met een doc-comment in dezelfde stijl. Bij het lezen begrenzen tot 0-60.
**[src/Kniip/Infrastructure/TrayIcon.cs](src/Kniip/Infrastructure/TrayIcon.cs)**
- `_delayItem` (een `ToolStripMenuItem`) met in de `DropDownItems` een `ToolStripControlHost` om een klein `Panel`: daarin een `TrackBar` (0-60, `TickFrequency` 5) en een `NumericUpDown` met het label "seconden".
- `public void SetCaptureDelay(int seconds)` zet de besturingselementen en de menutekst.
- `public event Action<int>? CaptureDelayChanged`: wordt afgevuurd als de waarde wijzigt. Opslaan gebeurt pas bij `DropDownClosed`, zodat niet elke stap van de slider een schrijfactie wordt.
- Toetsen in het getalveld mogen het menu niet sluiten. Als dat in de praktijk toch gebeurt, krijgt het hostpaneel focus en geven we Enter/Esc expliciet door.
**[src/Kniip/App.xaml.cs](src/Kniip/App.xaml.cs)**
- Bij het opstarten: `_tray.SetCaptureDelay(Settings.CaptureDelaySeconds)` en `CaptureDelayChanged` koppelen. De koppeling zet `Settings.CaptureDelaySeconds` en roept `SettingsStore.Save` aan.
- `BeginRegionSnip`/`BeginWindowSnip` gaan via een nieuwe `BeginDelayedSnip(bool pickWindow)`:
- Als de vertraging 0 is, of er al een knipsel loopt, of er al wordt afgeteld, gaat het direct naar `BeginSnip`. Een tweede sneltoets tijdens het aftellen wordt genegeerd.
- Anders wordt een `CountdownWindow` gestart. Na afloop sluit dat venster en volgt een `DispatcherTimer` van circa 150 ms (zelfde idee als `StartNewSnip`), daarna `BeginSnip(pickWindow)`.
- Esc tijdens het aftellen: tijdelijk een `Escape`-binding toevoegen aan `HotkeyHook.Bindings` die het aftellen annuleert, en daarna via `ApplyHotkeys()` herstellen. `HotkeyHook.Capture` gebruiken we niet, want dat slokt alle toetsen op, en de gebruiker moet tijdens het aftellen juist zijn scherm kunnen klaarzetten.
- `StartNewSnip` blijft `BeginRegionSnip` direct aanroepen, maar dan via de onvertraagde route (`BeginSnip(false)`).
**Nieuw: `src/Kniip/Capture/CountdownWindow.xaml(.cs)`**
- Een klein, rond-hoekig WPF-venster zonder rand: `Topmost`, `ShowActivated=False`, `ShowInTaskbar=False`, met `WS_EX_NOACTIVATE`/`WS_EX_TOOLWINDOW` zodat de focus bij het actieve programma blijft. De opmaak is vergelijkbaar met [`SnipToast.xaml`](src/Kniip/Notifications/SnipToast.xaml).
- Toont een groot getal en de tekst "Esc om te annuleren". Er loopt een `DispatcherTimer` van 1 s. Het venster schuift mee met de muis (positie bijwerken op dezelfde tick of op een snellere tick van circa 50 ms).
- Events `Elapsed` en `Cancelled`. Een muisklik op het venster telt als annuleren.
## Release 1.1.5
De versienummers in [build.ps1](build.ps1), [installer/Kniip.iss](installer/Kniip.iss), [src/Kniip/Kniip.csproj](src/Kniip/Kniip.csproj) en [README.md](README.md) staan al op 1.1.5. Tag `1.1.5` bestaat nog niet. Deze functie gaat dus mee in dezelfde release als de ShareX-indeling, het vergrootglas en de geschiedenis.
- [CHANGELOG.md](CHANGELOG.md): in de bestaande sectie `## 1.1.5 - 2026-11-09` onder **Nieuw** een item toevoegen in dezelfde stijl, bijvoorbeeld: **Schermafdruk vertraging.** In het systeemvakmenu staat **Schermafdruk vertraging: 0s**. Stel daar met de schuifbalk of het getalveld 0 tot 60 seconden in. Daarna wachten **Rechthoek knippen** en **Venster knippen** (ook via de sneltoets) zo lang voordat het scherm bevriest. Bij de muis telt een venstertje af, en met Esc annuleer je. 0 betekent geen vertraging; screencasts wachten niet.
- `.\build.ps1` draaien en controleren dat de build en de installer slagen.
- Commit "1.1.5" met alle openstaande 1.1.5-wijzigingen, waaronder de nieuwe History- en Magnifier-bestanden. Daarna tag `1.1.5` en pushen, net als bij 1.1.4. De release op git.hibbit.net maak je zelf aan; ik lever de changelogtekst aan.
@@ -0,0 +1,74 @@
---
name: ShareX-indeling editor
overview: "Een tweede editor-indeling toevoegen in de stijl van ShareX: bovenaan de bewerkgereedschappen met daaronder de eigenschappenbalk, onderaan een aparte balk met zoom en de opslag-, upload- en kopieerknoppen. De indeling is te kiezen in Instellingen (tab Editor) en via het rechtsklikmenu van de werkbalk. Uitbrengen als versie 1.1.5."
todos:
- id: settings
content: EditorLayout-enum, EditorLayout- en SplitToolbarOrder-instelling toevoegen aan AppSettings
status: completed
- id: xaml
content: ActionBar met ActionItems (ToolbarPanel) toevoegen aan EditorWindow.xaml
status: completed
- id: code
content: "Werkbalkcode ombouwen: items verdelen over twee panelen, labels/vergrendelen/verbergen/volgorde/omwisselen per paneel, menu Indeling, bijsnijden, OnActivated"
status: completed
- id: settingsui
content: Radioknoppen voor de indeling in de Editor-tab van Instellingen plus herstellen naar standaard
status: completed
- id: version
content: Versie 1.1.5 in csproj, build.ps1 en Kniip.iss en een CHANGELOG-regel
status: completed
- id: verify
content: Bouwen en beide indelingen handmatig testen
status: completed
isProject: false
---
# ShareX-achtige editor-indeling (versie 1.1.5)
## Indeling
- **Eén werkbalk** (standaard, zoals nu): alles in `ToolbarItems`, positie Boven/Onder/Links/Rechts blijft werken.
- **Gesplitst (zoals ShareX)**:
- Boven, gecentreerd: Selecteren, Rechthoek ... Vervagen, Bijsnijden, met daarnaast Wissen, Ongedaan maken, Opnieuw en Verwijderen. Direct daaronder de bestaande eigenschappenbalk `PropsBar` (die al gecentreerd is).
- Onder, boven de statusbalk: links Nieuw en Screencast, in het midden Uitzoomen / 100% / Inzoomen, rechts Instellingen, Opslaan, Uploaden en Kopiëren (met tekstlabels, want daar is ruimte).
```mermaid
flowchart TB
topBar["Boven: gereedschappen + ongedaan maken"]
props["Eigenschappenbalk: kleur, dikte, lijnstijl"]
canvas["Canvas"]
bottomBar["Onder: Nieuw, Screencast | Zoom | Instellingen, Opslaan, Uploaden, Kopiëren"]
status["Statusbalk"]
topBar --> props --> canvas --> bottomBar --> status
```
## Wijzigingen
**[src/Kniip/Settings/AppSettings.cs](src/Kniip/Settings/AppSettings.cs)**
- Nieuwe enum `EditorLayout { Single, Split }` en `public EditorLayout EditorLayout { get; set; } = EditorLayout.Single;`
- Aparte `List<string> SplitToolbarOrder`, zodat versleepte volgordes van beide indelingen elkaar niet overschrijven. `ToolbarHidden` blijft gedeeld.
**[src/Kniip/Editor/EditorWindow.xaml](src/Kniip/Editor/EditorWindow.xaml)**
- Een tweede `Border x:Name="ActionBar" DockPanel.Dock="Bottom"` (na de statusbalk gedeclareerd, dus erboven) met `local:ToolbarPanel x:Name="ActionItems"`, dezelfde `ContextMenuOpening`- en `OrderChanged`-handlers en standaard `Collapsed`.
- De knoppen zelf blijven op één plek in de XAML staan; de code verdeelt ze over de panelen.
**[src/Kniip/Editor/EditorWindow.xaml.cs](src/Kniip/Editor/EditorWindow.xaml.cs)** (rond regels 141-362 voor de werkbalk)
- In `InitializeToolbar` alle items één keer in een dictionary `_toolbarItems` (id naar element) vastleggen, plus een vaste set id's die in de gesplitste indeling onderaan komen: `New, Screencast, Spacer, ZoomOut, Zoom, ZoomIn, Spacer2, Settings, Save, Upload, Copy` (plus de bijbehorende scheidingslijnen).
- `ApplyToolbarOrder` ombouwen tot `ApplyToolbarLayout()`: beide panelen leegmaken en de items volgens de opgeslagen volgorde (`ToolbarOrder` of `SplitToolbarOrder`) verdelen over `ToolbarItems` en `ActionItems`. De logica voor nieuwe items die na hun standaardvoorganger komen, blijft hetzelfde. In de gesplitste indeling krijgt het bovenste paneel `HorizontalAlignment=Center`, `Toolbar` vast `Dock.Top` en wordt `ActionBar` getoond.
- Alle plekken die nu `ToolbarItems.Items` gebruiken, laten werken op beide panelen: `SelectTool` (regel 368), "Knoppen tonen", `ResetToolbar`, de vergrendeling (`IsLocked` op beide panelen, achtergrondkleur op beide balken) en `OrderChanged` (de volgorde van boven + onder samen opslaan in `SplitToolbarOrder`).
- `UpdateToolbarLayout` en `SetActionLabelsVisible` per paneel laten rekenen, zodat de labels op de onderbalk zichtbaar blijven, ook als de bovenbalk vol is. De lus op regel 236 stopt bij het `ToolbarPanel` waarin het label staat, in plaats van alleen bij `ToolbarItems`.
- `SwapToolbarGroups` werkt op het paneel waarop is rechtsgeklikt.
- Rechtsklikmenu: een nieuw submenu "Indeling" met "Eén werkbalk" en "Gesplitst (zoals ShareX)". "Positie" alleen tonen bij Eén werkbalk.
- Op regel 935, waar `Toolbar` tijdens bijsnijden wordt verborgen, ook `ActionBar` verbergen (alleen als de gesplitste indeling aanstaat).
- In `OnActivated` (regel 1508) `ApplyToolbarLayout()` aanroepen als de indeling is veranderd, zodat een wijziging in Instellingen meteen zichtbaar is in een open editor (zoals dat nu ook gebeurt met `ApplySaveButton`).
**[src/Kniip/Settings/SettingsWindow.xaml](src/Kniip/Settings/SettingsWindow.xaml) en [.xaml.cs](src/Kniip/Settings/SettingsWindow.xaml.cs)**
- In de tab Editor (regel 252) een kopje "Werkbalk" met twee radioknoppen `LayoutSingleRadio` / `LayoutSplitRadio` en een korte uitleg, op dezelfde manier geladen en opgeslagen als `SaveOnlyRadio` / `SaveAndCopyRadio`.
- Bij het herstellen van de standaardinstellingen (regel 466) ook `EditorLayout` en `SplitToolbarOrder` meenemen.
**Versie 1.1.5**
- `<Version>1.1.5</Version>` in [src/Kniip/Kniip.csproj](src/Kniip/Kniip.csproj), de standaardwaarde in [build.ps1](build.ps1) (regel 16) en `#define AppVersion` in [installer/Kniip.iss](installer/Kniip.iss).
- In [CHANGELOG.md](CHANGELOG.md) bovenaan `## 1.1.5 - 2026-11-09`, onder `### Nieuw`: **Werkbalkindeling zoals ShareX.**
## Controle
- Bouwen met `build.ps1`, daarna beide indelingen uitproberen: wisselen via Instellingen en via het rechtsklikmenu, knoppen verslepen en verbergen, bijsnijden, een smal venster (de balken moeten netjes doorlopen naar een nieuwe regel) en "Standaard herstellen".
@@ -0,0 +1,94 @@
---
name: Tekstballon en -wolk
overview: Het gereedschap "Tekst in kader" krijgt een vormkeuze (kader, ballon, ovale ballon, wolk). Ballon en wolk hebben een staart met een sleepbare greep op de punt, zodat je ermee iets kunt aanwijzen.
todos:
- id: model
content: TextShape enum, StyleSet.TextShape en StyleProps.TextShape toevoegen in StyleSet.cs
status: completed
- id: geometry
content: "TextAnnotation: TailTip, BuildShape (kader/ballon/ovaal/wolk), Bounds/HitTest/Move/handles"
status: completed
- id: resize
content: "TextAnnotation: BoxWidth/BoxHeight, Box/TextArea, 8 formaatgrepen, woordafbreking, dubbelklik = automatisch; TextBox-editor op TextArea met vaste breedte"
status: completed
- id: ui
content: Vorm-keuzelijst + template in EditorWindow.xaml, wiring in UpdatePropsPanel en TextShape_Changed
status: completed
- id: create
content: Standaard staartpunt bij nieuwe tekst; omtrek+staart zichtbaar tijdens bewerken
status: completed
- id: verify
content: "Bouwen en handmatig testen: vormen wisselen, staart slepen, verplaatsen, kopiëren/plakken, export"
status: completed
isProject: false
---
# Tekstballon en -wolk voor "Tekst in kader"
## Gedrag
- In de eigenschappenbalk van het tekstgereedschap (en van een geselecteerd tekstobject) komt een nieuwe keuzelijst **Vorm**:
- **Kader**: zoals nu, zonder staart.
- **Ballon**: een rechthoek met een spitse staart. De bestaande keuze voor afgeronde hoeken blijft hierbij werken.
- **Ovale ballon**: een ovaal om de tekst, met een spitse staart.
- **Wolk**: een bolle wolkrand met als staart drie steeds kleinere rondjes (een gedachtewolk).
- De punt van de staart is een greep. Je kunt die met het selectiegereedschap of het tekstgereedschap verslepen naar wat je wilt aanwijzen.
- Als je het hele object verplaatst, schuift de staartpunt mee. Als de tekst langer wordt, blijft de punt op dezelfde plek staan, dus hij blijft naar hetzelfde wijzen.
- Een nieuw object krijgt de staartpunt standaard schuin linksonder het kader.
- Ligt de punt binnen de vorm, dan wordt er geen staart getekend.
- De rand, vulling, stippellijn en schaduw lopen als één doorlopende omtrek om de vorm en de staart heen.
- **Formaat aanpassen** (geldt voor alle vormen, ook Kader): een geselecteerd tekstobject krijgt 8 formaatgrepen rond de vorm, net als bij een rechthoek. Zodra je die versleept, krijgt het kader een vaste breedte en loopt de tekst daarbinnen door naar de volgende regel.
- De hoogte die je instelt is een minimum. Past de tekst er niet in, dan groeit het kader naar beneden mee.
- Is het kader hoger dan de tekst, dan staat de tekst verticaal in het midden.
- Een nieuw tekstobject begint zoals nu, met een automatische grootte. Dubbelklik op een formaatgreep zet het kader terug naar automatisch.
- De staartpunt blijft op zijn plek als je het formaat aanpast.
## Technische aanpak
**1. Datamodel** in [src/Kniip/Editor/StyleSet.cs](src/Kniip/Editor/StyleSet.cs)
- Nieuwe enum `TextShape { Frame, Balloon, Oval, Cloud }`.
- Nieuwe stijleigenschap `TextShape TextShape` (standaard `Frame`). Oude instellingen blijven daardoor werken.
- Nieuwe vlag `StyleProps.TextShape`, toe te voegen aan `PropsFor(ToolKind.Text)`.
- De gekozen vorm wordt per gereedschap onthouden via de bestaande `ToolStyles`.
**2. Vorm tekenen** in [src/Kniip/Editor/Annotations/TextAnnotation.cs](src/Kniip/Editor/Annotations/TextAnnotation.cs)
- Nieuwe eigenschap `Point? TailTip`. Zolang die leeg is, wordt de standaardpositie onder het kader berekend.
- Nieuwe hulpfunctie `BuildShape(Rect box)` die de `Geometry` opbouwt:
- **Kader**: de huidige `RectangleGeometry`.
- **Ballon** (zoals in het voorbeeld met het rode kader dat naar het Google-logo wijst): een afgeronde rechthoek, samengevoegd met een spitse driehoekige staart via `Geometry.Combine(..., GeometryCombineMode.Union)`. De voet van de staart zit op de zijde die naar de punt kijkt (links, rechts, boven of onder, afhankelijk van waar de punt ligt). Hij staat ongeveer midden op die zijde en schuift mee richting de punt, maar blijft buiten de afgeronde hoeken. De voet is ongeveer 25% van die zijde breed en de staart loopt spits uit naar de greep.
- **Ovaal**: een `EllipseGeometry` die zo veel ruimer is dan het tekstvlak (factor ongeveer √2) dat de tekst erin past, plus dezelfde staart.
- **Wolk**: een vereniging van cirkels langs de omtrek van een ovaal, plus het ovaal zelf. De staart bestaat uit drie losse rondjes van de rand naar de punt, die steeds kleiner worden.
- Nieuwe eigenschappen `double? BoxWidth` en `double? BoxHeight`. Is `null` een automatische grootte, zoals nu.
- Nieuwe hulpfuncties `Box` (de rechthoek van de vorm zonder staart, met `Position` linksboven) en `TextArea` (het tekstvlak daarbinnen):
- Bij Kader en Ballon is `TextArea` gelijk aan `Box` min de `Padding`.
- Bij Ovaal en Wolk springt `TextArea` extra in, zodat de tekst binnen de ronding blijft.
- Bij een vaste breedte krijgt `FormattedText.MaxTextWidth` de breedte van `TextArea`, zodat de tekst op woorden afbreekt. De hoogte wordt `max(BoxHeight, tekst + padding)`.
- Bij een automatische grootte wordt `Box` uit de tekstmaat berekend, opgeblazen naar de vorm.
- `Render` gebruikt `BuildShape(Box)` in plaats van de vaste rechthoek, en tekent de tekst verticaal gecentreerd in `TextArea`.
- `Bounds` en `HitTest` nemen de buitengrenzen van de geometrie mee, inclusief de staart.
- `GetHandles()` geeft eerst de 8 formaatgrepen van `Box` (zelfde volgorde als in [RectAnnotation.cs](src/Kniip/Editor/Annotations/RectAnnotation.cs)) en daarna, behalve bij Kader, de staartpunt (index 8).
- `BeginHandleDrag` onthoudt de oorspronkelijke `Box`.
- `MoveHandle` voor index 0 tot en met 7 rekent de nieuwe rechthoek uit zoals `RectAnnotation.MoveHandle`, via de bestaande `RectAnnotation.FromAnchor`. Daarna zet het `Position`, `BoxWidth` en `BoxHeight`, met een minimale breedte van ongeveer twee tekens plus padding.
- Index 8 past `TailTip` aan.
- `RemoveHandle` (dubbelklik) op een formaatgreep zet `BoxWidth` en `BoxHeight` terug op `null`.
- `Move` verschuift ook `TailTip`.
**3. Editor** in [src/Kniip/Editor/EditorWindow.xaml](src/Kniip/Editor/EditorWindow.xaml) en [src/Kniip/Editor/EditorWindow.xaml.cs](src/Kniip/Editor/EditorWindow.xaml.cs)
- Een `TextShapeGroup` met `TextShapeCombo` en een `TextShapeTemplate` (klein vormicoontje met een label), naar het voorbeeld van `CursorKindTemplate` en `CursorKindCombo`.
- `TextShapeValues` als `ItemsSource`. In `UpdatePropsPanel` worden zichtbaarheid en selectie gezet. Een handler `TextShape_Changed` roept `ChangeStyle("textshape", s => s.TextShape = shape)` aan.
- Bij het aanmaken van een nieuwe tekst (rond regel 842) wordt de standaard `TailTip` vastgezet. Het verslepen van de greep loopt via het bestaande generieke handle-mechanisme (rond regel 778), daar hoeft niets aan te veranderen.
- `BeginTextEdit` en `ApplyTextEditorStyle` zetten de `TextBox` op `TextArea` in plaats van op `Position`. Bij een vaste breedte krijgt de `TextBox` die breedte en `TextWrapping.Wrap`, zodat je tijdens het typen dezelfde regelafbreking ziet als in het eindresultaat.
- Tijdens het typen blijft de `TextBox` rechthoekig. De staart blijft dan zichtbaar, omdat alleen de tekstlaag verborgen is. Dat is een kleine aanpassing: bij een vorm met staart tekent `Render` de omtrek ook als `IsHidden` aan staat, alleen de tekst niet.
```mermaid
flowchart LR
TextShapeCombo --> ChangeStyle --> StyleSetTextShape["StyleSet.TextShape"]
StyleSetTextShape --> BuildShape
TailTip --> BuildShape
BuildShape --> Render
BuildShape --> Bounds
HandleDrag["Greep slepen"] --> TailTip
```
## Buiten scope
- Een explosie- of uitroepvorm en gebogen staarten. Die zijn later toe te voegen als extra `TextShape`-waarde.
- Draaien van het kader (de draaigreep boven het kader in het voorbeeld).
@@ -0,0 +1,93 @@
---
name: Update-melding blauwe stip
overview: kniip controleert op de achtergrond (bij het starten en daarna elke 24 uur, uit te zetten in Instellingen) op een nieuwe versie. Als die er is, krijgen de knop Instellingen in de editor-werkbalk en de tab Update in Instellingen een blauwe stip.
todos:
- id: setting
content: CheckForUpdates toevoegen aan AppSettings
status: completed
- id: notifier
content: UpdateNotifier maken (controle na het starten en elke 24 uur, AvailabilityChanged-event, Report)
status: completed
- id: app
content: "UpdateNotifier koppelen in App: aanroepen bij het opstarten en in SaveSettings"
status: completed
- id: editor-badge
content: Blauwe stip op de knop Instellingen in EditorWindow
status: completed
- id: settings-tab
content: Blauwe stip op de tab Update, vinkje voor automatische controle, Report na handmatige controle
status: completed
- id: changelog-readme
content: CHANGELOG.md sectie 1.1.4 toevoegen en README bijwerken (achtergrondcontrole, versietabel, build-commentaar)
status: completed
- id: bump-version
content: Versie naar 1.1.4 in build.ps1, installer/Kniip.iss en src/Kniip/Kniip.csproj
status: completed
- id: build-release
content: build.ps1 draaien, commit "1.1.4", tag 1.1.4 en pushen; installer klaarzetten voor de release op git.hibbit.net
status: completed
isProject: false
---
# Blauwe update-melding bij Instellingen en de tab Update
## Huidige situatie
- [src/Kniip/Update/UpdateChecker.cs](src/Kniip/Update/UpdateChecker.cs) haalt releases op met `GetReleasesAsync`, maar dat gebeurt nu alleen in [src/Kniip/Settings/SettingsWindow.xaml.cs](src/Kniip/Settings/SettingsWindow.xaml.cs) zodra de tab Update geopend wordt (`Tabs_SelectionChanged` gevolgd door `CheckForUpdatesAsync`).
- De knop Instellingen staat in [src/Kniip/Editor/EditorWindow.xaml](src/Kniip/Editor/EditorWindow.xaml) (regel 318–321, glyph `&#xE713;`).
- `AccentBrush` is `#0067C0` (blauw) in [src/Kniip/App.xaml](src/Kniip/App.xaml), en die gebruiken we voor de stip.
```mermaid
flowchart LR
AppStart[App start] --> Notifier[UpdateNotifier]
Timer["Timer 24 uur"] --> Notifier
Notifier -->|GetReleasesAsync| Checker[UpdateChecker]
Notifier -->|AvailabilityChanged| EditorBadge[Stip op knop Instellingen]
Notifier -->|AvailabilityChanged| TabBadge[Stip op tab Update]
SettingsCheck[Handmatige controle in tab Update] -->|Report| Notifier
```
## Stappen
1. **Nieuwe instelling** in [src/Kniip/Settings/AppSettings.cs](src/Kniip/Settings/AppSettings.cs):
`public bool CheckForUpdates { get; set; } = true;`
2. **Nieuwe klasse `UpdateNotifier`** in `src/Kniip/Update/UpdateNotifier.cs`:
- Houdt `ReleaseInfo? Available` bij en `bool IsUpdateAvailable`, met een event `AvailabilityChanged` dat op de UI-thread wordt afgevuurd.
- `Apply(bool enabled)`: als de controle aanstaat, wordt die kort na het starten uitgevoerd (ongeveer 10 seconden vertraging, zodat het opstarten niet trager wordt) en daarna via een `DispatcherTimer` elke 24 uur. Als de controle uitstaat, stopt de timer en verdwijnt de stip.
- `CheckAsync()` roept `UpdateChecker.GetReleasesAsync` aan en vangt `UpdateException` stil af, zodat een achtergrondcontrole nooit een foutmelding toont.
- `Report(ReleaseInfo? newest)` wordt gebruikt door de handmatige controle in de tab Update, zodat de stip ook daarmee gelijkloopt.
- "Nieuwer" betekent hetzelfde als in `ShowReleases`: `r.Version > UpdateChecker.CurrentVersion`.
3. **App** ([src/Kniip/App.xaml.cs](src/Kniip/App.xaml.cs)):
- Een property `public UpdateNotifier Updates { get; } = new();`
- Bij het opstarten (na `ApplyHotkeys`) wordt `Updates.Apply(Settings.CheckForUpdates)` aangeroepen.
- In `SaveSettings()` gebeurt dat opnieuw, zodat het aan- of uitzetten van de controle direct effect heeft.
4. **Stip op de knop Instellingen** ([src/Kniip/Editor/EditorWindow.xaml](src/Kniip/Editor/EditorWindow.xaml) en `.xaml.cs`):
- De inhoud van de knop wordt een `Grid` met de bestaande glyph plus `<Ellipse x:Name="SettingsBadge" Width="8" Height="8" Fill="{StaticResource AccentBrush}" Stroke="White" StrokeThickness="1" HorizontalAlignment="Right" VerticalAlignment="Top" Margin="0,-2,-3,0" Visibility="Collapsed" />`.
- In de code-behind abonneert de editor zich op `App.Instance.Updates.AvailabilityChanged`. Bij een wijziging worden `SettingsBadge.Visibility` en de tooltip bijgewerkt (de tooltip wordt "Instellingen - nieuwe versie beschikbaar"). Bij `Closed` wordt het abonnement weer opgezegd.
5. **Stip op de tab Update en een vinkje voor de achtergrondcontrole** ([src/Kniip/Settings/SettingsWindow.xaml](src/Kniip/Settings/SettingsWindow.xaml) en `.xaml.cs`):
- De `Header` van `UpdateTab` wordt een horizontale `StackPanel` met de tekst "Update" en daarachter een blauwe `Ellipse x:Name="UpdateTabBadge"` van dezelfde stijl, met een margin links van 6.
- In de tab Update, onder `CurrentVersionText`, komt `<CheckBox x:Name="AutoUpdateCheckBox" Content="Automatisch controleren op nieuwe versies (bij starten en dagelijks)" />`. Die wordt gevuld in `LoadFrom`, bewaard in `Save_Click` en meegenomen bij Standaardinstellingen.
- Het venster abonneert zich op `AvailabilityChanged` om de zichtbaarheid van de stip te zetten, en zegt dat abonnement op bij `Closed`.
- Na een handmatige controle roept `ShowReleases` `App.Instance.Updates.Report(newer.FirstOrDefault())` aan.
## Gedrag
- De stip blijft zichtbaar zolang er een nieuwere versie is. Na het updaten start kniip met de nieuwe versie, en dan is er geen nieuwere versie meer, dus verdwijnt de stip vanzelf.
- Er komen geen extra meldingen in het systeemvak, zoals afgesproken.
## Changelog en release 1.1.4 (na de codewijzigingen)
6. **[CHANGELOG.md](CHANGELOG.md)**: bovenaan een nieuwe sectie `## 1.1.4 - 2026-11-09` in dezelfde stijl als de eerdere versies, bijvoorbeeld:
- Onder `### Nieuw`: **Melding bij een nieuwe versie.** kniip kijkt bij het starten en daarna elke dag of er een nieuwere versie is. Als die er is, staat er een blauwe stip op de knop **Instellingen** in de editor en op de tab **Update**. Je zet dit uit in de tab **Update** met **Automatisch controleren op nieuwe versies**.
- Onder `### Gewijzigd`: kniip zoekt nu standaard zelf op de achtergrond naar updates. Tot nu toe gebeurde dat alleen als je de tab **Update** opende, zoals vermeld bij 1.1.0.
7. **[README.md](README.md)**:
- In de versietabel 1.1.3 vervangen door 1.1.4, met de link `.../releases/download/1.1.4/Kniip-Setup-1.1.4.exe`.
- Het commentaar `# versie 1.1.3` bij het bouwen wordt `# versie 1.1.4`.
- Bij "Updaten vanuit kniip" komt een zin over de automatische controle en de blauwe stip.
8. **Versienummer** van 1.1.3 naar 1.1.4 in [build.ps1](build.ps1) (`$Version`), [installer/Kniip.iss](installer/Kniip.iss) (`#define AppVersion`) en [src/Kniip/Kniip.csproj](src/Kniip/Kniip.csproj) (`<Version>`).
9. **Bouwen en uitbrengen**, op dezelfde manier als bij 1.1.2 en 1.1.3:
- `.\build.ps1` draaien. Dat levert `artifacts\Kniip-1.1.4.exe` en `artifacts\Kniip-Setup-1.1.4.exe` op.
- Daarna `git add -A`, `git commit -m "1.1.4"`, `git tag 1.1.4` (zonder `v`, net als de vorige tags) en `git push origin HEAD` plus `git push origin 1.1.4`.
- De release op git.hibbit.net maak je, net als eerder, zelf aan. Je hangt daar `artifacts\Kniip-Setup-1.1.4.exe` aan als bijlage en plakt de 1.1.4-tekst uit de changelog in de beschrijving. Ik lever die tekst kant-en-klaar aan.
@@ -0,0 +1,86 @@
---
name: Update-tab met changelog
overview: Add an "Update" tab to the settings that reads releases from git.hibbit.net (public Gitea/Forgejo API), shows the changelog rendered as Markdown, and on "Updaten" downloads the installer, runs it silently and restarts kniip.
todos:
- id: checker
content: "UpdateChecker: releases ophalen via Gitea-API, versies vergelijken"
status: completed
- id: markdown
content: Markdig-referentie + MarkdownRenderer naar FlowDocument
status: completed
- id: installer-dl
content: "UpdateInstaller: download met voortgang en stille start"
status: completed
- id: tab-ui
content: Update-tab in SettingsWindow (xaml + code-behind)
status: completed
- id: app-exit
content: "App: TryCloseAllWindows en --updated-argument"
status: completed
- id: iss
content: "Kniip.iss: herstart na stille installatie"
status: completed
- id: readme
content: "README: releasenotes en publiek-vereiste documenteren"
status: completed
isProject: false
---
# Update-tab met changelog
## Uitgangspunten
- git.hibbit.net heeft een Gitea/Forgejo-API (`/api/v1/version` geeft `28.3.0`). Releases: `GET https://git.hibbit.net/api/v1/repos/rogier/kniip/releases?limit=20`. De repo moet **publiek** worden; nu geeft die URL 404.
- Per release gebruiken we deze velden: `tag_name` (`v1.2.0`), `name`, `body` (markdown-changelog), `published_at`, `draft`, `prerelease`, `html_url` en `assets[]`. Uit `assets[]` zoeken we `name` = `Kniip-Setup-*.exe` en de bijbehorende `browser_download_url` op.
- Alleen handmatig controleren: bij het openen van de tab en met de knop "Opnieuw controleren". Er komt geen achtergrondcontrole.
- Huidige versie: `Assembly.GetEntryAssembly().GetName().Version`, gezet via `-p:Version` in [build.ps1](build.ps1). Niet `InformationalVersion`, want daar zet de .NET 8 SDK een `+commithash` achter.
```mermaid
flowchart LR
Tab[Update-tab geopend] --> Api["GET releases API"]
Api --> Compare{"Nieuwere versie?"}
Compare -->|ja| Show["Changelog van alle nieuwere versies + knop Updaten"]
Compare -->|nee| Current["'Je hebt de nieuwste versie' + changelog huidige versie"]
Show --> Download["Installer downloaden naar TEMP met voortgang"]
Download --> Exit["Vensters sluiten, installer /VERYSILENT starten, kniip afsluiten"]
Exit --> Restart["Installer start kniip --updated"]
```
## Nieuwe bestanden
- `src/Kniip/Update/UpdateChecker.cs`
- Record `ReleaseInfo(Version Version, string Tag, string Title, DateTimeOffset? Date, string Body, string HtmlUrl, string? InstallerUrl, long InstallerSize)`.
- `Task<IReadOnlyList<ReleaseInfo>> GetReleasesAsync(CancellationToken)`: haalt de releases op via één statische `HttpClient` (zoals in [ZiplineUploader.cs](src/Kniip/Upload/ZiplineUploader.cs)) met User-Agent `kniip/<versie>`. Drafts, prereleases en tags die niet als versie te lezen zijn vallen af. Gesorteerd van nieuw naar oud.
- `static Version CurrentVersion`, genormaliseerd naar 3 delen voor de vergelijking.
- `src/Kniip/Update/UpdateInstaller.cs`
- `Task<string> DownloadAsync(ReleaseInfo, IProgress<double>, CancellationToken)` downloadt naar `%TEMP%\kniip-update\Kniip-Setup-<versie>.exe`. Daarna controleren we of de bestandsgrootte gelijk is aan `size` van de asset.
- `Start(string path)` start de installer met `/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /CLOSEAPPLICATIONS`. `UsePreviousTasks` behoudt de eerder gekozen integraties.
- `src/Kniip/Update/MarkdownRenderer.cs`: zet markdown om naar een WPF `FlowDocument` via **Markdig** (nieuwe NuGet-referentie in [Kniip.csproj](src/Kniip/Kniip.csproj)). Een eigen walker over de AST ondersteunt:
- koppen, alinea's, opsommingen (ook genummerd en genest) en citaten
- codeblokken en inline code (Consolas, grijze achtergrond), vet, cursief en doorhalen
- links: `Hyperlink` die de browser opent, met `Process.Start` zoals `OpenUrl` in `SettingsWindow`
- horizontale lijnen
- kleuren en lettertypen uit de bestaande resources (`AccentBrush`, `SubtleTextBrush`, Segoe UI)
## Wijzigingen
- [src/Kniip/Settings/SettingsWindow.xaml](src/Kniip/Settings/SettingsWindow.xaml): nieuwe `TabItem Header="Update"` na "Editor", met:
- `CardTitle` "Update", met daaronder "Geïnstalleerde versie: 1.0.0" en een statusregel (`Hint`).
- Knoppen `AccentButton` "Updaten naar x.y.z" (verborgen als er geen update is), `SecondaryButton` "Opnieuw controleren" en `FlatButton` "Releasepagina".
- `ProgressBar` (verborgen) voor de download.
- Changelog in een `FlowDocumentScrollViewer` met vaste hoogte (~300) en een rand. De tab staat al in een `ScrollViewer`, dus de viewer heeft een vaste hoogte nodig.
- [src/Kniip/Settings/SettingsWindow.xaml.cs](src/Kniip/Settings/SettingsWindow.xaml.cs):
- In `Tabs_SelectionChanged` start de eerste keer dat de Update-tab wordt gekozen de controle (`CheckForUpdatesAsync`). Het resultaat blijft bewaard zolang het venster open is.
- Changelog: per nieuwere release een kop "Versie x.y.z, d MMMM yyyy" met de body eronder. Is er geen update, dan tonen we de notes van de huidige versie. Is er een fout of offline, dan tonen we een melding in de statusregel.
- `Update_Click`:
1. Bevestigen met "kniip wordt afgesloten en bijgewerkt".
2. Downloaden met voortgang. Tijdens het downloaden zijn de knoppen uitgeschakeld en annuleert sluiten van het venster de download.
3. `App.Instance.TryExitForUpdate()`. Lukt dat, dan `UpdateInstaller.Start` en `Shutdown`.
- [src/Kniip/App.xaml.cs](src/Kniip/App.xaml.cs):
- Splits `ExitApplication` op in `bool TryCloseAllWindows()`. Die stopt als er een screencast loopt of als een editor het sluiten tegenhoudt, zodat we de installer pas starten als kniip echt kan stoppen.
- Nieuw argument `--updated` in `HandleArguments`: toont de ballon "kniip is bijgewerkt naar versie x.y.z" en gedraagt zich verder als `--tray`.
- [installer/Kniip.iss](installer/Kniip.iss): extra `[Run]`-regel die kniip opnieuw start na een stille installatie:
`Filename: "{app}\{#AppExe}"; Parameters: "--updated"; Flags: nowait; Check: WizardSilent`
- [README.md](README.md), sectie "Een nieuwe versie publiceren": de releasebeschrijving (markdown) wordt de changelog in kniip, de installerbijlage moet `Kniip-Setup-<versie>.exe` heten, en de repo moet publiek zijn voor de updatecontrole.
## Randgevallen
- Draai je kniip als losse exe (niet geïnstalleerd), dan installeert de update in `%LOCALAPPDATA%\Programs\Kniip`. Dat is acceptabel; het staat in de bevestiging.
- Een release zonder installerbijlage: geen knop Updaten, wel een link naar de releasepagina.
- Een oude download in `%TEMP%\kniip-update` wordt overschreven.
@@ -0,0 +1,66 @@
---
name: Vergrootglas bij selectie
overview: Add a magnifier (circular zoom with crosshair and X/Y coordinates near the cursor) to the snip overlay and the screencast region selector, with a settings checkbox that is on by default. Release as 1.1.5.
todos:
- id: setting
content: ShowMagnifier (default true) toevoegen aan AppSettings
status: completed
- id: control
content: "MagnifierControl maken: ronde zoom, raster, kruisdraad, X/Y-badge, plaatsing bij cursor met randcorrectie"
status: completed
- id: snip
content: Vergrootglas in RegionCaptureWindow + doorgeven vanuit App.BeginSnip
status: completed
- id: screencast
content: Vergrootglas in RegionSelectorWindow met vooraf vastgelegd beeld vanuit RecordingController.BeginSelection
status: completed
- id: settings-ui
content: Checkbox in SettingsWindow (tab Sneltoetsen) laden en opslaan
status: completed
- id: release
content: CHANGELOG 1.1.5, README, versienummers, build.ps1 draaien, commit/tag 1.1.5 en pushen
status: completed
isProject: false
---
# Vergrootglas bij het kiezen van een gebied (1.1.5)
## Starting point
- kniip has no magnifier yet. The snip overlay [src/Kniip/Capture/RegionCaptureWindow.xaml](src/Kniip/Capture/RegionCaptureWindow.xaml) shows a frozen screenshot (`Frozen`), a dim layer, the selection and a `W × H` badge.
- The screencast selector [src/Kniip/Recording/RegionSelectorWindow.xaml](src/Kniip/Recording/RegionSelectorWindow.xaml) is a transparent overlay over the live screen. It has no image to zoom into.
## Approach
```mermaid
flowchart LR
Settings["AppSettings.ShowMagnifier"] --> App["App.BeginSnip"]
Settings --> Rec["RecordingController.BeginSelection"]
App -->|"frozen image + flag"| Snip[RegionCaptureWindow]
Rec -->|"capture per screen + flag"| Sel[RegionSelectorWindow]
Snip --> Mag[MagnifierControl]
Sel --> Mag
```
1. **Setting**: in [src/Kniip/Settings/AppSettings.cs](src/Kniip/Settings/AppSettings.cs) add
`public bool ShowMagnifier { get; set; } = true;` with a Dutch doc comment.
2. **Shared control**: new `src/Kniip/Capture/MagnifierControl.xaml(.cs)` (UserControl, `IsHitTestVisible="False"`), styled like the screenshot:
- A round clipped area (about 130 DIP) showing an `Image` of the frozen bitmap with `NearestNeighbor` scaling. A `CroppedBitmap` of about 15x15 physical pixels around the cursor is enlarged around 8x.
- A pixel grid, a blue crosshair through the center pixel and a small square on the center pixel. The crosshair uses `AccentBrush`, the ring is white with a shadow.
- Below the circle, a badge in the same style as `SizeBadge` with `X: … Y: …` in physical screen coordinates.
- Method `Update(BitmapSource frozen, System.Drawing.Point physicalRelative, System.Drawing.Point screenPoint)`. Pixels outside the image are left black.
- Placement: bottom-right of the cursor at about 20 DIP offset. It flips left and/or up near the screen edge.
3. **Snip overlay** [src/Kniip/Capture/RegionCaptureWindow.xaml.cs](src/Kniip/Capture/RegionCaptureWindow.xaml.cs):
- Add a constructor parameter `bool showMagnifier` and add `<local:MagnifierControl x:Name="Magnifier" Visibility="Collapsed"/>` to the Grid.
- In `OnMouseMove`, update and show the magnifier while it is on (before and during dragging). Not in window-pick mode (`PicksWindow`), because exact pixels don't matter there. Hide it on `MouseLeave`.
- [src/Kniip/App.xaml.cs](src/Kniip/App.xaml.cs) `BeginSnip` passes `Settings.ShowMagnifier`.
4. **Screencast selector** [src/Kniip/Recording/RegionSelectorWindow.xaml.cs](src/Kniip/Recording/RegionSelectorWindow.xaml.cs):
- Add an optional `BitmapSource? frozen` constructor parameter. When it is set, the same magnifier runs in `OnMouseMove` (also while hovering over windows, because you can drag from there).
- [src/Kniip/Recording/RecordingController.cs](src/Kniip/Recording/RecordingController.cs) `BeginSelection`: if `_settings.ShowMagnifier` is on, call `ScreenGrabber.Capture(screen.Bounds)` per screen *before* the selectors are shown, so the overlay is not in the image. Otherwise pass `null`.
5. **Settings screen** [src/Kniip/Settings/SettingsWindow.xaml](src/Kniip/Settings/SettingsWindow.xaml) tab **Sneltoetsen**: below the hotkeys, add a card title "Gebied kiezen" with
`<CheckBox x:Name="MagnifierBox" Content="Vergrootglas tonen bij het kiezen van een gebied (knipsel en screencast)" />`.
In [SettingsWindow.xaml.cs](src/Kniip/Settings/SettingsWindow.xaml.cs), add a line to `LoadFrom` (around line 100) and to the save step (around line 444).
6. **Release 1.1.5**:
- [CHANGELOG.md](CHANGELOG.md) gets a new section `## 1.1.5 - 2026-11-09` with a **Nieuw** entry for the magnifier (round zoom with crosshair and X/Y, for both snips and screencasts) and how to turn it off.
- [README.md](README.md): the version table and build comment go to 1.1.5.
- Version 1.1.4 becomes 1.1.5 in [build.ps1](build.ps1), [installer/Kniip.iss](installer/Kniip.iss) and [src/Kniip/Kniip.csproj](src/Kniip/Kniip.csproj).
- Run `.\build.ps1`, then commit "1.1.5", tag `1.1.5` and push, the same way as 1.1.4. You create the release on git.hibbit.net yourself; I'll supply the changelog text.
+85
View File
@@ -0,0 +1,85 @@
---
name: Vormenknop
overview: Een nieuwe werkbalkknop "Vormen" naast Rechthoek en Ellips, met in de eigenschappenbalk een keuzelijst voor 11 vormen (driehoek, pijl, ster, wolk, ballon, hart, maan, druppel, ei, bliksemschicht, spiraal). Je tekent een vorm door een kader te slepen, net als bij een rechthoek.
todos:
- id: model
content: ToolKind.Shape, ShapeKind-enum, StyleSet.ShapeKind en StyleProps.ShapeKind in StyleSet.cs
status: completed
- id: geometries
content: "ShapeGeometries.cs: de 11 vormen in een eenheidsvierkant, plus IsOpen en Icon; ShapeIconConverter"
status: completed
- id: annotation
content: "FigureAnnotation.cs: Render met transform, HitTest (open/gesloten), spiegelen"
status: completed
- id: ui
content: Werkbalkknop, ShapeKindTemplate, ShapeGroup/ShapeKindCombo in EditorWindow.xaml
status: completed
- id: wiring
content: "EditorWindow.xaml.cs: CreateAnnotation, UpdatePropsPanel, ShapeKind_Changed, knopicoon, DefaultSplitOrder, sneltoets F, hinttekst"
status: completed
- id: verify
content: "Bouwen en handmatig testen: alle vormen tekenen, wisselen, Shift, grepen, spiegelen, kopiëren/plakken, export"
status: completed
isProject: false
---
# Vormenknop met 11 vormen
## Gedrag
- Er komt een nieuwe gereedschapsknop **Vormen** direct na Ellips. Rechthoek en Ellips blijven zoals ze zijn.
- In de eigenschappenbalk staat een keuzelijst **Vorm** (icoon plus naam): Driehoek, Pijl, Ster, Wolk, Ballon, Hart, Maan, Druppel, Ei, Bliksemschicht, Spiraal.
- Het icoon op de werkbalkknop toont de laatst gekozen vorm. De keuze wordt per gereedschap onthouden, net als de andere stijlinstellingen.
- Tekenen gaat door een kader te slepen; de vorm vult dat kader. Met Shift wordt het kader vierkant. Er zijn 8 formaatgrepen, net als bij een rechthoek.
- Eigenschappen: randkleur, vulkleur, dikte, stippellijn en schaduw. De spiraal is een open lijn en krijgt geen vulling.
- Bij een geselecteerde vorm kun je via de keuzelijst een andere vorm kiezen (met undo).
- Spiegelen (horizontaal en verticaal) werkt voor vormen, zodat de pijl, maan, bliksem, ballon en spiraal ook de andere kant op kunnen.
- Sneltoets **F** (figuur), want de meeste voor de hand liggende letters zijn al bezet.
- Aanname: **Ballon** is hier een tekstballon zonder tekst (afgeronde rechthoek met een staart linksonder) en **Wolk** een gedachtewolk zonder staart, als losse vorm. Voor een ballon of wolk met tekst blijft het tekstgereedschap bestaan.
## Technische aanpak
**1. Model** in [src/Kniip/Editor/StyleSet.cs](src/Kniip/Editor/StyleSet.cs)
- `ToolKind.Shape` achteraan toevoegen. Instellingen worden als tekst opgeslagen (`JsonStringEnumConverter`), dus de volgorde breekt niets.
- Nieuwe enum `ShapeKind { Triangle, BlockArrow, Star, Cloud, Balloon, Heart, Moon, Drop, Egg, Lightning, Spiral }`.
- `StyleSet.ShapeKind` (standaard `Triangle`) en vlag `StyleProps.ShapeKind`.
- `PropsFor(ToolKind.Shape)` = Stroke, Fill, Thickness, Dash, Shadow, ShapeKind. `DefaultFor` gebruikt de gewone standaard (rode rand, geen vulling).
**2. Geometrie** in een nieuw bestand `src/Kniip/Editor/Annotations/ShapeGeometries.cs`
- `Geometry For(ShapeKind kind)` bouwt elke vorm in een eenheidsvierkant (0..1), gecachet en bevroren, zoals [CursorGeometries.cs](src/Kniip/Editor/Annotations/CursorGeometries.cs):
- Driehoek, Pijl (blokpijl naar rechts), Ster (5 punten), Bliksemschicht: rechte polygonen.
- Hart, Druppel, Ei, Maan, Ballon: Bezier- en boogsegmenten in een `StreamGeometry`. De maan wordt een cirkel min een verschoven cirkel (`GeometryCombineMode.Exclude`).
- Wolk: een vereniging van cirkels, zoals `TextAnnotation` dat al doet.
- Spiraal: een open polyline langs een Archimedes-spiraal (ongeveer 3 omwentelingen).
- `bool IsOpen(ShapeKind)` geeft true voor Spiraal.
- `Geometry Icon(ShapeKind)` schaalt dezelfde vorm naar 20x20 voor de knop en de keuzelijst. Er komt een `ShapeIconConverter` naar het voorbeeld van [CursorIconConverter.cs](src/Kniip/Editor/CursorIconConverter.cs).
**3. Annotatie**: nieuw `src/Kniip/Editor/Annotations/FigureAnnotation.cs`, afgeleid van [RectAnnotation.cs](src/Kniip/Editor/Annotations/RectAnnotation.cs), zodat grepen, verplaatsen en Shift al werken.
- `Kind => ToolKind.Shape`. De vorm komt uit `Style.ShapeKind`, zodat wisselen via `ChangeStyle` meteen werkt.
- `Render`: de eenheidsgeometrie klonen met een `MatrixTransform` naar `Rect` (plus een spiegeling met `FlipX`/`FlipY`). De lijndikte schaalt daardoor niet mee. Tekenen gebeurt via `RenderHelpers.DrawWithShadow`, zonder vulling bij een open vorm.
- `HitTest`: `FillContains` voor gesloten vormen, `StrokeContains` met tolerantie voor de spiraal.
- `CanFlip => true`; `Flip` wisselt `FlipX` of `FlipY`.
**4. Editor** in [src/Kniip/Editor/EditorWindow.xaml](src/Kniip/Editor/EditorWindow.xaml) en [EditorWindow.xaml.cs](src/Kniip/Editor/EditorWindow.xaml.cs)
- XAML: een `RadioButton Tag="Shape" ItemId="Shape" Title="Vormen"` na Ellips, met een `Path x:Name="ShapeToolIcon"`. Daarnaast een `ShapeKindTemplate` (icoon via de converter, plus een label per waarde, net als `CursorKindTemplate`) en een `ShapeGroup` met `ShapeKindCombo` in de eigenschappenbalk.
- Code:
- `ShapeKindValues` als `ItemsSource`.
- `"Shape"` na `"Ellipse"` in `DefaultSplitOrder`. `MergeToolbarOrder` zet de nieuwe knop bij bestaande gebruikers automatisch na Ellips.
- `CreateAnnotation`: `ToolKind.Shape => new FigureAnnotation { Rect = new Rect(p, p), Style = style }`.
- `UpdatePropsPanel`: zichtbaarheid van `ShapeGroup` en selectie van `ShapeKindCombo`. De vulling wordt verborgen bij Spiraal.
- Handler `ShapeKind_Changed` roept `ChangeStyle("shape", s => s.ShapeKind = kind)` aan en werkt `ShapeToolIcon` bij. Bij het starten wordt het icoon ook gezet.
- Sneltoets `Key.F` en een hinttekst: "Sleep om de gekozen vorm te tekenen. Shift = gelijke breedte en hoogte. Kies een andere vorm in de balk eronder."
```mermaid
flowchart LR
ShapeKindCombo --> ChangeStyle --> StyleShapeKind["StyleSet.ShapeKind"]
StyleShapeKind --> ShapeGeometries
ShapeGeometries --> FigureAnnotation
ShapeGeometries --> ShapeIconConverter
ShapeIconConverter --> ShapeToolIcon
ShapeIconConverter --> ShapeKindTemplate
```
## Buiten scope
- Draaien van vormen. Een pijl omhoog of omlaag kan daardoor niet; links en rechts wel, via spiegelen.
- Tekst in een vorm. Daarvoor blijft het tekstgereedschap met ballon en wolk.
- Instelbare details per vorm, zoals het aantal punten van de ster, de dikte van de maan of het aantal windingen van de spiraal.
+217
View File
@@ -0,0 +1,217 @@
# Changelog
Alle noemenswaardige wijzigingen in kniip staan in dit bestand. De nieuwste versie staat bovenaan.
## 2.0.0 - 2026-11-10
### Nieuw
- **OCR (tekst herkennen).**\
Lees tekst van het scherm en kopieer hem. Naast de knoppen voor een schermafdruk en een screencast staat nu de knop **OCR**, met hetzelfde icoon als in het Knipprogramma. Klik erop, sleep een rechthoek, en de tekst daarin opent in een apart venster. Daar pas je hem aan en kopieer je alles, of alleen wat je selecteert. De editor gaat tijdens het kiezen even opzij en komt daarna terug.
- Druk in de editor op Ctrl+Shift+T om de tekst van het geopende knipsel te lezen, met je aantekeningen erbij.
- Je vindt OCR ook in het menu van het icoon in het systeemvak, onder **OCR (tekst herkennen)...**. Geef het in de instellingen onder **Sneltoetsen** een eigen sneltoets.
- kniip gebruikt de tekstherkenning van Windows, in de talen van je Windows-account. Vindt Windows geen taal voor tekstherkenning, dan staat in het venster hoe je er een toevoegt.
- **Verloop in elke richting.**\
De richting van een verloop als achtergrond kies je nu met een draaiknop in plaats van uit vier vaste richtingen. Klik op de knop naast de kleuren en sleep de knop rond de cirkel. Je ziet het verloop meteen meedraaien.
- Hij klikt vast op de acht hoofdrichtingen. Houd Shift ingedrukt voor stappen van 15°, of Alt om niet vast te klikken. Je kunt ook de hoek in graden typen, of scrollen boven de knop.
- **Vanuit het midden** staat nu als knop onder de cirkel.
- Elke nieuwe richting is één stap in **Ongedaan maken**, ook als je lang sleept.
### Gewijzigd
- Het blokjespatroon achter transparante delen is lichter en daardoor rustiger om naar te kijken.
## 1.1.9 - 2026-11-10
### Gewijzigd
- Klik je op het icoon in het systeemvak, dan maak je direct een schermafdruk: sleep een rechthoek om te knippen. Het menu opent nog steeds met de rechtermuisknop. Daarin staat **Rechthoek knippen** nu vetgedrukt als standaardactie, en **Bewerken vanaf klembord** staat er nog gewoon in.
## 1.1.8 - 2026-11-10
### Nieuw
- **Transparant canvas en achtergrond.**\
Een leeg canvas is nu transparant in plaats van wit. In de editor zie je dat aan een blokjespatroon, en een opgeslagen PNG houdt de transparantie. Wil je toch een achtergrond, kies dan het selectiegereedschap zonder iets te selecteren: in de balk met eigenschappen staat **Achtergrond**. Kies **Geen (transparant)**, **Kleur**, **Verloop** (twee kleuren en een richting) of **Afbeelding** (vult het hele canvas).
- De achtergrond komt alleen onder de transparante delen. Bij een gewoon schermknipsel staat de keuze er daarom niet. Bij een geopende PNG met transparantie wel.
- Vervagen en pixeleren werken ook op de achtergrond. **Samenvoegen** laat de achtergrond apart, zodat je hem daarna nog kunt wijzigen.
- Een JPEG kent geen transparantie. Daarin, en in de bitmap op het klembord voor programma's die geen PNG lezen, worden transparante delen wit.
- Sommige programma's zetten een afbeelding op het klembord die volledig doorzichtig lijkt. Zo'n afbeelding opent kniip nu als gewone, ondoorzichtige afbeelding, zodat hij niet onzichtbaar wordt.
- Elke wijziging van de achtergrond is één stap in **Ongedaan maken**.
- **Drie-split-weergave.**\
Een derde indeling voor de editor: de gereedschappen staan boven, **Opslaan**, **Uploaden**, **Kopiëren** en de zoomknoppen onder, en **Nieuw** en **Screencast** apart. Kies de indeling in de instellingen onder **Weergave** > **Werkbalk**, of klik met rechts op een werkbalk en kies **Indeling**.
- De balk met **Nieuw** en **Screencast** staat links, rechts, boven of onder het canvas, of **Zwevend** boven het canvas. Kies de plek in de instellingen of via rechtsklik > **Nieuw en Screencast**.
- Zwevende knoppen versleep je aan het greepje of de rand naar elke plek boven het canvas. Bij de boven- of onderrand klikken ze vast en liggen ze horizontaal, verder liggen ze verticaal. De plek wordt onthouden. Klik met rechts en kies **Zwevende knoppen vastzetten** om ze niet per ongeluk te verslepen.
- In de balk staan alleen de iconen. Ctrl+H verbergt ook deze balk, en **Standaard herstellen** zet hem weer links.
- **Linialen en hulplijnen.**\
Boven en links van het canvas staan linialen met een schaalverdeling in pixels van de afbeelding. Ze zoomen en scrollen mee, en een blauw streepje laat zien waar de muis staat. Zet ze aan of uit met de knop **Linialen** naast de zoomknoppen, of met Ctrl+R.
- Sleep vanuit een liniaal naar het canvas voor een hulplijn: de bovenste liniaal geeft een horizontale lijn, de linker een verticale. Met het selectiegereedschap versleep je een hulplijn. Sleep hem terug naar de liniaal of buiten de afbeelding om hem te verwijderen. Klik met rechts op het hoekje tussen de linialen of op het canvas en kies **Hulplijnen wissen** om ze allemaal weg te halen.
- Objecten klikken vast aan hulplijnen en aan de randen en het midden van de afbeelding: bij verplaatsen met de linker-, midden- of rechterkant (en boven, midden of onder), bij tekenen, bij het slepen van grepen en bij bijsnijden. De lijn waar het object aan vastklikt, licht roze op. Houd Alt ingedrukt om niet vast te klikken.
- Hulplijnen zijn alleen een hulpmiddel in de editor. Ze komen niet in het opgeslagen of gekopieerde knipsel.
- **Uitlijnen, spiegelen en volgorde.**\
Zodra je een object selecteert, staat in de balk met eigenschappen de knop **Uitlijnen**. Klik erop en er klappen knoppen uit om het object links, in het midden of rechts, en boven, in het midden of onder op de afbeelding te zetten. Ook kun je het horizontaal of verticaal spiegelen, en naar voren of naar achteren brengen. Klik nog eens op **Uitlijnen** om de knoppen weer in te klappen. Ze klappen ook in als je niets meer selecteert. Dezelfde opties staan in het rechtsklikmenu van een object, onder **Uitlijnen op de afbeelding**. Daar staat ook **Midden van de afbeelding**.
- Spiegelen werkt voor lijnen, pijlen, markeringen uit de vrije hand, cursors, emoji en vormen. Bij rechthoeken, ellipsen, tekst, stappen en vervagen is de knop uitgeschakeld.
- Elke klik is één stap in **Ongedaan maken**.
- **Vormen.**\
Na **Ellips** staat een nieuwe knop **Vormen** (sneltoets F). Kies in de balk met eigenschappen welke vorm je tekent: driehoek, pijl, ster, wolk, ballon, hart, maan, druppel, ei, bliksemschicht of spiraal. Het icoon op de knop laat de gekozen vorm zien.
- Sleep een kader om de vorm te tekenen. Met Shift worden breedte en hoogte gelijk. Met de grepen pas je het formaat later aan.
- Je kiest randkleur, vulkleur, dikte, stippellijn en schaduw, net als bij een rechthoek. De spiraal is een open lijn en heeft geen vulling.
- Selecteer een vorm en kies een andere in de keuzelijst om hem te vervangen. Spiegel of draai een pijl, maan of bliksem om hem een andere kant op te laten wijzen.
- **Draaien van vormen en emoji.**\
Boven een geselecteerde vorm of emoji staat een ronde greep. Sleep die rond om het object te draaien. Het draait om zijn midden en klikt vast op 0°, 90°, 180° en 270°. Houd Shift ingedrukt voor stappen van 15°, of Alt om niet vast te klikken. Tijdens het draaien staat de hoek onderin de statusbalk.
- Onder **Uitlijnen** staan knoppen om 90° linksom of rechtsom te draaien. Die opties staan ook in het rechtsklikmenu, samen met **Draaiing herstellen**. Dubbelklik op de ronde greep om de draaiing ook terug te zetten.
- Een gedraaide vorm maak je met de grepen gewoon groter of kleiner. De schaduw valt altijd rechtsonder.
- Elke draaiing is één stap in **Ongedaan maken**.
- **Tekstballon en -wolk.**\
Bij **Tekst in kader** kies je in de balk met eigenschappen de vorm: **Kader**, **Ballon**, **Ovale ballon** of **Wolk**. Ballon en wolk hebben een staart waarmee je iets aanwijst.
- Sleep de punt van de staart naar wat je wilt aanwijzen. Verplaats je het hele object, dan schuift de punt mee.
- Bij een wolk bestaat de staart uit steeds kleinere bolletjes. Kies het aantal bolletjes, of laat het op **Auto** staan.
- Een geselecteerd tekstobject heeft formaatgrepen. Maak het kader breder of smaller, dan loopt de tekst door naar de volgende regel. Dubbelklik op een greep om het kader weer automatisch op de tekst te laten passen.
## 1.1.7 - 2026-11-09
### Gewijzigd
- Het icoon in het systeemvak is groter en sluit aan bij de andere systeemvakpictogrammen.
- De tekst bij het icoon is nu **kniip**. Tijdens een opname staat daar **kniip - opname loopt**.
## 1.1.6 - 2026-11-09
### Nieuw
- **Donkere modus.**\
Kies in de instellingen onder **Weergave** > **Kleurmodus** tussen **Licht**, **Donker** en **Systeem**. Bij **Systeem** volgt kniip de app-modus van Windows (Instellingen > Persoonlijke instellingen > Kleuren) en wisselt mee zodra je die verandert.
- De kleurmodus geldt voor alle vensters: de editor, de instellingen, de geschiedenis, de screencastvensters, de meldingen en het systeemvakmenu. Ook de titelbalk wordt donker.
- Na **Opslaan** wisselen open vensters meteen mee. Je knipsel zelf verandert niet van kleur.
- **Licht** is de standaard, dus na de update ziet kniip er hetzelfde uit tot je iets anders kiest.
### Gewijzigd
- De tab **Editor** in de instellingen heet nu **Weergave**. Daar staan nu de kleurmodus, de markeerstift en de werkbalkindeling.
- De tab **Windows** heet nu **Systeem** en staat onder **Weergave**. De tabs **Knipsels** en **Nieuw knipsel** zijn opgegaan in de tab **Systeem**. Bovenaan staat de sectie **Knipprogramma** (klembord en map Schermafbeeldingen bewaken), daaronder **Bij een nieuw knipsel** en daarna **Windows-integratie**. De instellingen openen nu op de tab **Sneltoetsen**.
- Vinkjes en keuzerondjes in de instellingen hebben een nieuwe, rustigere stijl in de kleur van kniip.
### Opgelost
- De tekst in een tab van de instellingen was helemaal blauw en vet, net als de naam van de tab. Nu is alleen de tabnaam zo opgemaakt. De changelog in de tab **Update** volgt weer de markdown: gewone tekst normaal, koppen en **vet** alleen waar dat in de tekst staat.
## 1.1.5 - 2026-11-09
### Nieuw
- **Split-weergave.**\
De editor kan nu ook twee werkbalken tonen. Bovenaan staan de gereedschappen in het midden, met **Instellingen** rechts en daaronder de eigenschappen (kleur, dikte, lijnstijl). Onderaan staan **Nieuw** en **Screencast** links, **Opslaan**, **Uploaden** en **Kopiëren** in het midden en de zoomknoppen rechts. Kies de indeling in de instellingen onder **Editor** > **Werkbalk**, of klik met rechts op een werkbalk en kies **Indeling**.
- In beide werkbalken kun je knoppen verslepen (na ontgrendelen) en verbergen, net als bij één werkbalk. Beide indelingen onthouden hun eigen volgorde.
- Split-weergave is de nieuwe standaard. Wil je de vertrouwde indeling terug, kies dan **Eén werkbalk**.
- **Vergrootglas bij het kiezen van een gebied.**\
Bij het knippen van een rechthoek en bij het kiezen van een opnamegebied voor een screencast loopt naast de muisaanwijzer een rond vergrootglas mee. Je ziet de pixels rond de aanwijzer vergroot met een raster en een blauwe kruisdraad, en daaronder de schermcoördinaten (X en Y). Zo zet je een hoek precies op de goede pixel. Bij **Venster knippen** verschijnt het vergrootglas niet.
- Het vergrootglas staat standaard aan. Wil je het niet, zet dan in de instellingen onder **Sneltoetsen** > **Gebied kiezen** het vinkje **Vergrootglas tonen bij het kiezen van een gebied** uit.
- **Schermafdruk vertraging.**\
In het systeemvakmenu staat **Schermafdruk vertraging: 0s**. Stel daar met de schuifbalk of het getalveld 0 tot 60 seconden in. **Rechthoek knippen** en **Venster knippen** wachten dan zo lang voordat het scherm bevriest, ook via de sneltoets. Zo kun je eerst een menu openklappen of een tooltip laten verschijnen.
- Bij de muis telt een klein venster af. Dat venster komt zelf niet in het knipsel. Druk op Esc of klik op het venster om te annuleren.
- Bij 0 seconden knip je direct, zoals voorheen. Screencasts en de knop **Nieuw** in de editor wachten niet.
- **Geschiedenis.**\
kniip houdt nu bij wat je opslaat en uploadt: knipsels en screencasts, met het bestand en de link. Knipsels die je alleen kopieert of niet bewaart, komen er niet in. Het systeemvakmenu heeft vier nieuwe items:
- **Map openen**, met de map Schermafbeeldingen, je knipselmap en je screencastmap.
- **Geschiedenis...** opent een lijst met datum, soort, naam, map en link. Je kunt zoeken op naam of link.
- **Afbeeldingsgeschiedenis...** opent hetzelfde venster met miniaturen. Ook een knipsel dat je alleen hebt geüpload heeft een miniatuur.
- **Recente items** toont de laatste tien. Klik op een item om het te openen, of ga naar het submenu voor **Openen in de editor**, **Link kopiëren**, **Afbeelding kopiëren**, **Pad kopiëren** en **Tonen in map**.
Dubbelklik in het geschiedenisvenster om een item te openen. Klik met rechts voor dezelfde acties en voor **Uit geschiedenis verwijderen**. Met **Geschiedenis wissen** maak je de lijst leeg. Je bestanden en uploads blijven dan gewoon bestaan. kniip bewaart de laatste 500 items.
## 1.1.4 - 2026-11-09
### Nieuw
- **Melding bij een nieuwe versie.**\
kniip kijkt bij het starten en daarna elke dag of er een nieuwere versie is. Is die er, dan staat er een blauwe stip op de knop **Instellingen** in de editor en op de tab **Update** in de instellingen. Wil je dat niet, zet dan in de tab **Update** het vinkje **Automatisch controleren op nieuwe versies** uit.
- **Uitgebreid rechtermuisknopmenu in de editor.**\
Klik met rechts op het canvas, ook op een leeg stuk, voor een menu met iconen en sneltoetsen. Wat op dat moment niet kan, staat grijs.
- **Ongedaan maken** en **Opnieuw**.
- **Verwijder**, **Verwijder alles** (Shift+Delete, de afbeelding blijft staan) en **Samenvoegen** (Ctrl+Shift+F). Samenvoegen maakt de annotaties deel van de afbeelding en kun je ongedaan maken.
- **Knip** (Ctrl+X), **Kopieer** (Ctrl+Shift+C), **Plak** (Ctrl+V) en **Dupliceren** (Ctrl+D). Plak via het menu zet de annotatie op de plek waar je rechts klikte. Kopiëren en plakken werkt ook tussen twee editorvensters.
- **Naar voorgrond** (Home), **Naar voren** (PageUp), **Naar achteren** (PageDown) en **Naar achtergrond** (End).
- **Werkbalken verbergen** (Ctrl+H). Via hetzelfde menu of Ctrl+H haal je ze terug. Bij een nieuwe start zijn ze er altijd weer.
- **Lijnen en pijlen buigen.**\
Selecteer een lijn of pijl. Midden op elk stuk staat dan een klein rondje. Sleep dat rondje en het wordt een buigpunt. De lijn loopt daarna als vloeiende kromme door alle punten. Zo kun je zoveel buigpunten toevoegen als je wilt.
- Dubbelklik op een buigpunt om het weer te verwijderen.
- Bij een pijl draait de punt mee met de richting van het laatste stuk.
- Met Shift springen begin- en eindpunt in stappen van 45°, gemeten vanaf het buigpunt ernaast.
- Toevoegen, verschuiven en verwijderen van buigpunten kun je ongedaan maken.
### Gewijzigd
- kniip zoekt nu standaard zelf op de achtergrond naar updates. Tot nu toe gebeurde dat alleen als je de tab **Update** opende of op **Opnieuw controleren** klikte. Een mislukte controle, bijvoorbeeld zonder internet, geeft geen foutmelding.
- **Ctrl+V** plakt nu eerst een gekopieerde annotatie. Heb je daarna iets anders gekopieerd, dan opent Ctrl+V zoals voorheen de afbeelding van het klembord.
- Het rechtermuisknopmenu van de werkbalk heeft dezelfde nieuwe stijl gekregen.
- **Testversie naast kniip.**\
`build.ps1` maakt nu ook `KniipTest-<versie>.exe`. Die testversie heeft eigen instellingen en registervermeldingen, zodat hij naast de geïnstalleerde kniip kan draaien zonder die te raken. De testversie werkt zichzelf niet bij. Met `-SkipTest` sla je hem over.
## 1.1.3 - 2026-11-09
### Nieuw
- **Sneltoets voor het klembord.**\
In de tab **Sneltoetsen** kun je een combinatie zetten op **Vanaf klembord**. Die opent de afbeelding op het klembord in de editor. Standaard is er geen sneltoets. De gekozen combinatie staat ook bij **Bewerken vanaf klembord** in het systeemvakmenu.
- **Afbeelding naar de editor slepen.**\
Sleep een afbeeldingsbestand, of een afbeelding uit een ander programma, naar het kniip-venster. kniip vraagt daarna of je die afbeelding wilt openen, zodat een lopende bewerking niet zomaar verdwijnt.
## 1.1.2 - 2026-11-09
### Opgelost
- **Link kopiëren na uploaden.**\
Na het uploaden kon kniip de link soms niet op het klembord zetten en meldde dan dat het klembord bezet was. Dat gebeurde vooral als de klembordgeschiedenis van Windows (Win+V) of een ander klembordprogramma actief is. kniip zet de link nu op een directere manier op het klembord en probeert het langer opnieuw. Dit geldt ook voor het uploaden van screencasts en de test-upload in de instellingen.
## 1.1.1 - 2026-11-09
### Gewijzigd
- **Instellingen zonder scrollbalk.**\
Het instellingenvenster is iets hoger geworden, zodat elke tab, ook **Opslaan** met de nieuwe naamvelden, in één keer past. De changelog in de tab **Update** heeft daardoor ook meer ruimte.
### Opgelost
- De downloadlinks in de handleiding (README) wezen naar een verkeerde tag en gaven een foutmelding.
## 1.1.0 - 2026-11-09
### Nieuw
- **Updaten vanuit kniip.**\
In de instellingen staat een nieuwe tab **Update**. kniip kijkt op git.hibbit.net of er een nieuwere versie is en toont de wijzigingen van alle nieuwere versies, netjes opgemaakt. Met **Updaten** downloadt kniip de installer, sluit zichzelf af, installeert de nieuwe versie op de achtergrond en start daarna opnieuw. Je eerder gekozen instellingen, zoals starten met Windows en 'Openen met', blijven staan.
- Na een update meldt kniip in het systeemvak naar welke versie het is bijgewerkt.
- Er wordt alleen gecontroleerd als je de tab opent of op **Opnieuw controleren** klikt; kniip zoekt niet zelf op de achtergrond naar updates.
- Loopt er nog een screencast, of wil je een open knipsel eerst opslaan, dan wacht de update tot dat klaar is.
- **Naamsjabloon voor opslaan.**\
In de tab **Opslaan** stel je per soort (knipsels en screencasts) zelf in hoe bestanden heten. Je kunt deze codes gebruiken:
- `%J` jaar, `%M` maand en `%D` dag (bijvoorbeeld `2026`, `11`, `09`)
- `%V` de naam van het programma waarvan je een knipsel of opname maakt, bijvoorbeeld `chrome`
- `%R6` zes willekeurige letters en cijfers
- `%N3` een oplopend nummer met drie cijfers (`001`, `002`, ...), per map opnieuw geteld
- `/` maakt een submap; kniip maakt die mappen zelf aan.
Zo wordt `%J/%M-%D/%V_%N3` bijvoorbeeld `2026/11-09/chrome_001.png`. Onder het veld zie je meteen een voorbeeld. De extensie (`.png`, `.mp4` of `.gif`) zet kniip er zelf achter. Laat je het veld leeg, dan blijft de vertrouwde naam staan: `Knipsel` of `Screencast` met datum en tijd.
- **Autostart-vermelding opruimen.**\
Heb je kniip ooit als losse exe gestart, dan kan er een vermelding in **Instellingen > Apps > Opstarten** achterblijven. Met `tools\remove-autostart.cmd` haal je die weg. Beheerdersrechten zijn daarvoor niet nodig.
### Gewijzigd
- Het versienummer staat nu ook in de naam van de losse exe (`Kniip-1.1.0.exe`), naast de installer (`Kniip-Setup-1.1.0.exe`).
- De handleiding (README) beschrijft nu het downloaden, installeren, verwijderen en publiceren van nieuwe versies, en heeft een schermafbeelding van de editor.
### Let op
- Vanaf 1.0.0 moet je deze versie nog één keer zelf installeren met `Kniip-Setup-1.1.0.exe`. Daarna kun je volgende versies bijwerken via **Instellingen > Update**.
## 1.0.0 - 2026-11-07
Eerste stabiele versie.
- **Annotatie-editor** naast het Windows Knipprogramma: voeg pijlen, lijnen, rechthoeken en andere vormen, tekst, markeringen, genummerde stappen, emoji, muisaanwijzers en vervaging toe aan een knipsel. Met ongedaan maken en opnieuw doen, en een werkbalk die je zelf kunt indelen.
- **Knipsels oppikken**: kniip ziet nieuwe afbeeldingen van het Knipprogramma op het klembord of in de map Schermafbeeldingen, en toont dan een melding of opent meteen de editor.
- **Eigen sneltoetsen** voor een rechthoek of een venster knippen, ook met Print Screen.
- **Screencasts** van een gekozen gebied, met systeemgeluid, microfoon, muisaanwijzer en gemarkeerde muisklikken. Daarna knippen en exporteren als MP4-video of GIF.
- **Opslaan, kopiëren en uploaden**: opslaan in een vaste map of elke keer kiezen, desgewenst ook naar het klembord, en uploaden naar een eigen Zipline-server. Na het uploaden staat de link op het klembord.
- **Windows-integratie**: draait op de achtergrond in het systeemvak, kan starten met Windows en staat in 'Openen met' voor afbeeldingen.
- **Installer** per gebruiker, zonder beheerdersrechten en zonder losse .NET-installatie.
+59 -6
View File
@@ -5,23 +5,38 @@
<h1 align="center">kniip</h1> <h1 align="center">kniip</h1>
<p align="center"> <p align="center">
Annotatie-editor naast het Windows Knipprogramma <b>kniip</b> is een uitgebreide annotatie-editor voor Windows. Leg iets op je scherm vast, maak het duidelijk met annotaties en deel het als afbeelding, video, GIF of link.
</p> </p>
<p align="center"> <p align="center">
<a href="https://git.hibbit.net/rogier/kniip/releases"><strong>Download de installer</strong></a> <a href="https://git.hibbit.net/rogier/kniip/releases"><strong>Download: kniip</strong></a>
</p> </p>
--- ---
kniip is een annotatie-editor die naast het Windows Knipprogramma werkt. Maak een schermafbeelding, voeg pijlen, tekst, markeringen, stappen, emoji of vervaging toe en sla het resultaat op, kopieer het of upload het. kniip kan ook schermopnames maken en die exporteren als video of GIF. kniip is een annotatie-editor die naast het Windows Knipprogramma werkt. Maak een schermafbeelding, voeg pijlen, tekst, markeringen, stappen, emoji of vervaging toe en sla het resultaat op, kopieer het of upload het. kniip kan ook schermopnames maken en die exporteren als video of GIF.
<p align="center">
<a href="screenshot/kniip.png"><img src="screenshot/kniip.png" alt="Screenshot van de kniip-editor" width="720"></a>
</p>
## Wat kan kniip?
- **Knippen tot op de pixel.** Rechthoek of venster, met een vergrootglas voor precieze hoeken en een instelbare vertraging om eerst een menu of tooltip te openen.
- **Aantekenen.** Pijlen en lijnen die je kunt buigen, tekst, vormen, genummerde stappen, markeerstift, emoji en vervaging.
- **Screencasts.** Neem een deel van je scherm op en exporteer als video of GIF.
- **Delen.** Opslaan, kopiëren naar het klembord of uploaden en de link meteen delen.
- **Geschiedenis.** Vind eerdere knipsels en uploads terug, met miniaturen en zoeken.
- **Jouw werkbalk.** Kies een of twee werkbalken, versleep knoppen en verberg wat je niet gebruikt.
- **Altijd bij.** kniip meldt zelf wanneer er een nieuwe versie is en werkt zichzelf bij met een klik.
## Downloaden ## Downloaden
| Versie | Installer | | Versie | Installer |
| ------ | --------- | | ------ | --------- |
| Nieuwste versie | [Alle releases](https://git.hibbit.net/rogier/kniip/releases) | | 2.0.0 | [Kniip-Setup-2.0.0.exe](https://git.hibbit.net/rogier/kniip/releases/download/2.0.0/Kniip-Setup-2.0.0.exe) |
| 1.0.0 | [Kniip-Setup-1.0.0.exe](https://git.hibbit.net/rogier/kniip/releases/download/v1.0.0/Kniip-Setup-1.0.0.exe) |
Oudere versies vind je bij [alle releases](https://git.hibbit.net/rogier/kniip/releases).
## Installeren ## Installeren
@@ -45,6 +60,16 @@ Een losse .NET-installatie is niet nodig; de installer bevat alles wat kniip nod
Verwijder kniip via **Instellingen > Apps > Geïnstalleerde apps**. Autostart en de 'Openen met'-registratie worden daarbij ook opgeruimd. Verwijder kniip via **Instellingen > Apps > Geïnstalleerde apps**. Autostart en de 'Openen met'-registratie worden daarbij ook opgeruimd.
Heb je alleen `Kniip.exe` gestart en geen installer gebruikt, dan staat kniip mogelijk nog bij **Instellingen > Apps > Opstarten**. Die regel wijst naar de map waar de exe toen stond. De schakelaar daar uitzetten verbergt kniip niet: Windows bewaart de regel en zet hem alleen op uit.
`tools\remove-autostart.cmd` haalt de regel weg. Dubbelklik het bestand, of voer uit:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File .\tools\remove-autostart.ps1
```
Het script geldt voor de aangemelde gebruiker. Het pad van de exe maakt niet uit en het bestand hoeft er niet meer te staan. Beheerdersrechten zijn niet nodig. Elke Windows-account heeft een eigen opstartlijst, dus draai het in de account waarin kniip een keer is gestart. Blijft de vermelding daarna zichtbaar, log dan een keer uit en weer in.
## Zelf bouwen ## Zelf bouwen
Vereisten: Vereisten:
@@ -55,17 +80,45 @@ Vereisten:
Bouwen: Bouwen:
```powershell ```powershell
.\build.ps1 # versie 1.0.0 .\build.ps1 # versie 2.0.0
.\build.ps1 -Version 1.2.0 # andere versie .\build.ps1 -Version 1.2.0 # andere versie
``` ```
Het script levert op: Het script levert op:
- `artifacts\publish\Kniip.exe`: zelfstandige exe, draait zonder installatie - `artifacts\publish\Kniip.exe`: zelfstandige exe, draait zonder installatie
- `artifacts\Kniip-<versie>.exe`: dezelfde exe, met het versienummer in de naam
- `artifacts\Kniip-Setup-<versie>.exe`: de installer (alleen als Inno Setup 6 gevonden is) - `artifacts\Kniip-Setup-<versie>.exe`: de installer (alleen als Inno Setup 6 gevonden is)
- `artifacts\KniipTest-<versie>.exe`: testversie (overslaan met `-SkipTest`)
### Testversie
De testversie draait naast de geïnstalleerde kniip en raakt die niet aan. Hij gebruikt overal `KniipTest` in plaats van `Kniip`: de autostartregel, de 'Openen met'-registratie (`KniipTest.Image`), de instellingenmap `%APPDATA%\KniipTest` en de single-instance-mutex. Bij de eerste start kopieert hij de instellingen van de gewone kniip. Zichzelf bijwerken doet hij niet, want de installer zou de gewone kniip overschrijven.
Opruimen hoeft alleen als je in de testversie autostart of 'Openen met' hebt aangezet:
```powershell
.\artifacts\KniipTest-<versie>.exe --unregister
Remove-Item "$env:APPDATA\KniipTest" -Recurse # optioneel: instellingen van de testversie
```
Draaien beide versies tegelijk, dan reageren ze allebei op dezelfde sneltoetsen en knipsels. Sluit de gewone kniip tijdens het testen, of kies in de testversie andere sneltoetsen.
## Een nieuwe versie publiceren ## Een nieuwe versie publiceren
1. Bouw met `.\build.ps1 -Version <versie>`. 1. Bouw met `.\build.ps1 -Version <versie>`.
2. Maak op [git.hibbit.net](https://git.hibbit.net/rogier/kniip/releases) een release met tag `v<versie>` en voeg `artifacts\Kniip-Setup-<versie>.exe` toe als bijlage. 2. Maak op [git.hibbit.net](https://git.hibbit.net/rogier/kniip/releases) een release met tag `v<versie>` en voeg `artifacts\Kniip-Setup-<versie>.exe` toe als bijlage.
3. Voeg de nieuwe versie toe aan de tabel onder [Downloaden](#downloaden). 3. Schrijf de wijzigingen in markdown in de beschrijving van de release. kniip toont die als changelog onder **Instellingen > Update**.
4. Vervang de versie en de link in de tabel onder [Downloaden](#downloaden) door de nieuwe versie.
### Updaten vanuit kniip
Onder **Instellingen > Update** controleert kniip via de API van git.hibbit.net of er een nieuwere release is en toont de changelog van alle nieuwere versies. **Updaten** downloadt de installer, sluit kniip, installeert stil en start kniip opnieuw. De eerder gekozen integraties blijven behouden.
kniip controleert ook zelf bij het starten en daarna elke 24 uur. Is er een nieuwere versie, dan staat er een blauwe stip op de knop **Instellingen** in de editor en op de tab **Update**. Dit zet je uit met **Automatisch controleren op nieuwe versies** in die tab.
Voorwaarden:
- De repository `rogier/kniip` is publiek; kniip gebruikt geen token.
- De tag is een versienummer (`v1.2.0`); concepten en voorversies worden overgeslagen.
- De bijlage heet `Kniip-Setup-<versie>.exe`. Zonder bijlage toont kniip alleen de changelog en een link naar de releasepagina.
+40 -15
View File
@@ -1,19 +1,27 @@
# Publiceert kniip als zelfstandige single-file exe (geen .NET-installatie nodig op de doel-pc) # Publiceert kniip als zelfstandige single-file exe (geen .NET-installatie nodig op de doel-pc)
# en bouwt de installer als Inno Setup 6 aanwezig is. # en bouwt de installer als Inno Setup 6 aanwezig is.
# ScreenRecorderLib (screencasts) is een C++/CLI-assembly die niet uit de bundel geladen kan worden; # ScreenRecorderLib (screencasts) is een C++/CLI-assembly die niet uit de bundel geladen kan worden;
# IncludeAllContentForSelfExtract pakt daarom bij de eerste start alles uit naar %TEMP%\.net. # IncludeAllContentForSelfExtract pakt daarom bij de eerste start alles uit naar %TEMP%\.net.
# #
# .\build.ps1 -> artifacts\publish\Kniip.exe (+ installer indien mogelijk) #
# Elke build levert ook artifacts\KniipTest-<versie>.exe: een testversie met eigen register-, instellingen-
# en mutexnamen (KniipTest), die naast de geïnstalleerde kniip draait zonder diens registratie te raken.
#
# .\build.ps1 -> artifacts\publish\Kniip.exe (+ installer indien mogelijk) + testexe
# .\build.ps1 -Version 1.2.0 # .\build.ps1 -Version 1.2.0
# .\build.ps1 -SkipTest -> zonder testexe
param( param(
[string]$Version = "1.0.0", [string]$Version = "2.0.0",
[string]$Runtime = "win-x64" [string]$Runtime = "win-x64",
[switch]$SkipTest
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
$root = $PSScriptRoot $root = $PSScriptRoot
$project = Join-Path $root "src\Kniip\Kniip.csproj"
$publishDir = Join-Path $root "artifacts\publish" $publishDir = Join-Path $root "artifacts\publish"
$testPublishDir = Join-Path $root "artifacts\publish-test"
# Gebruik een SDK in het gebruikersprofiel als er geen systeembrede SDK is. # Gebruik een SDK in het gebruikersprofiel als er geen systeembrede SDK is.
$localDotnet = Join-Path $env:LOCALAPPDATA "Microsoft\dotnet" $localDotnet = Join-Path $env:LOCALAPPDATA "Microsoft\dotnet"
@@ -22,21 +30,31 @@ if (-not (dotnet --list-sdks 2>$null) -and (Test-Path (Join-Path $localDotnet "d
$env:PATH = "$localDotnet;$env:PATH" $env:PATH = "$localDotnet;$env:PATH"
} }
if (Test-Path $publishDir) { Remove-Item $publishDir -Recurse -Force } function Publish-Kniip([string]$outputDir, [bool]$test) {
if (Test-Path $outputDir) { Remove-Item $outputDir -Recurse -Force }
dotnet publish (Join-Path $root "src\Kniip\Kniip.csproj") ` dotnet publish $project `
-c Release -r $Runtime --self-contained true ` -c Release -r $Runtime --self-contained true `
-p:PublishSingleFile=true ` -p:PublishSingleFile=true `
-p:IncludeNativeLibrariesForSelfExtract=true ` -p:IncludeNativeLibrariesForSelfExtract=true `
-p:IncludeAllContentForSelfExtract=true ` -p:IncludeAllContentForSelfExtract=true `
-p:EnableCompressionInSingleFile=true ` -p:EnableCompressionInSingleFile=true `
-p:DebugType=none ` -p:DebugType=none `
-p:Version=$Version ` -p:Version=$Version `
-o $publishDir -p:KniipTest=$($test.ToString().ToLowerInvariant()) `
if ($LASTEXITCODE -ne 0) { throw "dotnet publish is mislukt." } -o $outputDir
if ($LASTEXITCODE -ne 0) { throw "dotnet publish is mislukt." }
}
Publish-Kniip $publishDir $false
Write-Host "Gepubliceerd: $publishDir\Kniip.exe" -ForegroundColor Green Write-Host "Gepubliceerd: $publishDir\Kniip.exe" -ForegroundColor Green
# Losse exe met versienummer; de installer blijft Kniip.exe uit de publish-map gebruiken.
$portableExe = Join-Path $root "artifacts\Kniip-$Version.exe"
Copy-Item (Join-Path $publishDir "Kniip.exe") $portableExe -Force
Write-Host "Losse exe: $portableExe" -ForegroundColor Green
$iscc = @( $iscc = @(
"${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe", "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe",
"$env:ProgramFiles\Inno Setup 6\ISCC.exe", "$env:ProgramFiles\Inno Setup 6\ISCC.exe",
@@ -50,3 +68,10 @@ if ($iscc) {
} else { } else {
Write-Host "Inno Setup 6 niet gevonden; installer overgeslagen (winget install JRSoftware.InnoSetup)." -ForegroundColor Yellow Write-Host "Inno Setup 6 niet gevonden; installer overgeslagen (winget install JRSoftware.InnoSetup)." -ForegroundColor Yellow
} }
if (-not $SkipTest) {
Publish-Kniip $testPublishDir $true
$testExe = Join-Path $root "artifacts\KniipTest-$Version.exe"
Copy-Item (Join-Path $testPublishDir "Kniip.exe") $testExe -Force
Write-Host "Testversie: $testExe" -ForegroundColor Green
}
+3 -1
View File
@@ -5,7 +5,7 @@
#define AppName "kniip" #define AppName "kniip"
#define AppExe "Kniip.exe" #define AppExe "Kniip.exe"
#ifndef AppVersion #ifndef AppVersion
#define AppVersion "1.0.0" #define AppVersion "2.0.0"
#endif #endif
#ifndef PublishDir #ifndef PublishDir
#define PublishDir "..\artifacts\publish" #define PublishDir "..\artifacts\publish"
@@ -75,6 +75,8 @@ Root: HKCU; Subkey: "Software\Classes\.gif\OpenWithProgids"; ValueType: none; Va
[Run] [Run]
Filename: "{app}\{#AppExe}"; Description: "kniip nu starten"; Flags: nowait postinstall skipifsilent Filename: "{app}\{#AppExe}"; Description: "kniip nu starten"; Flags: nowait postinstall skipifsilent
; Stille installatie = update vanuit kniip zelf; daarna opnieuw starten.
Filename: "{app}\{#AppExe}"; Parameters: "--updated"; Flags: nowait; Check: WizardSilent
[UninstallRun] [UninstallRun]
Filename: "{cmd}"; Parameters: "/C taskkill /IM {#AppExe} /F"; Flags: runhidden; RunOnceId: "StopKniip" Filename: "{cmd}"; Parameters: "/C taskkill /IM {#AppExe} /F"; Flags: runhidden; RunOnceId: "StopKniip"
Binary file not shown.

After

Width:  |  Height:  |  Size: 488 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 955 KiB

+447 -286
View File
@@ -3,301 +3,462 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
ShutdownMode="OnExplicitShutdown"> ShutdownMode="OnExplicitShutdown">
<Application.Resources> <Application.Resources>
<SolidColorBrush x:Key="AccentBrush" Color="#0067C0" /> <ResourceDictionary>
<SolidColorBrush x:Key="AccentHoverBrush" Color="#1975C5" /> <!-- Kleuren: ThemeManager wisselt deze tussen Themes/Light.xaml en Themes/Dark.xaml; gebruik ze via DynamicResource -->
<SolidColorBrush x:Key="AccentPressedBrush" Color="#3183CA" /> <ResourceDictionary.MergedDictionaries>
<SolidColorBrush x:Key="WindowBrush" Color="#F3F3F3" /> <ResourceDictionary Source="Themes/Light.xaml" />
<SolidColorBrush x:Key="ToolbarBrush" Color="#F9F9F9" /> </ResourceDictionary.MergedDictionaries>
<SolidColorBrush x:Key="HoverBrush" Color="#0F000000" /> <FontFamily x:Key="IconFont">Segoe Fluent Icons, Segoe MDL2 Assets</FontFamily>
<SolidColorBrush x:Key="PressedBrush" Color="#18000000" />
<SolidColorBrush x:Key="CheckedBrush" Color="#FFFFFF" />
<SolidColorBrush x:Key="DividerBrush" Color="#E5E5E5" />
<SolidColorBrush x:Key="InkBrush" Color="#1A1A1A" />
<SolidColorBrush x:Key="SubtleTextBrush" Color="#5C5C5C" />
<FontFamily x:Key="IconFont">Segoe Fluent Icons, Segoe MDL2 Assets</FontFamily>
<Style TargetType="ToolTip"> <Style TargetType="ToolTip">
<Setter Property="FontFamily" Value="Segoe UI" /> <Setter Property="FontFamily" Value="Segoe UI" />
</Style> </Style>
<Style x:Key="FlatButton" TargetType="ButtonBase"> <Style x:Key="FlatButton" TargetType="ButtonBase">
<Setter Property="Background" Value="Transparent" /> <Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="Transparent" /> <Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="BorderThickness" Value="1" /> <Setter Property="BorderThickness" Value="1" />
<Setter Property="Foreground" Value="{StaticResource InkBrush}" /> <Setter Property="Foreground" Value="{DynamicResource InkBrush}" />
<Setter Property="Padding" Value="8,5" /> <Setter Property="Padding" Value="8,5" />
<Setter Property="MinWidth" Value="36" /> <Setter Property="MinWidth" Value="36" />
<Setter Property="MinHeight" Value="34" /> <Setter Property="MinHeight" Value="34" />
<Setter Property="Margin" Value="1,0" /> <Setter Property="Margin" Value="1,0" />
<Setter Property="FontFamily" Value="Segoe UI" /> <Setter Property="FontFamily" Value="Segoe UI" />
<Setter Property="FontSize" Value="13" /> <Setter Property="FontSize" Value="13" />
<Setter Property="FocusVisualStyle" Value="{x:Null}" /> <Setter Property="FocusVisualStyle" Value="{x:Null}" />
<Setter Property="Template"> <Setter Property="Template">
<Setter.Value> <Setter.Value>
<ControlTemplate TargetType="ButtonBase"> <ControlTemplate TargetType="ButtonBase">
<Border x:Name="Bd" Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}" CornerRadius="4" Padding="{TemplateBinding Padding}"
SnapsToDevicePixels="True">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource HoverBrush}" />
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource PressedBrush}" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.35" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="ProminentButton" TargetType="Button" BasedOn="{StaticResource FlatButton}">
<Setter Property="Background" Value="#ECECEC" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd" Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}" CornerRadius="4" Padding="{TemplateBinding Padding}"
SnapsToDevicePixels="True">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#E0E0E0" />
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#D4D4D4" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.35" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="ToolToggle" TargetType="ToggleButton" BasedOn="{StaticResource FlatButton}">
<Setter Property="Padding" Value="6,4" />
<Setter Property="MinWidth" Value="38" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ToggleButton">
<Grid>
<Border x:Name="Bd" Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}" <Border x:Name="Bd" Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="1" CornerRadius="4" Padding="{TemplateBinding Padding}" SnapsToDevicePixels="True"> BorderThickness="{TemplateBinding BorderThickness}" CornerRadius="4" Padding="{TemplateBinding Padding}"
SnapsToDevicePixels="True">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" /> <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border> </Border>
<Border x:Name="Indicator" Height="3" Width="16" CornerRadius="1.5" Background="{StaticResource AccentBrush}" <ControlTemplate.Triggers>
VerticalAlignment="Bottom" Margin="0,0,0,2" Visibility="Collapsed" /> <Trigger Property="IsMouseOver" Value="True">
</Grid> <Setter TargetName="Bd" Property="Background" Value="{DynamicResource HoverBrush}" />
<ControlTemplate.Triggers> </Trigger>
<Trigger Property="IsMouseOver" Value="True"> <Trigger Property="IsPressed" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource HoverBrush}" /> <Setter TargetName="Bd" Property="Background" Value="{DynamicResource PressedBrush}" />
</Trigger> </Trigger>
<Trigger Property="IsChecked" Value="True"> <Trigger Property="IsEnabled" Value="False">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource CheckedBrush}" /> <Setter Property="Opacity" Value="0.35" />
<Setter TargetName="Bd" Property="BorderBrush" Value="#E0E0E0" /> </Trigger>
<Setter TargetName="Indicator" Property="Visibility" Value="Visible" /> </ControlTemplate.Triggers>
</Trigger> </ControlTemplate>
<Trigger Property="IsPressed" Value="True"> </Setter.Value>
<Setter TargetName="Bd" Property="Background" Value="{StaticResource PressedBrush}" /> </Setter>
</Trigger> </Style>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.35" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="AccentButton" TargetType="Button"> <Style x:Key="ProminentButton" TargetType="Button" BasedOn="{StaticResource FlatButton}">
<Setter Property="Foreground" Value="White" /> <Setter Property="Background" Value="{DynamicResource ProminentBrush}" />
<Setter Property="Background" Value="{StaticResource AccentBrush}" /> <Setter Property="Template">
<Setter Property="Padding" Value="14,6" /> <Setter.Value>
<Setter Property="MinHeight" Value="32" /> <ControlTemplate TargetType="Button">
<Setter Property="FontFamily" Value="Segoe UI" /> <Border x:Name="Bd" Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}"
<Setter Property="FontSize" Value="13" /> BorderThickness="{TemplateBinding BorderThickness}" CornerRadius="4" Padding="{TemplateBinding Padding}"
<Setter Property="Margin" Value="4,0" /> SnapsToDevicePixels="True">
<Setter Property="FocusVisualStyle" Value="{x:Null}" /> <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" />
<Setter Property="Template"> </Border>
<Setter.Value> <ControlTemplate.Triggers>
<ControlTemplate TargetType="Button"> <Trigger Property="IsMouseOver" Value="True">
<Border x:Name="Bd" Background="{TemplateBinding Background}" CornerRadius="4" Padding="{TemplateBinding Padding}"> <Setter TargetName="Bd" Property="Background" Value="{DynamicResource ProminentHoverBrush}" />
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" /> </Trigger>
</Border> <Trigger Property="IsPressed" Value="True">
<ControlTemplate.Triggers> <Setter TargetName="Bd" Property="Background" Value="{DynamicResource ProminentPressedBrush}" />
<Trigger Property="IsMouseOver" Value="True"> </Trigger>
<Setter TargetName="Bd" Property="Background" Value="{StaticResource AccentHoverBrush}" /> <Trigger Property="IsEnabled" Value="False">
</Trigger> <Setter Property="Opacity" Value="0.35" />
<Trigger Property="IsPressed" Value="True"> </Trigger>
<Setter TargetName="Bd" Property="Background" Value="{StaticResource AccentPressedBrush}" /> </ControlTemplate.Triggers>
</Trigger> </ControlTemplate>
<Trigger Property="IsEnabled" Value="False"> </Setter.Value>
<Setter Property="Opacity" Value="0.45" /> </Setter>
</Trigger> </Style>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="SecondaryButton" TargetType="Button" BasedOn="{StaticResource FlatButton}"> <Style x:Key="ToolToggle" TargetType="ToggleButton" BasedOn="{StaticResource FlatButton}">
<Setter Property="Background" Value="White" /> <Setter Property="Padding" Value="6,4" />
<Setter Property="BorderBrush" Value="#D6D6D6" /> <Setter Property="MinWidth" Value="38" />
<Setter Property="Padding" Value="12,5" /> <Setter Property="Template">
<Setter Property="MinHeight" Value="32" /> <Setter.Value>
<Setter Property="Margin" Value="4,0" /> <ControlTemplate TargetType="ToggleButton">
</Style> <Grid>
<Border x:Name="Bd" Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}"
<Style x:Key="IconText" TargetType="TextBlock"> BorderThickness="1" CornerRadius="4" Padding="{TemplateBinding Padding}" SnapsToDevicePixels="True">
<Setter Property="FontFamily" Value="{StaticResource IconFont}" /> <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" />
<Setter Property="FontSize" Value="16" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="HorizontalAlignment" Value="Center" />
</Style>
<Style x:Key="IconPath" TargetType="Path">
<Setter Property="Stroke" Value="{StaticResource InkBrush}" />
<Setter Property="StrokeThickness" Value="1" />
<Setter Property="StrokeLineJoin" Value="Round" />
<Setter Property="StrokeStartLineCap" Value="Round" />
<Setter Property="StrokeEndLineCap" Value="Round" />
<Setter Property="Width" Value="20" />
<Setter Property="Height" Value="20" />
<Setter Property="Stretch" Value="None" />
</Style>
<Style x:Key="PropLabel" TargetType="TextBlock">
<Setter Property="Foreground" Value="{StaticResource SubtleTextBrush}" />
<Setter Property="FontSize" Value="12" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="Margin" Value="0,0,6,0" />
</Style>
<Style x:Key="PropGroup" TargetType="StackPanel">
<Setter Property="Orientation" Value="Horizontal" />
<Setter Property="Margin" Value="0,2,4,2" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<!-- Minimalistische keuzeknop: dicht = alleen icoon + pijltje, open = icoon + naam -->
<Style x:Key="MiniComboItem" TargetType="ComboBoxItem">
<Setter Property="Padding" Value="8,5" />
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBoxItem">
<Border x:Name="Bd" Background="Transparent" CornerRadius="4" Padding="{TemplateBinding Padding}" Margin="1">
<ContentPresenter VerticalAlignment="Center" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#1A0067C0" />
</Trigger>
<Trigger Property="IsHighlighted" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource HoverBrush}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="MiniCombo" TargetType="ComboBox">
<Setter Property="ItemContainerStyle" Value="{StaticResource MiniComboItem}" />
<Setter Property="Height" Value="30" />
<Setter Property="MinWidth" Value="0" />
<Setter Property="Margin" Value="2,0" />
<Setter Property="FontSize" Value="12" />
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBox">
<Grid>
<ToggleButton Focusable="False" ClickMode="Press"
IsChecked="{Binding IsDropDownOpen, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}">
<ToggleButton.Template>
<ControlTemplate TargetType="ToggleButton">
<Border x:Name="Bd" Background="White" BorderBrush="#E0E0E0" BorderThickness="1" CornerRadius="4" />
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#F5F5F5" />
</Trigger>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#EBEBEB" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</ToggleButton.Template>
</ToggleButton>
<StackPanel Orientation="Horizontal" IsHitTestVisible="False" Margin="8,0,6,0" VerticalAlignment="Center">
<ContentPresenter Content="{TemplateBinding SelectionBoxItem}"
ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}"
VerticalAlignment="Center" />
<TextBlock Text="&#xE70D;" FontFamily="{StaticResource IconFont}" FontSize="8" Margin="6,1,0,0"
VerticalAlignment="Center" Foreground="#5C5C5C" />
</StackPanel>
<Popup x:Name="PART_Popup" IsOpen="{Binding IsDropDownOpen, RelativeSource={RelativeSource TemplatedParent}}"
Placement="Bottom" AllowsTransparency="True" Focusable="False" PopupAnimation="Fade">
<Border Background="White" BorderBrush="#D6D6D6" BorderThickness="1" CornerRadius="6" Padding="3"
Margin="0,4,10,10" MinWidth="{TemplateBinding ActualWidth}">
<Border.Effect>
<DropShadowEffect BlurRadius="10" ShadowDepth="2" Opacity="0.18" />
</Border.Effect>
<ScrollViewer MaxHeight="360" VerticalScrollBarVisibility="Auto">
<ItemsPresenter KeyboardNavigation.DirectionalNavigation="Contained" />
</ScrollViewer>
</Border> </Border>
</Popup> <Border x:Name="Indicator" Height="3" Width="16" CornerRadius="1.5" Background="{DynamicResource AccentBrush}"
</Grid> VerticalAlignment="Bottom" Margin="0,0,0,2" Visibility="Collapsed" />
</ControlTemplate> </Grid>
</Setter.Value> <ControlTemplate.Triggers>
</Setter> <Trigger Property="IsMouseOver" Value="True">
</Style> <Setter TargetName="Bd" Property="Background" Value="{DynamicResource HoverBrush}" />
</Trigger>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource CheckedBrush}" />
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource ControlBorderBrush}" />
<Setter TargetName="Indicator" Property="Visibility" Value="Visible" />
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource PressedBrush}" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.35" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Aan/uit-knop met alleen een icoon (schaduw, vet, recht) --> <Style x:Key="AccentButton" TargetType="Button">
<Style x:Key="MiniToggle" TargetType="ToggleButton"> <Setter Property="Foreground" Value="White" />
<Setter Property="Height" Value="30" /> <Setter Property="Background" Value="{DynamicResource AccentBrush}" />
<Setter Property="MinWidth" Value="30" /> <Setter Property="Padding" Value="14,6" />
<Setter Property="Margin" Value="2,0" /> <Setter Property="MinHeight" Value="32" />
<Setter Property="Padding" Value="6,0" /> <Setter Property="FontFamily" Value="Segoe UI" />
<Setter Property="Foreground" Value="{StaticResource InkBrush}" /> <Setter Property="FontSize" Value="13" />
<Setter Property="FocusVisualStyle" Value="{x:Null}" /> <Setter Property="Margin" Value="4,0" />
<Setter Property="VerticalAlignment" Value="Center" /> <Setter Property="FocusVisualStyle" Value="{x:Null}" />
<Setter Property="Template"> <Setter Property="Template">
<Setter.Value> <Setter.Value>
<ControlTemplate TargetType="ToggleButton"> <ControlTemplate TargetType="Button">
<Border x:Name="Bd" Background="White" BorderBrush="#E0E0E0" BorderThickness="1" CornerRadius="4" <Border x:Name="Bd" Background="{TemplateBinding Background}" CornerRadius="4" Padding="{TemplateBinding Padding}">
Padding="{TemplateBinding Padding}"> <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" />
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" /> </Border>
</Border> <ControlTemplate.Triggers>
<ControlTemplate.Triggers> <Trigger Property="IsMouseOver" Value="True">
<Trigger Property="IsMouseOver" Value="True"> <Setter TargetName="Bd" Property="Background" Value="{DynamicResource AccentHoverBrush}" />
<Setter TargetName="Bd" Property="Background" Value="#F5F5F5" /> </Trigger>
</Trigger> <Trigger Property="IsPressed" Value="True">
<Trigger Property="IsChecked" Value="True"> <Setter TargetName="Bd" Property="Background" Value="{DynamicResource AccentPressedBrush}" />
<Setter TargetName="Bd" Property="Background" Value="#1A0067C0" /> </Trigger>
<Setter TargetName="Bd" Property="BorderBrush" Value="{StaticResource AccentBrush}" /> <Trigger Property="IsEnabled" Value="False">
</Trigger> <Setter Property="Opacity" Value="0.45" />
</ControlTemplate.Triggers> </Trigger>
</ControlTemplate> </ControlTemplate.Triggers>
</Setter.Value> </ControlTemplate>
</Setter> </Setter.Value>
</Style> </Setter>
</Style>
<Style x:Key="PropCombo" TargetType="ComboBox"> <Style x:Key="SecondaryButton" TargetType="Button" BasedOn="{StaticResource FlatButton}">
<Setter Property="MinWidth" Value="80" /> <Setter Property="Background" Value="{DynamicResource ControlBrush}" />
<Setter Property="Height" Value="28" /> <Setter Property="BorderBrush" Value="{DynamicResource ControlStrongBorderBrush}" />
<Setter Property="VerticalContentAlignment" Value="Center" /> <Setter Property="Padding" Value="12,5" />
<Setter Property="FontSize" Value="12" /> <Setter Property="MinHeight" Value="32" />
</Style> <Setter Property="Margin" Value="4,0" />
</Style>
<Style x:Key="IconText" TargetType="TextBlock">
<Setter Property="FontFamily" Value="{StaticResource IconFont}" />
<Setter Property="FontSize" Value="16" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="HorizontalAlignment" Value="Center" />
</Style>
<Style x:Key="IconPath" TargetType="Path">
<Setter Property="Stroke" Value="{DynamicResource InkBrush}" />
<Setter Property="StrokeThickness" Value="1" />
<Setter Property="StrokeLineJoin" Value="Round" />
<Setter Property="StrokeStartLineCap" Value="Round" />
<Setter Property="StrokeEndLineCap" Value="Round" />
<Setter Property="Width" Value="20" />
<Setter Property="Height" Value="20" />
<Setter Property="Stretch" Value="None" />
</Style>
<Style x:Key="PropLabel" TargetType="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource SubtleTextBrush}" />
<Setter Property="FontSize" Value="12" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="Margin" Value="0,0,6,0" />
</Style>
<Style x:Key="PropGroup" TargetType="StackPanel">
<Setter Property="Orientation" Value="Horizontal" />
<Setter Property="Margin" Value="0,2,4,2" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<!-- Minimalistische keuzeknop: dicht = alleen icoon + pijltje, open = icoon + naam -->
<Style x:Key="MiniComboItem" TargetType="ComboBoxItem">
<Setter Property="Padding" Value="8,5" />
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBoxItem">
<Border x:Name="Bd" Background="Transparent" CornerRadius="4" Padding="{TemplateBinding Padding}" Margin="1">
<ContentPresenter VerticalAlignment="Center" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource SelectedBrush}" />
</Trigger>
<Trigger Property="IsHighlighted" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource HoverBrush}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="MiniCombo" TargetType="ComboBox">
<Setter Property="ItemContainerStyle" Value="{StaticResource MiniComboItem}" />
<Setter Property="Height" Value="30" />
<Setter Property="MinWidth" Value="0" />
<Setter Property="Margin" Value="2,0" />
<Setter Property="FontSize" Value="12" />
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBox">
<Grid>
<ToggleButton Focusable="False" ClickMode="Press"
IsChecked="{Binding IsDropDownOpen, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}">
<ToggleButton.Template>
<ControlTemplate TargetType="ToggleButton">
<Border x:Name="Bd" Background="{DynamicResource ControlBrush}"
BorderBrush="{DynamicResource ControlBorderBrush}" BorderThickness="1" CornerRadius="4" />
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource ControlHoverBrush}" />
</Trigger>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource ControlPressedBrush}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</ToggleButton.Template>
</ToggleButton>
<DockPanel IsHitTestVisible="False" Margin="8,0,6,0" VerticalAlignment="Center">
<TextBlock DockPanel.Dock="Right" Text="&#xE70D;" FontFamily="{StaticResource IconFont}" FontSize="8" Margin="6,1,0,0"
VerticalAlignment="Center" Foreground="{DynamicResource SubtleTextBrush}" />
<ContentPresenter Content="{TemplateBinding SelectionBoxItem}"
ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}"
VerticalAlignment="Center" />
</DockPanel>
<Popup x:Name="PART_Popup" IsOpen="{Binding IsDropDownOpen, RelativeSource={RelativeSource TemplatedParent}}"
Placement="Bottom" AllowsTransparency="True" Focusable="False" PopupAnimation="Fade">
<Border Background="{DynamicResource CardBrush}" BorderBrush="{DynamicResource ControlStrongBorderBrush}"
BorderThickness="1" CornerRadius="6" Padding="3"
Margin="0,4,10,10" MinWidth="{TemplateBinding ActualWidth}">
<Border.Effect>
<DropShadowEffect BlurRadius="10" ShadowDepth="2" Opacity="0.18" />
</Border.Effect>
<ScrollViewer MaxHeight="360" VerticalScrollBarVisibility="Auto">
<ItemsPresenter KeyboardNavigation.DirectionalNavigation="Contained" />
</ScrollViewer>
</Border>
</Popup>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Aan/uit-knop met alleen een icoon (schaduw, vet, recht) -->
<Style x:Key="MiniToggle" TargetType="ToggleButton">
<Setter Property="Height" Value="30" />
<Setter Property="MinWidth" Value="30" />
<Setter Property="Margin" Value="2,0" />
<Setter Property="Padding" Value="6,0" />
<Setter Property="Foreground" Value="{DynamicResource InkBrush}" />
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ToggleButton">
<Border x:Name="Bd" Background="{DynamicResource ControlBrush}" BorderBrush="{DynamicResource ControlBorderBrush}"
BorderThickness="1" CornerRadius="4" Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource ControlHoverBrush}" />
</Trigger>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource SelectedBrush}" />
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource AccentBrush}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Contextmenu's: icoon, tekst, sneltoets en eventueel een submenu -->
<Style TargetType="ContextMenu">
<Setter Property="FontFamily" Value="Segoe UI" />
<Setter Property="FontSize" Value="13" />
<Setter Property="Foreground" Value="{DynamicResource InkBrush}" />
<Setter Property="HasDropShadow" Value="False" />
<Setter Property="Grid.IsSharedSizeScope" Value="True" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ContextMenu">
<Border Background="{DynamicResource ToolbarBrush}" BorderBrush="{DynamicResource ControlStrongBorderBrush}" BorderThickness="1" CornerRadius="8"
Padding="4" Margin="0,0,10,10" MinWidth="220">
<Border.Effect>
<DropShadowEffect BlurRadius="12" ShadowDepth="2" Opacity="0.18" />
</Border.Effect>
<ItemsPresenter KeyboardNavigation.DirectionalNavigation="Cycle" />
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="{x:Static MenuItem.SeparatorStyleKey}" TargetType="Separator">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Separator">
<Border Height="1" Margin="6,4" Background="{DynamicResource DividerBrush}" />
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="MenuItem">
<Setter Property="Foreground" Value="{DynamicResource InkBrush}" />
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="MenuItem">
<Border x:Name="Bd" Background="Transparent" CornerRadius="4" Padding="8,6" SnapsToDevicePixels="True">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="28" />
<ColumnDefinition Width="*" SharedSizeGroup="MenuHeader" />
<ColumnDefinition Width="Auto" SharedSizeGroup="MenuGesture" />
<ColumnDefinition Width="16" />
</Grid.ColumnDefinitions>
<ContentPresenter x:Name="IconHost" ContentSource="Icon" VerticalAlignment="Center" HorizontalAlignment="Left" />
<TextBlock x:Name="Check" Text="&#xE73E;" FontFamily="{StaticResource IconFont}" FontSize="12"
VerticalAlignment="Center" HorizontalAlignment="Left" Visibility="Collapsed" />
<ContentPresenter Grid.Column="1" ContentSource="Header" RecognizesAccessKey="True" VerticalAlignment="Center" />
<TextBlock Grid.Column="2" Text="{TemplateBinding InputGestureText}" Margin="24,0,0,0"
Foreground="{DynamicResource SubtleTextBrush}" VerticalAlignment="Center" HorizontalAlignment="Right" />
<TextBlock x:Name="Arrow" Grid.Column="3" Text="&#xE76C;" FontFamily="{StaticResource IconFont}" FontSize="10"
VerticalAlignment="Center" HorizontalAlignment="Right" Visibility="Collapsed" />
<Popup x:Name="PART_Popup" Placement="Right" HorizontalOffset="6" VerticalOffset="-6" AllowsTransparency="True"
Focusable="False" PopupAnimation="Fade" IsOpen="{Binding IsSubmenuOpen, RelativeSource={RelativeSource TemplatedParent}}">
<Border Background="{DynamicResource ToolbarBrush}" BorderBrush="{DynamicResource ControlStrongBorderBrush}" BorderThickness="1" CornerRadius="8"
Padding="4" Margin="0,0,10,10" MinWidth="180" Grid.IsSharedSizeScope="True">
<Border.Effect>
<DropShadowEffect BlurRadius="12" ShadowDepth="2" Opacity="0.18" />
</Border.Effect>
<ItemsPresenter KeyboardNavigation.DirectionalNavigation="Cycle" />
</Border>
</Popup>
</Grid>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="Check" Property="Visibility" Value="Visible" />
<Setter TargetName="IconHost" Property="Visibility" Value="Collapsed" />
</Trigger>
<Trigger Property="HasItems" Value="True">
<Setter TargetName="Arrow" Property="Visibility" Value="Visible" />
</Trigger>
<Trigger Property="IsHighlighted" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource HoverBrush}" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.4" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="PropCombo" TargetType="ComboBox" BasedOn="{StaticResource MiniCombo}">
<Setter Property="MinWidth" Value="80" />
<Setter Property="Height" Value="28" />
<Setter Property="Margin" Value="0" />
</Style>
<Style TargetType="CheckBox">
<Setter Property="Foreground" Value="{DynamicResource InkBrush}" />
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="CheckBox">
<Grid Background="Transparent">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Border x:Name="Box" Width="16" Height="16" CornerRadius="3" BorderThickness="1" VerticalAlignment="Center"
Background="{DynamicResource ControlBrush}" BorderBrush="{DynamicResource CheckBoxBorderBrush}">
<TextBlock x:Name="Mark" Text="&#xE73E;" FontFamily="{StaticResource IconFont}" FontSize="11" Foreground="White"
HorizontalAlignment="Center" VerticalAlignment="Center" Visibility="Collapsed" />
</Border>
<ContentPresenter Grid.Column="1" Margin="6,0,0,0" VerticalAlignment="Center" RecognizesAccessKey="True" />
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Box" Property="Background" Value="{DynamicResource ControlHoverBrush}" />
</Trigger>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="Box" Property="Background" Value="{DynamicResource AccentBrush}" />
<Setter TargetName="Box" Property="BorderBrush" Value="{DynamicResource AccentBrush}" />
<Setter TargetName="Mark" Property="Visibility" Value="Visible" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.45" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="RadioButton">
<Setter Property="Foreground" Value="{DynamicResource InkBrush}" />
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="RadioButton">
<Grid Background="Transparent">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid Width="16" Height="16" VerticalAlignment="Center">
<Ellipse x:Name="Ring" Fill="{DynamicResource ControlBrush}" Stroke="{DynamicResource CheckBoxBorderBrush}"
StrokeThickness="1" />
<Ellipse x:Name="Dot" Width="6" Height="6" Fill="White" Visibility="Collapsed" />
</Grid>
<ContentPresenter Grid.Column="1" Margin="6,0,0,0" VerticalAlignment="Center" RecognizesAccessKey="True" />
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Ring" Property="Fill" Value="{DynamicResource ControlHoverBrush}" />
</Trigger>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="Ring" Property="Fill" Value="{DynamicResource AccentBrush}" />
<Setter TargetName="Ring" Property="Stroke" Value="{DynamicResource AccentBrush}" />
<Setter TargetName="Dot" Property="Visibility" Value="Visible" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.45" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="TextBox">
<Setter Property="Background" Value="{DynamicResource ControlBrush}" />
<Setter Property="Foreground" Value="{DynamicResource InkBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource ControlStrongBorderBrush}" />
<Setter Property="CaretBrush" Value="{DynamicResource InkBrush}" />
</Style>
</ResourceDictionary>
</Application.Resources> </Application.Resources>
</Application> </Application>
+207 -20
View File
@@ -7,11 +7,13 @@ using System.Windows.Media.Imaging;
using System.Windows.Threading; using System.Windows.Threading;
using Kniip.Capture; using Kniip.Capture;
using Kniip.Editor; using Kniip.Editor;
using Kniip.History;
using Kniip.Infrastructure; using Kniip.Infrastructure;
using Kniip.Interop; using Kniip.Interop;
using Kniip.Notifications; using Kniip.Notifications;
using Kniip.Recording; using Kniip.Recording;
using Kniip.Settings; using Kniip.Settings;
using Kniip.Update;
using Microsoft.Win32; using Microsoft.Win32;
using Screen = System.Windows.Forms.Screen; using Screen = System.Windows.Forms.Screen;
@@ -19,10 +21,18 @@ namespace Kniip;
public partial class App : Application public partial class App : Application
{ {
/// <summary>Waar een rechthoekknipsel heen gaat: naar de editor of naar tekstherkenning.</summary>
private enum SnipTarget { Editor, Ocr }
private SingleInstance? _singleInstance; private SingleInstance? _singleInstance;
private TrayIcon? _tray; private TrayIcon? _tray;
private SettingsWindow? _settingsWindow; private SettingsWindow? _settingsWindow;
private HistoryWindow? _historyWindow;
private readonly List<RegionCaptureWindow> _snipWindows = new(); private readonly List<RegionCaptureWindow> _snipWindows = new();
private List<SnappedWindow> _snipSnapshot = new();
private CountdownWindow? _snipCountdown;
private SnipTarget _snipTarget;
private Action? _snipFinished;
private uint? _ignoredClipboardSequence; private uint? _ignoredClipboardSequence;
public static App Instance => (App)Current; public static App Instance => (App)Current;
@@ -36,6 +46,9 @@ public partial class App : Application
/// <summary>Toetsenbordhaak voor alle sneltoetsen (knipsels en screencasts).</summary> /// <summary>Toetsenbordhaak voor alle sneltoetsen (knipsels en screencasts).</summary>
internal GlobalHotkeyHook? HotkeyHook { get; private set; } internal GlobalHotkeyHook? HotkeyHook { get; private set; }
/// <summary>Achtergrondcontrole op nieuwe versies; stuurt de blauwe stip bij Instellingen.</summary>
public UpdateNotifier Updates { get; } = new();
protected override void OnStartup(StartupEventArgs e) protected override void OnStartup(StartupEventArgs e)
{ {
base.OnStartup(e); base.OnStartup(e);
@@ -69,6 +82,7 @@ public partial class App : Application
} }
Settings = SettingsStore.Load(); Settings = SettingsStore.Load();
ThemeManager.Apply(Settings.ColorMode);
Capture = new CaptureCoordinator(Dispatcher); Capture = new CaptureCoordinator(Dispatcher);
Capture.ApplySettings(Settings); Capture.ApplySettings(Settings);
@@ -85,6 +99,13 @@ public partial class App : Application
_tray.OpenFileRequested += OpenFileWithDialog; _tray.OpenFileRequested += OpenFileWithDialog;
_tray.SnipRequested += BeginRegionSnip; _tray.SnipRequested += BeginRegionSnip;
_tray.WindowSnipRequested += BeginWindowSnip; _tray.WindowSnipRequested += BeginWindowSnip;
_tray.TextRecognitionRequested += () => BeginTextSnip();
_tray.SetCaptureDelay(Settings.CaptureDelaySeconds);
_tray.CaptureDelayChanged += seconds =>
{
Settings.CaptureDelaySeconds = seconds;
SettingsStore.Save(Settings);
};
_tray.RecordRequested += () => _tray.RecordRequested += () =>
{ {
if (Recorder.Stage == RecordingStage.Idle) if (Recorder.Stage == RecordingStage.Idle)
@@ -98,11 +119,16 @@ public partial class App : Application
}; };
_tray.GifRequested += () => Recorder.Toggle(ScreencastFormat.Gif); _tray.GifRequested += () => Recorder.Toggle(ScreencastFormat.Gif);
_tray.SettingsRequested += ShowSettings; _tray.SettingsRequested += ShowSettings;
_tray.HistoryRequested += ShowHistory;
_tray.FoldersProvider = TrayFolders;
_tray.ExitRequested += ExitApplication; _tray.ExitRequested += ExitApplication;
_tray.PauseChanged += _ => UpdateCapturePaused(); _tray.PauseChanged += _ => UpdateCapturePaused();
_tray.ApplyTheme(ThemeManager.IsDark);
ThemeManager.Changed += () => _tray?.ApplyTheme(ThemeManager.IsDark);
HotkeyHook = new GlobalHotkeyHook(Dispatcher); HotkeyHook = new GlobalHotkeyHook(Dispatcher);
ApplyHotkeys(); ApplyHotkeys();
Updates.Apply(Settings.CheckForUpdates);
_singleInstance.StartServer(received => Dispatcher.BeginInvoke(() => HandleArguments(received, false))); _singleInstance.StartServer(received => Dispatcher.BeginInvoke(() => HandleArguments(received, false)));
HandleArguments(args, true); HandleArguments(args, true);
@@ -134,6 +160,12 @@ public partial class App : Application
OpenFile(file); OpenFile(file);
} }
if (args.Contains("--updated", StringComparer.OrdinalIgnoreCase))
{
_tray?.ShowBalloon("kniip is bijgewerkt", $"Je gebruikt nu versie {UpdateChecker.CurrentVersion}.");
return;
}
if (files.Length > 0 || args.Contains("--tray", StringComparer.OrdinalIgnoreCase)) if (files.Length > 0 || args.Contains("--tray", StringComparer.OrdinalIgnoreCase))
{ {
return; return;
@@ -142,7 +174,7 @@ public partial class App : Application
if (isFirstStart) if (isFirstStart)
{ {
string? hotkey = HotkeyDisplay(Settings.SnipHotkey); string? hotkey = HotkeyDisplay(Settings.SnipHotkey);
_tray?.ShowBalloon("kniip is actief", hotkey == null _tray?.ShowBalloon($"{AppIdentity.DisplayName} is actief", hotkey == null
? "Maak een knipsel met Win + Shift + S. kniip biedt het daarna aan om te bewerken." ? "Maak een knipsel met Win + Shift + S. kniip biedt het daarna aan om te bewerken."
: $"Druk op {hotkey} en sleep een rechthoek om te knippen en te bewerken."); : $"Druk op {hotkey} en sleep een rechthoek om te knippen en te bewerken.");
} }
@@ -163,9 +195,10 @@ public partial class App : Application
} }
} }
public void OpenEditor(BitmapSource image) /// <param name="windowName">Programmanaam van het geknipte venster, voor %V in het naamsjabloon.</param>
public void OpenEditor(BitmapSource image, string? windowName = null)
{ {
var editor = new EditorWindow(image, Settings); var editor = new EditorWindow(image, Settings, windowName: windowName);
editor.Show(); editor.Show();
BringToFront(editor); BringToFront(editor);
} }
@@ -178,20 +211,94 @@ public partial class App : Application
timer.Tick += (_, _) => timer.Tick += (_, _) =>
{ {
timer.Stop(); timer.Stop();
BeginRegionSnip(); if (_snipCountdown == null && _snipWindows.Count == 0)
{
_snipTarget = SnipTarget.Editor;
}
BeginSnip(pickWindow: false);
}; };
timer.Start(); timer.Start();
} }
/// <summary>Bevriest alle schermen en laat een rechthoek slepen; het resultaat opent in de editor.</summary> /// <summary>Bevriest alle schermen (na de ingestelde vertraging) en laat een rechthoek slepen; het resultaat opent in de editor.</summary>
public void BeginRegionSnip() => BeginSnip(pickWindow: false); public void BeginRegionSnip() => BeginDelayedSnip(pickWindow: false);
/// <summary>Bevriest alle schermen en laat een venster aanklikken; het resultaat opent in de editor.</summary> /// <summary>Bevriest alle schermen (na de ingestelde vertraging) en laat een venster aanklikken; het resultaat opent in de editor.</summary>
public void BeginWindowSnip() => BeginSnip(pickWindow: true); public void BeginWindowSnip() => BeginDelayedSnip(pickWindow: true);
/// <summary>Als een rechthoekknipsel, maar de herkende tekst opent in een apart venster in plaats van de editor.</summary>
/// <param name="finished">Na het kiezen of annuleren, vóórdat het tekstvenster opent; bijvoorbeeld om de editor terug te zetten.</param>
public void BeginTextSnip(Action? finished = null)
{
if (_snipCountdown != null || _snipWindows.Count > 0 || Recorder.Stage == RecordingStage.Selecting)
{
finished?.Invoke();
return;
}
_snipFinished = finished;
BeginDelayedSnip(pickWindow: false, SnipTarget.Ocr);
}
private void RunSnipFinished()
{
Action? finished = _snipFinished;
_snipFinished = null;
finished?.Invoke();
}
private void BeginDelayedSnip(bool pickWindow, SnipTarget target = SnipTarget.Editor)
{
if (_snipCountdown == null && _snipWindows.Count == 0)
{
_snipTarget = target;
}
if (_snipCountdown != null)
{
return;
}
int seconds = Settings.CaptureDelaySeconds;
if (seconds <= 0 || _snipWindows.Count > 0)
{
BeginSnip(pickWindow);
return;
}
var countdown = new CountdownWindow(seconds);
countdown.Elapsed += () =>
{
EndCountdown();
// Even wachten tot het aftelvenster echt van het scherm is.
var timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(150) };
timer.Tick += (_, _) =>
{
timer.Stop();
BeginSnip(pickWindow);
};
timer.Start();
};
countdown.Cancelled += () =>
{
EndCountdown();
RunSnipFinished();
};
_snipCountdown = countdown;
ApplyHotkeys();
countdown.Start();
}
private void EndCountdown()
{
_snipCountdown = null;
ApplyHotkeys();
}
private void BeginSnip(bool pickWindow) private void BeginSnip(bool pickWindow)
{ {
if (_snipWindows.Count > 0 || Recorder.Stage == RecordingStage.Selecting) if (_snipWindows.Count > 0 || _snipCountdown != null || Recorder.Stage == RecordingStage.Selecting)
{ {
return; return;
} }
@@ -200,11 +307,12 @@ public partial class App : Application
var cursorPoint = new System.Drawing.Point(cursor.X, cursor.Y); var cursorPoint = new System.Drawing.Point(cursor.X, cursor.Y);
// Eerst alle schermen vastleggen, dan pas vensters tonen, anders staat de overlay in het beeld. // Eerst alle schermen vastleggen, dan pas vensters tonen, anders staat de overlay in het beeld.
List<System.Drawing.Rectangle>? windows = pickWindow ? PhysicalWindow.SnapshotWindows(includeOwn: true) : null; _snipSnapshot = PhysicalWindow.Snapshot(includeOwn: true);
List<System.Drawing.Rectangle>? windows = pickWindow ? _snipSnapshot.Select(w => w.Bounds).ToList() : null;
var frozen = Screen.AllScreens.Select(screen => (screen, ScreenGrabber.Capture(screen.Bounds))).ToList(); var frozen = Screen.AllScreens.Select(screen => (screen, ScreenGrabber.Capture(screen.Bounds))).ToList();
foreach ((Screen screen, BitmapSource image) in frozen) foreach ((Screen screen, BitmapSource image) in frozen)
{ {
var window = new RegionCaptureWindow(screen, image, windows); var window = new RegionCaptureWindow(screen, image, windows, Settings.ShowMagnifier);
window.Finished += OnRegionSnipFinished; window.Finished += OnRegionSnipFinished;
_snipWindows.Add(window); _snipWindows.Add(window);
window.Show(); window.Show();
@@ -249,6 +357,7 @@ public partial class App : Application
HotkeyHook.Capture = null; HotkeyHook.Capture = null;
} }
System.Drawing.Rectangle? area = _snipWindows.Select(w => w.SelectedArea).FirstOrDefault(a => a != null);
foreach (RegionCaptureWindow window in _snipWindows) foreach (RegionCaptureWindow window in _snipWindows)
{ {
window.Finished -= OnRegionSnipFinished; window.Finished -= OnRegionSnipFinished;
@@ -256,9 +365,19 @@ public partial class App : Application
} }
_snipWindows.Clear(); _snipWindows.Clear();
if (image != null) List<SnappedWindow> snapshot = _snipSnapshot;
_snipSnapshot = new();
SnipTarget target = _snipTarget;
_snipTarget = SnipTarget.Editor;
RunSnipFinished();
if (image != null && target == SnipTarget.Ocr)
{ {
OpenEditor(image); BringToFront(OcrResultWindow.Open(image));
}
else if (image != null)
{
string? windowName = area is { } a ? PhysicalWindow.ProgramNameAt(snapshot, a) : null;
OpenEditor(image, windowName);
} }
} }
@@ -284,11 +403,20 @@ public partial class App : Application
Bind(Settings.WindowHotkey, BeginWindowSnip); Bind(Settings.WindowHotkey, BeginWindowSnip);
Bind(Settings.RecordHotkey, () => Recorder.Toggle(ScreencastFormat.Mp4)); Bind(Settings.RecordHotkey, () => Recorder.Toggle(ScreencastFormat.Mp4));
Bind(Settings.GifHotkey, () => Recorder.Toggle(ScreencastFormat.Gif)); Bind(Settings.GifHotkey, () => Recorder.Toggle(ScreencastFormat.Gif));
Bind(Settings.ClipboardHotkey, OpenFromClipboard);
Bind(Settings.OcrHotkey, () => BeginTextSnip());
if (_snipCountdown is { } countdown)
{
// Alleen tijdens het aftellen; daarna moet Esc gewoon naar het actieve programma.
bindings[new HotkeyGesture(System.Windows.Input.ModifierKeys.None, System.Windows.Input.Key.Escape)] = countdown.Cancel;
}
HotkeyHook.Bindings = bindings; HotkeyHook.Bindings = bindings;
} }
_tray?.SetHotkeys(HotkeyDisplay(Settings.SnipHotkey), HotkeyDisplay(Settings.WindowHotkey), _tray?.SetHotkeys(HotkeyDisplay(Settings.SnipHotkey), HotkeyDisplay(Settings.WindowHotkey),
HotkeyDisplay(Settings.RecordHotkey), HotkeyDisplay(Settings.GifHotkey)); HotkeyDisplay(Settings.RecordHotkey), HotkeyDisplay(Settings.GifHotkey), HotkeyDisplay(Settings.ClipboardHotkey),
HotkeyDisplay(Settings.OcrHotkey));
} }
/// <summary>Start een screencast: eerst het gebied kiezen, daarna de knoppenbalk.</summary> /// <summary>Start een screencast: eerst het gebied kiezen, daarna de knoppenbalk.</summary>
@@ -325,9 +453,9 @@ public partial class App : Application
private void UpdateCapturePaused() => private void UpdateCapturePaused() =>
Capture.Paused = (_tray?.IsPaused ?? false) || Recorder.Stage != RecordingStage.Idle; Capture.Paused = (_tray?.IsPaused ?? false) || Recorder.Stage != RecordingStage.Idle;
private void OpenScreencast(string path, ScreencastFormat? format) private void OpenScreencast(string path, ScreencastFormat? format, string? windowName)
{ {
var window = new ScreencastWindow(path, Settings, format); var window = new ScreencastWindow(path, Settings, format, windowName);
window.Show(); window.Show();
BringToFront(window); BringToFront(window);
} }
@@ -349,11 +477,11 @@ public partial class App : Application
OpenEditor(image); OpenEditor(image);
} }
/// <summary>Editor met een leeg wit canvas, voor als er (nog) geen knipsel is.</summary> /// <summary>Editor met een leeg, transparant canvas, voor als er (nog) geen knipsel is.</summary>
public void OpenBlankEditor(Window? placeLike = null) public void OpenBlankEditor(Window? placeLike = null)
{ {
const int width = 1280, height = 720; const int width = 1280, height = 720;
byte[] pixels = Enumerable.Repeat((byte)255, width * height * 4).ToArray(); var pixels = new byte[width * height * 4];
BitmapSource blank = BitmapSource.Create(width, height, 96, 96, System.Windows.Media.PixelFormats.Bgra32, null, pixels, width * 4); BitmapSource blank = BitmapSource.Create(width, height, 96, 96, System.Windows.Media.PixelFormats.Bgra32, null, pixels, width * 4);
var editor = new EditorWindow(blank, Settings, isBlank: true); var editor = new EditorWindow(blank, Settings, isBlank: true);
if (placeLike != null) if (placeLike != null)
@@ -418,13 +546,46 @@ public partial class App : Application
BringToFront(_settingsWindow); BringToFront(_settingsWindow);
} }
public void ShowHistory(bool images)
{
if (_historyWindow == null)
{
_historyWindow = new HistoryWindow(images);
_historyWindow.Closed += (_, _) => _historyWindow = null;
_historyWindow.Show();
}
else
{
_historyWindow.ShowImages(images);
}
BringToFront(_historyWindow);
}
private IReadOnlyList<(string Label, string? Path)> TrayFolders()
{
static string? Existing(params string?[] folders) =>
folders.FirstOrDefault(folder => folder is { Length: > 0 } && Directory.Exists(folder));
return new (string, string?)[]
{
("Schermafbeeldingen", Existing(CaptureCoordinator.ScreenshotsFolder)),
("Knipsels", Existing(Settings.SnipSaveFolder, Settings.LastSaveFolder,
Environment.GetFolderPath(Environment.SpecialFolder.MyPictures))),
("Screencasts", Existing(Settings.VideoSaveFolder, Settings.LastVideoFolder,
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyVideos), "kniip")))
};
}
public void ShowBalloon(string title, string text) => _tray?.ShowBalloon(title, text); public void ShowBalloon(string title, string text) => _tray?.ShowBalloon(title, text);
public void SaveSettings() public void SaveSettings()
{ {
SettingsStore.Save(Settings); SettingsStore.Save(Settings);
ThemeManager.Apply(Settings.ColorMode);
Capture.ApplySettings(Settings); Capture.ApplySettings(Settings);
ApplyHotkeys(); ApplyHotkeys();
Updates.Apply(Settings.CheckForUpdates);
} }
private static void BringToFront(Window window) private static void BringToFront(Window window)
@@ -441,12 +602,21 @@ public partial class App : Application
} }
private void ExitApplication() private void ExitApplication()
{
if (TryCloseAllWindows())
{
Shutdown();
}
}
/// <summary>Sluit alle vensters; false als een screencast loopt of een editor open blijft.</summary>
private bool TryCloseAllWindows()
{ {
if (Recorder.Stage != RecordingStage.Idle) if (Recorder.Stage != RecordingStage.Idle)
{ {
MessageBox.Show("Er loopt nog een screencast. Stop of annuleer die eerst.", "kniip", MessageBox.Show("Er loopt nog een screencast. Stop of annuleer die eerst.", "kniip",
MessageBoxButton.OK, MessageBoxImage.Information); MessageBoxButton.OK, MessageBoxImage.Information);
return; return false;
} }
foreach (Window window in Windows.Cast<Window>().ToList()) foreach (Window window in Windows.Cast<Window>().ToList())
@@ -454,11 +624,28 @@ public partial class App : Application
window.Close(); window.Close();
} }
if (Windows.OfType<EditorWindow>().Any()) return !Windows.OfType<EditorWindow>().Any();
}
/// <summary>Start de gedownloade installer en sluit kniip; de installer start kniip daarna opnieuw.</summary>
public void InstallUpdate(string installerPath)
{
if (!TryCloseAllWindows())
{ {
return; return;
} }
try
{
UpdateInstaller.Start(installerPath);
}
catch (Exception ex)
{
MessageBox.Show($"De installer kon niet worden gestart.\n\n{ex.Message}", "kniip",
MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
Shutdown(); Shutdown();
} }
+65 -25
View File
@@ -41,33 +41,14 @@ internal static class ClipboardImageReader
return null; return null;
} }
if (includeFiles && TryReadFile(data) is { } fromFile) if (ReadData(data, includeFiles) is { } image)
{ {
return fromFile; return image;
} }
if (data.GetDataPresent("PNG") && data.GetData("PNG") is MemoryStream png) if (Clipboard.ContainsImage() && Clipboard.GetImage() is { } bitmap)
{ {
return PixelUtil.Decode(png); return OpaqueCopy(bitmap);
}
if (data.GetDataPresent(DataFormats.Dib) && data.GetData(DataFormats.Dib) is MemoryStream dib)
{
BitmapSource? fromDib = FromDib(dib.ToArray());
if (fromDib != null)
{
return fromDib;
}
}
if (Clipboard.ContainsImage())
{
BitmapSource? image = Clipboard.GetImage();
if (image != null)
{
var copy = PixelUtil.CreateBitmap(PixelUtil.ToOpaqueBgra(image), image.PixelWidth, image.PixelHeight);
return copy;
}
} }
} }
catch (Exception ex) when (ex is COMException or ExternalException or NotSupportedException or FileFormatException) catch (Exception ex) when (ex is COMException or ExternalException or NotSupportedException or FileFormatException)
@@ -78,6 +59,64 @@ internal static class ClipboardImageReader
return null; return null;
} }
/// <summary>Snelle controle tijdens het slepen, zonder te decoderen.</summary>
public static bool CanDrop(IDataObject data)
{
try
{
return data.GetDataPresent("PNG") || data.GetDataPresent(DataFormats.Dib) || data.GetDataPresent(DataFormats.Bitmap)
|| (data.GetData(DataFormats.FileDrop) is string[] files && Array.Exists(files, IsImageFile));
}
catch (Exception ex) when (ex is COMException or ExternalException)
{
return false;
}
}
/// <summary>Afbeelding uit iets dat in kniip is neergezet: een afbeeldingsbestand of afbeeldingsdata.</summary>
public static BitmapSource? FromDrop(IDataObject data)
{
try
{
if (ReadData(data, includeFiles: true) is { } image)
{
return image;
}
return data.GetData(DataFormats.Bitmap) is BitmapSource bitmap ? OpaqueCopy(bitmap) : null;
}
catch (Exception ex) when (ex is COMException or ExternalException or NotSupportedException or FileFormatException)
{
return null;
}
}
private static BitmapSource? ReadData(IDataObject data, bool includeFiles)
{
if (includeFiles && TryReadFile(data) is { } fromFile)
{
return fromFile;
}
if (data.GetDataPresent("PNG") && data.GetData("PNG") is MemoryStream png)
{
return PixelUtil.Decode(png);
}
if (data.GetDataPresent(DataFormats.Dib) && data.GetData(DataFormats.Dib) is MemoryStream dib)
{
return FromDib(dib.ToArray());
}
return null;
}
private static BitmapSource OpaqueCopy(BitmapSource image) =>
PixelUtil.CreateBitmap(PixelUtil.ToOpaqueBgra(image), image.PixelWidth, image.PixelHeight);
private static bool IsImageFile(string file) =>
Array.IndexOf(ImageExtensions, Path.GetExtension(file).ToLowerInvariant()) >= 0;
private static BitmapSource? TryReadFile(IDataObject data) private static BitmapSource? TryReadFile(IDataObject data)
{ {
if (!data.GetDataPresent(DataFormats.FileDrop) || data.GetData(DataFormats.FileDrop) is not string[] files) if (!data.GetDataPresent(DataFormats.FileDrop) || data.GetData(DataFormats.FileDrop) is not string[] files)
@@ -87,7 +126,7 @@ internal static class ClipboardImageReader
foreach (string file in files) foreach (string file in files)
{ {
if (!File.Exists(file) || Array.IndexOf(ImageExtensions, Path.GetExtension(file).ToLowerInvariant()) < 0) if (!File.Exists(file) || !IsImageFile(file))
{ {
continue; continue;
} }
@@ -131,7 +170,8 @@ internal static class ClipboardImageReader
try try
{ {
return PixelUtil.Decode(new MemoryStream(file)); BitmapSource image = PixelUtil.Decode(new MemoryStream(file));
return PixelUtil.IsFullyTransparent(image) ? OpaqueCopy(image) : image;
} }
catch (Exception) catch (Exception)
{ {
+21
View File
@@ -0,0 +1,21 @@
<Window x:Class="Kniip.Capture.CountdownWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="kniip" WindowStyle="None" AllowsTransparency="True" Background="Transparent"
Topmost="True" ShowInTaskbar="False" ShowActivated="False" ResizeMode="NoResize"
SizeToContent="WidthAndHeight" FontFamily="Segoe UI" Foreground="{DynamicResource InkBrush}" UseLayoutRounding="True">
<Border Margin="10" Background="{DynamicResource ToastBrush}" CornerRadius="8" BorderBrush="{DynamicResource ToastBorderBrush}" BorderThickness="1"
Cursor="Hand" MouseLeftButtonUp="Border_Click" ToolTip="Klik om te annuleren">
<Border.Effect>
<DropShadowEffect BlurRadius="14" ShadowDepth="2" Opacity="0.22" />
</Border.Effect>
<StackPanel Orientation="Horizontal" Margin="12,8,14,8">
<TextBlock x:Name="SecondsText" FontSize="28" FontWeight="SemiBold" Foreground="{DynamicResource AccentTextBrush}"
MinWidth="34" TextAlignment="Center" VerticalAlignment="Center" />
<StackPanel Margin="10,0,0,0" VerticalAlignment="Center">
<TextBlock Text="Knipsel over" FontSize="12" FontWeight="SemiBold" />
<TextBlock Text="Esc om te annuleren" FontSize="11" Foreground="{DynamicResource SubtleTextBrush}" />
</StackPanel>
</StackPanel>
</Border>
</Window>
+116
View File
@@ -0,0 +1,116 @@
using System;
using System.Windows;
using System.Windows.Input;
using System.Windows.Threading;
using Kniip.Interop;
using Kniip.Recording;
using Rectangle = System.Drawing.Rectangle;
using Screen = System.Windows.Forms.Screen;
namespace Kniip.Capture;
/// <summary>Klein venster bij de muis dat aftelt tot een vertraagd knipsel; neemt nooit de focus over.</summary>
public partial class CountdownWindow : Window
{
private const int CursorOffset = 20;
private readonly DispatcherTimer _follow = new() { Interval = TimeSpan.FromMilliseconds(40) };
private readonly DateTime _end;
private bool _finished;
/// <summary>De tijd is om; het venster is dan al gesloten.</summary>
public event Action? Elapsed;
/// <summary>Geannuleerd met Esc of een klik; het venster is dan al gesloten.</summary>
public event Action? Cancelled;
public CountdownWindow(int seconds)
{
InitializeComponent();
Left = Top = -32000;
_end = DateTime.UtcNow.AddSeconds(seconds);
SecondsText.Text = seconds.ToString();
_follow.Tick += (_, _) => Tick();
SourceInitialized += (_, _) =>
{
NativeMethods.AddExtendedStyle(PhysicalWindow.Handle(this),
NativeMethods.WS_EX_TOOLWINDOW | NativeMethods.WS_EX_NOACTIVATE);
PhysicalWindow.ExcludeFromCapture(this);
};
ContentRendered += (_, _) => FollowCursor();
Closed += (_, _) =>
{
_follow.Stop();
if (!_finished)
{
_finished = true;
Cancelled?.Invoke();
}
};
}
public void Start()
{
Show();
FollowCursor();
_follow.Start();
}
public void Cancel() => Finish(Cancelled);
private void Tick()
{
TimeSpan left = _end - DateTime.UtcNow;
if (left <= TimeSpan.Zero)
{
Finish(Elapsed);
return;
}
SecondsText.Text = ((int)Math.Ceiling(left.TotalSeconds)).ToString();
FollowCursor();
}
private void FollowCursor()
{
if (!IsVisible)
{
return;
}
NativeMethods.GetCursorPos(out NativeMethods.POINT cursor);
Rectangle area = Screen.FromPoint(new System.Drawing.Point(cursor.X, cursor.Y)).WorkingArea;
Rectangle size = PhysicalWindow.GetBounds(this);
int x = cursor.X + CursorOffset, y = cursor.Y + CursorOffset;
if (x + size.Width > area.Right)
{
x = cursor.X - CursorOffset - size.Width;
}
if (y + size.Height > area.Bottom)
{
y = cursor.Y - CursorOffset - size.Height;
}
x = Math.Max(area.Left, x);
y = Math.Max(area.Top, y);
if (x != size.X || y != size.Y)
{
PhysicalWindow.Place(this, new Rectangle(x, y, size.Width, size.Height), resize: false);
}
}
private void Finish(Action? handler)
{
if (_finished)
{
return;
}
_finished = true;
Close();
handler?.Invoke();
}
private void Border_Click(object sender, MouseButtonEventArgs e) => Cancel();
}
+44
View File
@@ -0,0 +1,44 @@
<UserControl x:Class="Kniip.Capture.MagnifierControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
HorizontalAlignment="Left" VerticalAlignment="Top" IsHitTestVisible="False">
<StackPanel>
<Grid Width="120" Height="120">
<Grid.Effect>
<DropShadowEffect BlurRadius="10" ShadowDepth="2" Opacity="0.35" />
</Grid.Effect>
<Grid>
<Grid.Clip>
<EllipseGeometry Center="60,60" RadiusX="60" RadiusY="60" />
</Grid.Clip>
<Image x:Name="Zoom" Stretch="Fill" RenderOptions.BitmapScalingMode="NearestNeighbor" />
<Rectangle>
<Rectangle.Fill>
<DrawingBrush Viewport="0,0,8,8" ViewportUnits="Absolute" TileMode="Tile">
<DrawingBrush.Drawing>
<GeometryDrawing>
<GeometryDrawing.Pen>
<Pen Brush="#40000000" Thickness="0.5" />
</GeometryDrawing.Pen>
<GeometryDrawing.Geometry>
<RectangleGeometry Rect="0,0,8,8" />
</GeometryDrawing.Geometry>
</GeometryDrawing>
</DrawingBrush.Drawing>
</DrawingBrush>
</Rectangle.Fill>
</Rectangle>
<Rectangle Height="8" VerticalAlignment="Top" Margin="0,56,0,0" Fill="{DynamicResource AccentBrush}" Opacity="0.55" />
<Rectangle Width="8" HorizontalAlignment="Left" Margin="56,0,0,0" Fill="{DynamicResource AccentBrush}" Opacity="0.55" />
<Rectangle Width="10" Height="10" HorizontalAlignment="Left" VerticalAlignment="Top" Margin="55,55,0,0"
Stroke="White" StrokeThickness="1" />
<Rectangle Width="8" Height="8" HorizontalAlignment="Left" VerticalAlignment="Top" Margin="56,56,0,0"
Stroke="#202020" StrokeThickness="1" />
</Grid>
<Ellipse Stroke="White" StrokeThickness="2" />
</Grid>
<Border HorizontalAlignment="Center" Margin="0,6,0,0" Background="#E6202020" CornerRadius="4" Padding="6,2">
<TextBlock x:Name="CoordinateText" Foreground="White" FontSize="12" />
</Border>
</StackPanel>
</UserControl>
+102
View File
@@ -0,0 +1,102 @@
using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using System.Windows.Media.Imaging;
namespace Kniip.Capture;
/// <summary>Rond vergrootglas met kruisdraad en coördinaten dat naast de muisaanwijzer meeloopt.</summary>
public partial class MagnifierControl : UserControl
{
private const int Pixels = 15;
private const double CursorOffset = 20;
private static MagnifierControl? _visible;
private readonly WriteableBitmap _zoom = new(Pixels, Pixels, 96, 96, PixelFormats.Bgra32, null);
private readonly byte[] _buffer = new byte[Pixels * Pixels * 4];
private readonly TranslateTransform _offset = new();
public MagnifierControl()
{
InitializeComponent();
Zoom.Source = _zoom;
RenderTransform = _offset;
Visibility = Visibility.Collapsed;
Unloaded += (_, _) => Hide();
}
/// <param name="frozen">Vastgelegd beeld van het scherm.</param>
/// <param name="relative">Muispositie in pixels van <paramref name="frozen"/>.</param>
/// <param name="screen">Muispositie in fysieke bureaubladcoördinaten, voor de tekst.</param>
/// <param name="cursor">Muispositie in het venster (DIP).</param>
/// <param name="area">Grootte van het venster (DIP).</param>
public void Show(BitmapSource frozen, System.Drawing.Point relative, System.Drawing.Point screen, Point cursor, Size area)
{
// Elk scherm heeft een eigen overlay; een overlay krijgt soms muisbewegingen terwijl de aanwijzer op een ander scherm staat.
if (relative.X < 0 || relative.Y < 0 || relative.X >= frozen.PixelWidth || relative.Y >= frozen.PixelHeight)
{
Hide();
return;
}
if (_visible != this)
{
_visible?.Hide();
_visible = this;
}
CopyPixels(frozen, relative);
CoordinateText.Text = $"X: {screen.X} Y: {screen.Y}";
Visibility = Visibility.Visible;
Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
Size size = DesiredSize;
double left = cursor.X + CursorOffset;
if (left + size.Width > area.Width)
{
left = cursor.X - CursorOffset - size.Width;
}
double top = cursor.Y + CursorOffset;
if (top + size.Height > area.Height)
{
top = cursor.Y - CursorOffset - size.Height;
}
_offset.X = Math.Max(0, left);
_offset.Y = Math.Max(0, top);
}
public void Hide()
{
Visibility = Visibility.Collapsed;
if (_visible == this)
{
_visible = null;
}
}
private void CopyPixels(BitmapSource frozen, System.Drawing.Point center)
{
Array.Clear(_buffer);
for (int i = 3; i < _buffer.Length; i += 4)
{
_buffer[i] = 255;
}
int half = Pixels / 2;
int x0 = Math.Max(0, center.X - half);
int y0 = Math.Max(0, center.Y - half);
int x1 = Math.Min(frozen.PixelWidth, center.X + half + 1);
int y1 = Math.Min(frozen.PixelHeight, center.Y + half + 1);
if (x1 > x0 && y1 > y0)
{
int offset = ((y0 - (center.Y - half)) * Pixels + (x0 - (center.X - half))) * 4;
frozen.CopyPixels(new Int32Rect(x0, y0, x1 - x0, y1 - y0), _buffer, Pixels * 4, offset);
}
_zoom.WritePixels(new Int32Rect(0, 0, Pixels, Pixels), _buffer, Pixels * 4, 0);
}
}
+6 -4
View File
@@ -1,26 +1,28 @@
<Window x:Class="Kniip.Capture.RegionCaptureWindow" <Window x:Class="Kniip.Capture.RegionCaptureWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:Kniip.Capture"
Title="kniip rechthoek knippen" WindowStyle="None" ResizeMode="NoResize" Title="kniip rechthoek knippen" WindowStyle="None" ResizeMode="NoResize"
ShowInTaskbar="False" Topmost="True" Cursor="Cross" FontFamily="Segoe UI" FontSize="13" ShowInTaskbar="False" Topmost="True" Cursor="Cross" FontFamily="Segoe UI" FontSize="13"
Background="Black"> Background="Black">
<Grid> <Grid>
<Image x:Name="Frozen" Stretch="Fill" RenderOptions.BitmapScalingMode="NearestNeighbor" /> <Image x:Name="Frozen" Stretch="Fill" RenderOptions.BitmapScalingMode="NearestNeighbor" />
<Path x:Name="Dim" Fill="#73000000" /> <Path x:Name="Dim" Fill="#73000000" />
<Rectangle x:Name="SelectionRect" Stroke="{StaticResource AccentBrush}" StrokeThickness="2" <Rectangle x:Name="SelectionRect" Stroke="{DynamicResource AccentBrush}" StrokeThickness="2"
HorizontalAlignment="Left" VerticalAlignment="Top" Visibility="Collapsed" IsHitTestVisible="False" /> HorizontalAlignment="Left" VerticalAlignment="Top" Visibility="Collapsed" IsHitTestVisible="False" />
<Border x:Name="SizeBadge" HorizontalAlignment="Left" VerticalAlignment="Top" Background="#E6202020" <Border x:Name="SizeBadge" HorizontalAlignment="Left" VerticalAlignment="Top" Background="#E6202020"
CornerRadius="4" Padding="6,2" Visibility="Collapsed" IsHitTestVisible="False"> CornerRadius="4" Padding="6,2" Visibility="Collapsed" IsHitTestVisible="False">
<TextBlock x:Name="SizeText" Foreground="White" FontSize="12" /> <TextBlock x:Name="SizeText" Foreground="White" FontSize="12" />
</Border> </Border>
<Border x:Name="Hint" HorizontalAlignment="Center" VerticalAlignment="Top" Margin="0,28,0,0" Background="#F2FFFFFF" <local:MagnifierControl x:Name="Magnifier" />
<Border x:Name="Hint" HorizontalAlignment="Center" VerticalAlignment="Top" Margin="0,28,0,0" Background="{DynamicResource ToastBrush}"
CornerRadius="6" Padding="16,9" IsHitTestVisible="False"> CornerRadius="6" Padding="16,9" IsHitTestVisible="False">
<Border.Effect> <Border.Effect>
<DropShadowEffect BlurRadius="12" ShadowDepth="2" Opacity="0.25" /> <DropShadowEffect BlurRadius="12" ShadowDepth="2" Opacity="0.25" />
</Border.Effect> </Border.Effect>
<StackPanel> <StackPanel>
<TextBlock x:Name="HintTitle" Text="Knipsel: sleep een rechthoek" FontWeight="SemiBold" Foreground="{StaticResource InkBrush}" /> <TextBlock x:Name="HintTitle" Text="Knipsel: sleep een rechthoek" FontWeight="SemiBold" Foreground="{DynamicResource InkBrush}" />
<TextBlock Foreground="{StaticResource SubtleTextBrush}" FontSize="12" Margin="0,2,0,0" <TextBlock Foreground="{DynamicResource SubtleTextBrush}" FontSize="12" Margin="0,2,0,0"
Text="Enter = heel scherm · Esc of rechtermuisknop = annuleren" /> Text="Enter = heel scherm · Esc of rechtermuisknop = annuleren" />
</StackPanel> </StackPanel>
</Border> </Border>
+17 -2
View File
@@ -22,6 +22,7 @@ public partial class RegionCaptureWindow : Window
private readonly Screen _screen; private readonly Screen _screen;
private readonly BitmapSource _frozen; private readonly BitmapSource _frozen;
private readonly IReadOnlyList<Rectangle>? _windows; private readonly IReadOnlyList<Rectangle>? _windows;
private readonly bool _showMagnifier;
private Point? _dragStart; private Point? _dragStart;
private bool _dragging; private bool _dragging;
private Rectangle? _hover; private Rectangle? _hover;
@@ -29,14 +30,20 @@ public partial class RegionCaptureWindow : Window
/// <summary>Het knipsel, of null bij annuleren.</summary> /// <summary>Het knipsel, of null bij annuleren.</summary>
internal event Action<BitmapSource?>? Finished; internal event Action<BitmapSource?>? Finished;
/// <summary>Geknipt gebied in fysieke schermcoördinaten, gezet vlak voor Finished.</summary>
internal Rectangle? SelectedArea { get; private set; }
/// <param name="windows">Vensterrechthoeken van boven naar onder; gezet = venster aanklikken in plaats van slepen.</param> /// <param name="windows">Vensterrechthoeken van boven naar onder; gezet = venster aanklikken in plaats van slepen.</param>
internal RegionCaptureWindow(Screen screen, BitmapSource frozen, IReadOnlyList<Rectangle>? windows = null) /// <param name="showMagnifier">Vergrootglas bij de muisaanwijzer tonen; niet bij venster aanklikken.</param>
internal RegionCaptureWindow(Screen screen, BitmapSource frozen, IReadOnlyList<Rectangle>? windows = null, bool showMagnifier = false)
{ {
InitializeComponent(); InitializeComponent();
_screen = screen; _screen = screen;
_frozen = frozen; _frozen = frozen;
_windows = windows; _windows = windows;
_showMagnifier = showMagnifier && windows == null;
Frozen.Source = frozen; Frozen.Source = frozen;
MouseLeave += (_, _) => Magnifier.Hide();
if (windows != null) if (windows != null)
{ {
Cursor = Cursors.Hand; Cursor = Cursors.Hand;
@@ -75,12 +82,19 @@ public partial class RegionCaptureWindow : Window
return; return;
} }
Point position = e.GetPosition(this);
if (_showMagnifier)
{
System.Drawing.Point physical = ToPhysical(position);
var relative = new System.Drawing.Point(physical.X - _screen.Bounds.X, physical.Y - _screen.Bounds.Y);
Magnifier.Show(_frozen, relative, physical, position, new Size(ActualWidth, ActualHeight));
}
if (_dragStart is not Point start || e.LeftButton != MouseButtonState.Pressed) if (_dragStart is not Point start || e.LeftButton != MouseButtonState.Pressed)
{ {
return; return;
} }
Point position = e.GetPosition(this);
if (!_dragging && (position - start).Length >= DragThreshold) if (!_dragging && (position - start).Length >= DragThreshold)
{ {
_dragging = true; _dragging = true;
@@ -164,6 +178,7 @@ public partial class RegionCaptureWindow : Window
} }
var relative = new Int32Rect(clipped.X - _screen.Bounds.X, clipped.Y - _screen.Bounds.Y, clipped.Width, clipped.Height); var relative = new Int32Rect(clipped.X - _screen.Bounds.X, clipped.Y - _screen.Bounds.Y, clipped.Width, clipped.Height);
SelectedArea = clipped;
Finished?.Invoke(ScreenGrabber.Crop(_frozen, relative)); Finished?.Invoke(ScreenGrabber.Crop(_frozen, relative));
} }
+49
View File
@@ -0,0 +1,49 @@
using System;
using System.Runtime.InteropServices;
using System.Windows;
using Kniip.Editor.Annotations;
namespace Kniip.Editor;
/// <summary>
/// Gekopieerde annotatie, gedeeld tussen editorvensters. Op het Windows-klembord staat alleen een markering,
/// zodat iets wat daarna in een ander programma wordt gekopieerd bij plakken voorgaat.
/// </summary>
internal static class AnnotationClipboard
{
private const string MarkerFormat = "Kniip.Annotation";
private static Annotation? _item;
private static string? _token;
public static bool Set(Annotation item)
{
string token = Guid.NewGuid().ToString("N");
if (!ClipboardWriter.SetMarker(MarkerFormat, token))
{
return false;
}
_item = item.Clone();
_token = token;
return true;
}
/// <summary>Een kopie van de annotatie, of null als het klembord inmiddels iets anders bevat.</summary>
public static Annotation? Get()
{
if (_item == null)
{
return null;
}
try
{
return Clipboard.GetData(MarkerFormat) as string == _token ? _item.Clone() : null;
}
catch (ExternalException)
{
return null;
}
}
}
+152 -8
View File
@@ -12,7 +12,14 @@ namespace Kniip.Editor;
public sealed class AnnotationSurface : FrameworkElement public sealed class AnnotationSurface : FrameworkElement
{ {
private static readonly Brush AccentBrush = RenderHelpers.Freeze(new SolidColorBrush(Palette.Accent)); private static readonly Brush AccentBrush = RenderHelpers.Freeze(new SolidColorBrush(Palette.Accent));
private static readonly Brush GhostHandleBrush = RenderHelpers.Freeze(new SolidColorBrush(Color.FromArgb(170, 255, 255, 255)));
private static readonly Brush CropShadeBrush = RenderHelpers.Freeze(new SolidColorBrush(Color.FromArgb(120, 0, 0, 0))); private static readonly Brush CropShadeBrush = RenderHelpers.Freeze(new SolidColorBrush(Color.FromArgb(120, 0, 0, 0)));
private static readonly Brush CheckerLight = RenderHelpers.Freeze(new SolidColorBrush(Colors.White));
private static readonly Brush CheckerDark = RenderHelpers.Freeze(new SolidColorBrush(Color.FromRgb(0xEF, 0xEF, 0xEF)));
public static readonly Color GuideColor = Color.FromRgb(0, 180, 255);
private static readonly Brush GuideBrush = RenderHelpers.Freeze(new SolidColorBrush(GuideColor));
private static readonly Brush SnapBrush = RenderHelpers.Freeze(new SolidColorBrush(Color.FromRgb(255, 45, 160)));
public EditorDocument? Document { get; set; } public EditorDocument? Document { get; set; }
@@ -20,6 +27,19 @@ public sealed class AnnotationSurface : FrameworkElement
public Rect? CropRect { get; set; } public Rect? CropRect { get; set; }
/// <summary>Verticale hulplijnen (X in afbeeldingspixels); worden niet geëxporteerd.</summary>
public IReadOnlyList<double> GuidesX { get; set; } = System.Array.Empty<double>();
/// <summary>Horizontale hulplijnen (Y in afbeeldingspixels).</summary>
public IReadOnlyList<double> GuidesY { get; set; } = System.Array.Empty<double>();
public bool ShowGuides { get; set; }
/// <summary>Lijn waaraan het object tijdens het slepen vastklikt, om die op te laten lichten.</summary>
public double? SnapX { get; set; }
public double? SnapY { get; set; }
/// <summary>Schaal van de weergave (DIP per afbeeldingspixel), om grepen altijd even groot te tonen.</summary> /// <summary>Schaal van de weergave (DIP per afbeeldingspixel), om grepen altijd even groot te tonen.</summary>
public double ViewScale { get; set; } = 1; public double ViewScale { get; set; } = 1;
@@ -42,6 +62,11 @@ public sealed class AnnotationSurface : FrameworkElement
return; return;
} }
if (Document.HasTransparency)
{
dc.DrawRectangle(CreateChecker(), null, new Rect(0, 0, Document.Width, Document.Height));
}
DocumentRenderer.Render(dc, Document); DocumentRenderer.Render(dc, Document);
if (Selected != null && !Selected.IsHidden && Document.Items.Contains(Selected)) if (Selected != null && !Selected.IsHidden && Document.Items.Contains(Selected))
@@ -57,14 +82,105 @@ public sealed class AnnotationSurface : FrameworkElement
dc.DrawRectangle(null, CreateDashedPen(Brushes.White), crop); dc.DrawRectangle(null, CreateDashedPen(Brushes.White), crop);
dc.DrawRectangle(null, CreateDashedPen(AccentBrush, 4), crop); dc.DrawRectangle(null, CreateDashedPen(AccentBrush, 4), crop);
} }
if (ShowGuides)
{
DrawGuides(dc, Document);
}
} }
private void DrawGuides(DrawingContext dc, EditorDocument document)
{
var guidePen = new Pen(GuideBrush, 1 / ViewScale);
var snapPen = new Pen(SnapBrush, 1 / ViewScale);
foreach (double x in GuidesX)
{
dc.DrawLine(guidePen, new Point(x, 0), new Point(x, document.Height));
}
foreach (double y in GuidesY)
{
dc.DrawLine(guidePen, new Point(0, y), new Point(document.Width, y));
}
if (SnapX is double sx)
{
dc.DrawLine(snapPen, new Point(sx, 0), new Point(sx, document.Height));
}
if (SnapY is double sy)
{
dc.DrawLine(snapPen, new Point(0, sy), new Point(document.Width, sy));
}
}
/// <summary>Index van de hulplijn onder het punt; <paramref name="vertical"/> is true voor een verticale lijn (X).</summary>
public int HitGuide(Point p, out bool vertical)
{
double tolerance = 4 / ViewScale;
vertical = true;
for (int i = GuidesX.Count - 1; i >= 0; i--)
{
if (System.Math.Abs(GuidesX[i] - p.X) <= tolerance)
{
return i;
}
}
vertical = false;
for (int i = GuidesY.Count - 1; i >= 0; i--)
{
if (System.Math.Abs(GuidesY[i] - p.Y) <= tolerance)
{
return i;
}
}
return -1;
}
/// <summary>Plek van de draaigreep: een vaste schermafstand boven het (gedraaide) kader.</summary>
public Point? RotateHandle(Annotation item)
{
if (!item.CanRotate)
{
return null;
}
Rect f = item.Frame;
var local = new Point(f.X + f.Width / 2, f.Top - (3 + 22) / ViewScale);
return item.RotationMatrix.Transform(local);
}
public bool HitRotateHandle(Annotation item, Point p) =>
RotateHandle(item) is Point h && (h - p).Length <= HandleSize / 2 + 3 / ViewScale;
private void DrawSelection(DrawingContext dc, Annotation item) private void DrawSelection(DrawingContext dc, Annotation item)
{ {
Rect bounds = item.Bounds;
double pad = 3 / ViewScale; double pad = 3 / ViewScale;
bounds.Inflate(pad, pad); double size = HandleSize;
dc.DrawRectangle(null, CreateDashedPen(AccentBrush), bounds); var pen = new Pen(AccentBrush, 1.2 / ViewScale);
if (item.CanRotate)
{
Rect frame = item.Frame;
frame.Inflate(pad, pad);
dc.PushTransform(new MatrixTransform(item.RotationMatrix));
dc.DrawRectangle(null, CreateDashedPen(AccentBrush), frame);
dc.DrawLine(pen, new Point(frame.X + frame.Width / 2, frame.Top), new Point(frame.X + frame.Width / 2, frame.Top - 22 / ViewScale));
dc.Pop();
if (RotateHandle(item) is Point rotate)
{
dc.DrawEllipse(Brushes.White, pen, rotate, size / 2, size / 2);
dc.DrawEllipse(AccentBrush, null, rotate, size / 5, size / 5);
}
}
else
{
Rect bounds = item.Bounds;
bounds.Inflate(pad, pad);
dc.DrawRectangle(null, CreateDashedPen(AccentBrush), bounds);
}
IReadOnlyList<Point> handles = item.GetHandles(); IReadOnlyList<Point> handles = item.GetHandles();
if (handles.Count == 0) if (handles.Count == 0)
@@ -72,15 +188,37 @@ public sealed class AnnotationSurface : FrameworkElement
return; return;
} }
double size = HandleSize; for (int i = 0; i < handles.Count; i++)
var pen = new Pen(AccentBrush, 1.2 / ViewScale);
foreach (Point h in handles)
{ {
Point h = handles[i];
if (item.IsGhostHandle(i))
{
double radius = size * 0.35;
dc.DrawEllipse(GhostHandleBrush, pen, h, radius, radius);
continue;
}
dc.DrawRoundedRectangle(Brushes.White, pen, new Rect(h.X - size / 2, h.Y - size / 2, size, size), dc.DrawRoundedRectangle(Brushes.White, pen, new Rect(h.X - size / 2, h.Y - size / 2, size, size),
1.5 / ViewScale, 1.5 / ViewScale); 1.5 / ViewScale, 1.5 / ViewScale);
} }
} }
/// <summary>Blokjespatroon voor transparantie; de blokjes blijven op elk zoomniveau even groot op het scherm.</summary>
private Brush CreateChecker()
{
double cell = 8 / ViewScale;
var drawing = new DrawingGroup();
drawing.Children.Add(new GeometryDrawing(CheckerLight, null, new RectangleGeometry(new Rect(0, 0, 2, 2))));
drawing.Children.Add(new GeometryDrawing(CheckerDark, null, Geometry.Parse("M0,0 H1 V1 H0 Z M1,1 H2 V2 H1 Z")));
return new DrawingBrush(drawing)
{
TileMode = TileMode.Tile,
Viewport = new Rect(0, 0, cell * 2, cell * 2),
ViewportUnits = BrushMappingMode.Absolute,
Stretch = Stretch.Fill
};
}
private Pen CreateDashedPen(Brush brush, double offset = 0) => new(brush, 1 / ViewScale) private Pen CreateDashedPen(Brush brush, double offset = 0) => new(brush, 1 / ViewScale)
{ {
DashStyle = new DashStyle(new[] { 4.0, 4.0 }, offset) DashStyle = new DashStyle(new[] { 4.0, 4.0 }, offset)
@@ -90,14 +228,20 @@ public sealed class AnnotationSurface : FrameworkElement
{ {
IReadOnlyList<Point> handles = item.GetHandles(); IReadOnlyList<Point> handles = item.GetHandles();
double tolerance = HandleSize / 2 + 2 / ViewScale; double tolerance = HandleSize / 2 + 2 / ViewScale;
int ghost = -1;
for (int i = handles.Count - 1; i >= 0; i--) for (int i = handles.Count - 1; i >= 0; i--)
{ {
if (System.Math.Abs(handles[i].X - p.X) <= tolerance && System.Math.Abs(handles[i].Y - p.Y) <= tolerance) if (System.Math.Abs(handles[i].X - p.X) <= tolerance && System.Math.Abs(handles[i].Y - p.Y) <= tolerance)
{ {
return i; if (!item.IsGhostHandle(i))
{
return i;
}
ghost = i;
} }
} }
return -1; return ghost;
} }
} }
@@ -33,6 +33,9 @@ public abstract class Annotation
public virtual IReadOnlyList<Point> GetHandles() => Array.Empty<Point>(); public virtual IReadOnlyList<Point> GetHandles() => Array.Empty<Point>();
/// <summary>Een greep die pas iets wordt als je hem sleept, zoals het middenpunt om een lijn te buigen.</summary>
public virtual bool IsGhostHandle(int index) => false;
public virtual void BeginHandleDrag(int index) public virtual void BeginHandleDrag(int index)
{ {
} }
@@ -41,6 +44,69 @@ public abstract class Annotation
{ {
} }
public virtual bool RemoveHandle(int index) => false;
/// <summary>False voor objecten die gespiegeld hetzelfde of onleesbaar zouden zijn, zoals rechthoeken en tekst.</summary>
public virtual bool CanFlip => false;
/// <summary>Spiegelt het object; de positie mag daarbij verschuiven, de editor zet het daarna terug op zijn plek.</summary>
public virtual void Flip(bool horizontal)
{
}
protected static Point Mirror(Point p, bool horizontal) => horizontal ? new Point(-p.X, p.Y) : new Point(p.X, -p.Y);
/// <summary>Te draaien met de draaigreep boven het object.</summary>
public virtual bool CanRotate => false;
/// <summary>Draaiing in graden rechtsom, rond het midden van <see cref="Frame"/>.</summary>
public double Angle { get; set; }
/// <summary>Het kader van het object zonder draaiing; bij draaibare objecten is <see cref="Bounds"/> het omhullende kader daarvan.</summary>
public virtual Rect Frame => Bounds;
public Point Pivot
{
get
{
Rect f = Frame;
return new Point(f.X + f.Width / 2, f.Y + f.Height / 2);
}
}
public Matrix RotationMatrix
{
get
{
Matrix m = Matrix.Identity;
if (Angle != 0)
{
Point c = Pivot;
m.RotateAt(Angle, c.X, c.Y);
}
return m;
}
}
protected Rect RotatedBounds(Rect frame) => Angle == 0 ? frame : Rect.Transform(frame, RotationMatrix);
/// <summary>Hoek tussen -180 en 180 graden.</summary>
public static double NormalizeAngle(double angle)
{
angle %= 360;
if (angle > 180)
{
angle -= 360;
}
else if (angle <= -180)
{
angle += 360;
}
return Math.Abs(angle) < 1e-9 ? 0 : angle;
}
/// <summary>Te klein om te bewaren na het tekenen (bijv. een losse klik met het rechthoek-gereedschap).</summary> /// <summary>Te klein om te bewaren na het tekenen (bijv. een losse klik met het rechthoek-gereedschap).</summary>
public virtual bool IsDegenerate => false; public virtual bool IsDegenerate => false;
@@ -21,7 +21,7 @@ public sealed class BlurAnnotation : RectAnnotation
return; return;
} }
var key = (area, Style.BlurMode, Style.BlurStrength, document.Image); var key = (area, Style.BlurMode, Style.BlurStrength, document.EffectImage);
if (_cache == null || !_cacheKey.Equals(key)) if (_cache == null || !_cacheKey.Equals(key))
{ {
_cache = Style.BlurMode == BlurMode.Pixelate _cache = Style.BlurMode == BlurMode.Pixelate
@@ -10,6 +10,11 @@ public sealed class CursorAnnotation : Annotation
public Point Position { get; set; } public Point Position { get; set; }
/// <summary>Gespiegeld rond de hotspot.</summary>
public bool FlipX { get; set; }
public bool FlipY { get; set; }
public override ToolKind Kind => ToolKind.Cursor; public override ToolKind Kind => ToolKind.Cursor;
private double Scale => Style.Size / DesignSize; private double Scale => Style.Size / DesignSize;
@@ -19,8 +24,10 @@ public sealed class CursorAnnotation : Annotation
get get
{ {
Rect r = CursorGeometries.For(Style.CursorKind).Bounds; Rect r = CursorGeometries.For(Style.CursorKind).Bounds;
double x = FlipX ? -r.Right : r.X;
double y = FlipY ? -r.Bottom : r.Y;
double s = Scale; double s = Scale;
return new Rect(Position.X + r.X * s, Position.Y + r.Y * s, r.Width * s, r.Height * s); return new Rect(Position.X + x * s, Position.Y + y * s, r.Width * s, r.Height * s);
} }
} }
@@ -29,21 +36,51 @@ public sealed class CursorAnnotation : Annotation
dc.PushTransform(new TranslateTransform(Position.X, Position.Y)); dc.PushTransform(new TranslateTransform(Position.X, Position.Y));
dc.PushTransform(new ScaleTransform(Scale, Scale)); dc.PushTransform(new ScaleTransform(Scale, Scale));
// De geometrie spiegelen in plaats van de hele tekening, zodat de schaduw rechtsonder blijft vallen.
CursorGeometries.Parts parts = CursorGeometries.PartsFor(Style.CursorKind); CursorGeometries.Parts parts = CursorGeometries.PartsFor(Style.CursorKind);
RenderHelpers.DrawWithShadow(dc, parts.Body, Brushes.White, CursorGeometries.OutlinePen, Style.Shadow); RenderHelpers.DrawWithShadow(dc, Mirrored(parts.Body), Brushes.White, CursorGeometries.OutlinePen, Style.Shadow);
if (parts.Details != null) if (parts.Details != null)
{ {
dc.DrawGeometry(null, CursorGeometries.DetailPen, parts.Details); dc.DrawGeometry(null, CursorGeometries.DetailPen, Mirrored(parts.Details));
} }
if (parts.Rays != null) if (parts.Rays != null)
{ {
dc.DrawGeometry(null, CursorGeometries.RayPen, parts.Rays); dc.DrawGeometry(null, CursorGeometries.RayPen, Mirrored(parts.Rays));
} }
dc.Pop(); dc.Pop();
dc.Pop(); dc.Pop();
} }
private Geometry Mirrored(Geometry geometry)
{
if (!FlipX && !FlipY)
{
return geometry;
}
PathGeometry copy = PathGeometry.CreateFromGeometry(geometry);
Matrix matrix = copy.Transform?.Value ?? Matrix.Identity;
matrix.Scale(FlipX ? -1 : 1, FlipY ? -1 : 1);
copy.Transform = new MatrixTransform(matrix);
copy.Freeze();
return copy;
}
public override void Move(Vector delta) => Position += delta; public override void Move(Vector delta) => Position += delta;
public override bool CanFlip => true;
public override void Flip(bool horizontal)
{
if (horizontal)
{
FlipX = !FlipX;
}
else
{
FlipY = !FlipY;
}
}
} }
@@ -13,9 +13,15 @@ public sealed class EmojiAnnotation : Annotation
public Point Center { get; set; } public Point Center { get; set; }
public bool FlipX { get; set; }
public bool FlipY { get; set; }
public override ToolKind Kind => ToolKind.Emoji; public override ToolKind Kind => ToolKind.Emoji;
public override Rect Bounds public override bool CanRotate => true;
public override Rect Frame
{ {
get get
{ {
@@ -24,15 +30,34 @@ public sealed class EmojiAnnotation : Annotation
} }
} }
public override Rect Bounds => RotatedBounds(Frame);
public override bool HitTest(Point point, double tolerance)
{
Matrix toLocal = RotationMatrix;
toLocal.Invert();
Rect frame = Frame;
frame.Inflate(tolerance, tolerance);
return frame.Contains(toLocal.Transform(point));
}
public override void Render(DrawingContext dc, EditorDocument document) public override void Render(DrawingContext dc, EditorDocument document)
{ {
ImageSource image = EmojiCatalog.Get(Style.Emoji, Style.SkinTone); ImageSource image = EmojiCatalog.Get(Style.Emoji, Style.SkinTone);
Rect bounds = Bounds; Rect bounds = Frame;
bool flipped = FlipX || FlipY;
double sx = FlipX ? -1 : 1, sy = FlipY ? -1 : 1;
if (Style.Shadow) if (Style.Shadow)
{ {
double offset = Math.Max(2, Style.Size / 24); double offset = Math.Max(2, Style.Size / 24);
var mask = RenderHelpers.Freeze(new ImageBrush(image)); var mask = new ImageBrush(image);
if (flipped)
{
mask.RelativeTransform = new ScaleTransform(sx, sy, 0.5, 0.5);
}
mask.Freeze();
DrawShadowLayer(dc, mask, bounds, ShadowFar, offset * 1.6); DrawShadowLayer(dc, mask, bounds, ShadowFar, offset * 1.6);
DrawShadowLayer(dc, mask, bounds, ShadowNear, offset); DrawShadowLayer(dc, mask, bounds, ShadowNear, offset);
} }
@@ -42,18 +67,48 @@ public sealed class EmojiAnnotation : Annotation
RenderOptions.SetBitmapScalingMode(group, BitmapScalingMode.HighQuality); RenderOptions.SetBitmapScalingMode(group, BitmapScalingMode.HighQuality);
using (DrawingContext gc = group.Open()) using (DrawingContext gc = group.Open())
{ {
if (Angle != 0)
{
gc.PushTransform(new RotateTransform(Angle, Center.X, Center.Y));
}
if (flipped)
{
gc.PushTransform(new ScaleTransform(sx, sy, Center.X, Center.Y));
}
gc.DrawImage(image, bounds); gc.DrawImage(image, bounds);
} }
dc.DrawDrawing(group); dc.DrawDrawing(group);
} }
private static void DrawShadowLayer(DrawingContext dc, Brush mask, Rect bounds, Brush brush, double offset) public override bool CanFlip => true;
public override void Flip(bool horizontal)
{ {
bounds.Offset(offset, offset); if (horizontal)
{
FlipX = !FlipX;
}
else
{
FlipY = !FlipY;
}
Angle = NormalizeAngle(-Angle);
}
/// <summary>De schaduw draait mee met de emoji, maar valt altijd rechtsonder.</summary>
private void DrawShadowLayer(DrawingContext dc, Brush mask, Rect bounds, Brush brush, double offset)
{
dc.PushTransform(new TranslateTransform(offset, offset));
dc.PushTransform(new RotateTransform(Angle, Center.X, Center.Y));
dc.PushOpacityMask(mask); dc.PushOpacityMask(mask);
dc.DrawRectangle(brush, null, bounds); dc.DrawRectangle(brush, null, bounds);
dc.Pop(); dc.Pop();
dc.Pop();
dc.Pop();
} }
public override void Move(Vector delta) => Center += delta; public override void Move(Vector delta) => Center += delta;
@@ -0,0 +1,106 @@
using System.Collections.Generic;
using System.Linq;
using System.Windows;
using System.Windows.Media;
namespace Kniip.Editor.Annotations;
/// <summary>Een vorm uit het vormengereedschap (driehoek, ster, hart, ...), geschaald naar zijn kader.</summary>
public sealed class FigureAnnotation : RectAnnotation
{
public bool FlipX { get; set; }
public bool FlipY { get; set; }
public override ToolKind Kind => ToolKind.Shape;
public override bool CanRotate => true;
public override Rect Frame => Rect;
public override Rect Bounds => RotatedBounds(Rect);
private bool IsOpen => ShapeGeometries.IsOpen(Style.ShapeKind);
/// <summary>De vorm in beeldcoördinaten; via de geometrietransformatie, zodat de lijndikte niet meeschaalt.</summary>
private Geometry BuildGeometry()
{
Geometry source = ShapeGeometries.For(Style.ShapeKind);
Rect b = source.Bounds;
Rect r = Rect;
double cx = r.Left + r.Width / 2, cy = r.Top + r.Height / 2;
var m = Matrix.Identity;
m.Translate(-(b.Left + b.Width / 2), -(b.Top + b.Height / 2));
m.Scale(r.Width / b.Width * (FlipX ? -1 : 1), r.Height / b.Height * (FlipY ? -1 : 1));
m.Translate(cx, cy);
m.RotateAt(Angle, cx, cy);
Geometry geometry = source.Clone();
geometry.Transform = new MatrixTransform(m);
geometry.Freeze();
return geometry;
}
public override void Render(DrawingContext dc, EditorDocument document)
{
Rect r = Rect;
if (r.Width < 1 && r.Height < 1)
{
return;
}
Brush? fill = IsOpen ? null : RenderHelpers.BrushOrNull(Style.Fill);
RenderHelpers.DrawWithShadow(dc, BuildGeometry(), fill,
RenderHelpers.CreatePen(Style.Stroke, Style.Thickness, Style.Dash), Style.Shadow);
}
public override bool HitTest(Point point, double tolerance)
{
Geometry geometry = BuildGeometry();
if (!IsOpen && geometry.FillContains(point))
{
return true;
}
var pen = new Pen(Brushes.Black, Style.Thickness + 2 * tolerance);
return geometry.StrokeContains(pen, point);
}
public override IReadOnlyList<Point> GetHandles()
{
Matrix m = RotationMatrix;
return base.GetHandles().Select(p => m.Transform(p)).ToArray();
}
/// <summary>Rekent de muis terug naar het ongedraaide kader; daarna wordt het kader zo verschoven dat de tegenoverliggende greep op zijn plek blijft.</summary>
public override void MoveHandle(int index, Point p, bool constrain)
{
Rect o = DragOrigin;
var c = new Point(o.Left + o.Width / 2, o.Top + o.Height / 2);
Matrix toLocal = Matrix.Identity;
toLocal.RotateAt(-Angle, c.X, c.Y);
base.MoveHandle(index, toLocal.Transform(p), constrain);
Rect r = Rect;
var rc = new Point(r.Left + r.Width / 2, r.Top + r.Height / 2);
Matrix toWorld = Matrix.Identity;
toWorld.RotateAt(Angle, c.X, c.Y);
Rect = Rect.Offset(r, toWorld.Transform(rc) - rc);
}
public override bool CanFlip => true;
public override void Flip(bool horizontal)
{
if (horizontal)
{
FlipX = !FlipX;
}
else
{
FlipY = !FlipY;
}
Angle = NormalizeAngle(-Angle);
}
}
@@ -111,6 +111,16 @@ public sealed class HighlightAnnotation : Annotation
Points[index] = new Point(point.X, Points[1 - index].Y); Points[index] = new Point(point.X, Points[1 - index].Y);
} }
public override bool CanFlip => !Straight;
public override void Flip(bool horizontal)
{
for (int i = 0; i < Points.Count; i++)
{
Points[i] = Mirror(Points[i], horizontal);
}
}
public override Annotation Clone() public override Annotation Clone()
{ {
var clone = (HighlightAnnotation)base.Clone(); var clone = (HighlightAnnotation)base.Clone();
+209 -18
View File
@@ -5,8 +5,16 @@ using System.Windows.Media;
namespace Kniip.Editor.Annotations; namespace Kniip.Editor.Annotations;
/// <summary>
/// Lijn of pijl. Met buigpunten wordt het een vloeiende kromme die door alle punten loopt.
/// </summary>
public sealed class LineAnnotation : Annotation public sealed class LineAnnotation : Annotation
{ {
private const int SamplesPerSegment = 16;
private int _dragBend = -1;
private int _pendingGhost = -1;
public LineAnnotation(bool isArrow) public LineAnnotation(bool isArrow)
{ {
IsArrow = isArrow; IsArrow = isArrow;
@@ -18,27 +26,124 @@ public sealed class LineAnnotation : Annotation
public Point End { get; set; } public Point End { get; set; }
/// <summary>Tussenpunten waar de kromme doorheen loopt, in volgorde van begin naar eind.</summary>
public List<Point> Bends { get; set; } = new();
public override ToolKind Kind => IsArrow ? ToolKind.Arrow : ToolKind.Line; public override ToolKind Kind => IsArrow ? ToolKind.Arrow : ToolKind.Line;
public override Rect Bounds public override Rect Bounds
{ {
get get
{ {
var r = new Rect(Start, End); List<Point> points = Flatten();
var r = new Rect(points[0], points[0]);
foreach (Point p in points)
{
r.Union(p);
}
double pad = Style.Thickness / 2 + (IsArrow ? HeadLength / 2 : 0); double pad = Style.Thickness / 2 + (IsArrow ? HeadLength / 2 : 0);
r.Inflate(pad, pad); r.Inflate(pad, pad);
return r; return r;
} }
} }
public override bool IsDegenerate => (End - Start).Length < 4; public override bool IsDegenerate => Bends.Count == 0 && (End - Start).Length < 4;
private double HeadLength => Math.Max(12, Style.Thickness * 4); private double HeadLength => Math.Max(12, Style.Thickness * 4);
private readonly record struct Segment(Point A, Point C1, Point C2, Point B)
{
public Point At(double t)
{
double u = 1 - t;
double a = u * u * u, b = 3 * u * u * t, c = 3 * u * t * t, d = t * t * t;
return new Point(
a * A.X + b * C1.X + c * C2.X + d * B.X,
a * A.Y + b * C1.Y + c * C2.Y + d * B.Y);
}
}
private List<Point> ThroughPoints()
{
var points = new List<Point>(Bends.Count + 2) { Start };
points.AddRange(Bends);
points.Add(End);
return points;
}
/// <summary>Catmull-Rom door alle punten, omgezet naar kubische Bezier-segmenten.</summary>
private List<Segment> Segments()
{
List<Point> p = ThroughPoints();
int last = p.Count - 1;
var segments = new List<Segment>(last);
for (int i = 0; i < last; i++)
{
Point prev = p[Math.Max(i - 1, 0)];
Point next = p[Math.Min(i + 2, last)];
segments.Add(new Segment(p[i], p[i] + (p[i + 1] - prev) / 6, p[i + 1] - (next - p[i]) / 6, p[i + 1]));
}
return segments;
}
private List<Point> Flatten()
{
if (Bends.Count == 0)
{
return new List<Point> { Start, End };
}
var points = new List<Point> { Start };
foreach (Segment s in Segments())
{
for (int k = 1; k <= SamplesPerSegment; k++)
{
points.Add(s.At(k / (double)SamplesPerSegment));
}
}
return points;
}
private static double PathLength(List<Point> points)
{
double length = 0;
for (int i = 1; i < points.Count; i++)
{
length += (points[i] - points[i - 1]).Length;
}
return length;
}
private Geometry BuildGeometry(List<Segment> segments)
{
var geometry = new StreamGeometry();
using (StreamGeometryContext ctx = geometry.Open())
{
ctx.BeginFigure(segments[0].A, false, false);
foreach (Segment s in segments)
{
if (Bends.Count == 0)
{
ctx.LineTo(s.B, true, true);
}
else
{
ctx.BezierTo(s.C1, s.C2, s.B, true, true);
}
}
}
geometry.Freeze();
return geometry;
}
public override void Render(DrawingContext dc, EditorDocument document) public override void Render(DrawingContext dc, EditorDocument document)
{ {
Vector v = End - Start; double length = PathLength(Flatten());
double length = v.Length;
if (length < 1) if (length < 1)
{ {
return; return;
@@ -50,18 +155,26 @@ public sealed class LineAnnotation : Annotation
return; return;
} }
var geometry = new GeometryGroup { FillRule = FillRule.Nonzero }; List<Segment> segments = Segments();
Brush? headFill = null;
if (IsArrow) if (IsArrow)
{ {
Segment lastSegment = segments[^1];
Vector v = lastSegment.B - lastSegment.C2;
if (v.Length < 1e-6)
{
v = lastSegment.B - lastSegment.A;
}
v.Normalize(); v.Normalize();
double headLength = Math.Min(HeadLength, length); double headLength = Math.Min(HeadLength, length);
double halfWidth = headLength * 0.45; double halfWidth = headLength * 0.45;
Point basePoint = End - v * headLength; Point basePoint = End - v * headLength;
var normal = new Vector(-v.Y, v.X); var normal = new Vector(-v.Y, v.X);
geometry.Children.Add(new LineGeometry(Start, basePoint + v * Math.Min(2, headLength / 4))); Point lineEnd = basePoint + v * Math.Min(2, headLength / 4);
Vector shift = lineEnd - End;
segments[^1] = lastSegment with { C2 = lastSegment.C2 + shift, B = lineEnd };
var head = new StreamGeometry(); var head = new StreamGeometry();
using (StreamGeometryContext ctx = head.Open()) using (StreamGeometryContext ctx = head.Open())
@@ -72,39 +185,117 @@ public sealed class LineAnnotation : Annotation
} }
head.Freeze(); head.Freeze();
headFill = RenderHelpers.BrushOrNull(Style.Stroke); Brush? headFill = RenderHelpers.BrushOrNull(Style.Stroke);
// Lijn en punt apart tekenen zodat de punt gevuld en niet gestippeld wordt. // Lijn en punt apart tekenen zodat de punt gevuld en niet gestippeld wordt.
RenderHelpers.DrawWithShadow(dc, geometry, null, pen, Style.Shadow); RenderHelpers.DrawWithShadow(dc, BuildGeometry(segments), null, pen, Style.Shadow);
var headPen = RenderHelpers.CreatePen(Style.Stroke, Math.Max(1, Style.Thickness / 3), LineDash.Solid); var headPen = RenderHelpers.CreatePen(Style.Stroke, Math.Max(1, Style.Thickness / 3), LineDash.Solid);
RenderHelpers.DrawWithShadow(dc, head, headFill, headPen, Style.Shadow); RenderHelpers.DrawWithShadow(dc, head, headFill, headPen, Style.Shadow);
return; return;
} }
geometry.Children.Add(new LineGeometry(Start, End)); RenderHelpers.DrawWithShadow(dc, BuildGeometry(segments), null, pen, Style.Shadow);
RenderHelpers.DrawWithShadow(dc, geometry, null, pen, Style.Shadow);
} }
public override bool HitTest(Point point, double tolerance) => public override bool HitTest(Point point, double tolerance)
RenderHelpers.DistanceToSegment(point, Start, End) <= tolerance + Style.Thickness / 2 + (IsArrow ? 4 : 0); {
double limit = tolerance + Style.Thickness / 2 + (IsArrow ? 4 : 0);
List<Point> points = Flatten();
for (int i = 1; i < points.Count; i++)
{
if (RenderHelpers.DistanceToSegment(point, points[i - 1], points[i]) <= limit)
{
return true;
}
}
return false;
}
public override void Move(Vector delta) public override void Move(Vector delta)
{ {
Start += delta; Start += delta;
End += delta; End += delta;
for (int i = 0; i < Bends.Count; i++)
{
Bends[i] += delta;
}
} }
public override IReadOnlyList<Point> GetHandles() => new[] { Start, End }; /// <summary>Volgorde: begin, eind, buigpunten, en daarna één spook-greep midden op elk segment.</summary>
public override IReadOnlyList<Point> GetHandles()
{
var handles = new List<Point> { Start, End };
handles.AddRange(Bends);
foreach (Segment s in Segments())
{
handles.Add(s.At(0.5));
}
return handles;
}
public override bool IsGhostHandle(int index) => index >= 2 + Bends.Count;
public override void BeginHandleDrag(int index)
{
_pendingGhost = IsGhostHandle(index) ? index - 2 - Bends.Count : -1;
_dragBend = index >= 2 && _pendingGhost < 0 ? index - 2 : -1;
}
public override void MoveHandle(int index, Point point, bool constrain) public override void MoveHandle(int index, Point point, bool constrain)
{ {
if (index == 0) // Het buigpunt pas bij de eerste beweging invoegen, zodat een losse klik de lijn niet verandert.
if (_pendingGhost >= 0)
{ {
Start = constrain ? RenderHelpers.SnapAngle(End, point) : point; Bends.Insert(_pendingGhost, point);
_dragBend = _pendingGhost;
_pendingGhost = -1;
return;
} }
else
if (_dragBend >= 0)
{ {
End = constrain ? RenderHelpers.SnapAngle(Start, point) : point; Bends[_dragBend] = point;
}
else if (index == 0)
{
Start = constrain ? RenderHelpers.SnapAngle(Bends.Count > 0 ? Bends[0] : End, point) : point;
}
else if (index == 1)
{
End = constrain ? RenderHelpers.SnapAngle(Bends.Count > 0 ? Bends[^1] : Start, point) : point;
} }
} }
public override bool RemoveHandle(int index)
{
int bend = index - 2;
if (bend < 0 || bend >= Bends.Count)
{
return false;
}
Bends.RemoveAt(bend);
return true;
}
public override bool CanFlip => true;
public override void Flip(bool horizontal)
{
Start = Mirror(Start, horizontal);
End = Mirror(End, horizontal);
for (int i = 0; i < Bends.Count; i++)
{
Bends[i] = Mirror(Bends[i], horizontal);
}
}
public override Annotation Clone()
{
var clone = (LineAnnotation)base.Clone();
clone.Bends = new List<Point>(Bends);
return clone;
}
} }
@@ -6,7 +6,7 @@ namespace Kniip.Editor.Annotations;
/// <summary>Basis voor annotaties die door een rechthoek worden beschreven, met 8 formaatgrepen.</summary> /// <summary>Basis voor annotaties die door een rechthoek worden beschreven, met 8 formaatgrepen.</summary>
public abstract class RectAnnotation : Annotation public abstract class RectAnnotation : Annotation
{ {
private Rect _dragOrigin; protected Rect DragOrigin { get; private set; }
public Rect Rect { get; set; } public Rect Rect { get; set; }
@@ -28,11 +28,11 @@ public abstract class RectAnnotation : Annotation
}; };
} }
public override void BeginHandleDrag(int index) => _dragOrigin = Rect; public override void BeginHandleDrag(int index) => DragOrigin = Rect;
public override void MoveHandle(int index, Point p, bool constrain) public override void MoveHandle(int index, Point p, bool constrain)
{ {
Rect o = _dragOrigin; Rect o = DragOrigin;
Rect = index switch Rect = index switch
{ {
0 => FromAnchor(o.BottomRight, p, constrain), 0 => FromAnchor(o.BottomRight, p, constrain),
@@ -0,0 +1,110 @@
using System;
using System.Collections.Generic;
using System.Windows;
using System.Windows.Media;
namespace Kniip.Editor.Annotations;
/// <summary>Vormen voor het vormengereedschap, ontworpen in een raster van 100 x 100 en bij het tekenen naar het kader geschaald.</summary>
internal static class ShapeGeometries
{
private static readonly Dictionary<ShapeKind, Geometry> Cache = new();
public static bool IsOpen(ShapeKind kind) => kind == ShapeKind.Spiral;
public static Geometry For(ShapeKind kind)
{
if (!Cache.TryGetValue(kind, out Geometry? geometry))
{
geometry = RenderHelpers.Freeze(Create(kind));
Cache[kind] = geometry;
}
return geometry;
}
private static Geometry Create(ShapeKind kind) => kind switch
{
ShapeKind.BlockArrow => Geometry.Parse("M0,30 L58,30 L58,4 L100,50 L58,96 L58,70 L0,70 Z"),
ShapeKind.Star => Star(),
ShapeKind.Cloud => Cloud(),
ShapeKind.Balloon => Balloon(),
ShapeKind.Heart => Geometry.Parse(
"M50,95 C14,68 0,48 0,28 C0,11 13,0 28,0 C39,0 46,7 50,15 C54,7 61,0 72,0 C87,0 100,11 100,28 C100,48 86,68 50,95 Z"),
ShapeKind.Moon => Geometry.Combine(
new EllipseGeometry(new Point(50, 50), 50, 50),
new EllipseGeometry(new Point(74, 38), 42, 42),
GeometryCombineMode.Exclude, null, 0.01, ToleranceType.Absolute),
ShapeKind.Drop => Geometry.Parse("M50,0 C58,16 85,44 85,65 A35,35 0 1 1 15,65 C15,44 42,16 50,0 Z"),
ShapeKind.Egg => Geometry.Parse("M50,0 C77,0 94,38 94,62 C94,86 74,100 50,100 C26,100 6,86 6,62 C6,38 23,0 50,0 Z"),
ShapeKind.Lightning => Geometry.Parse("M58,0 L14,56 L44,56 L30,100 L86,38 L56,38 L74,0 Z"),
ShapeKind.Spiral => Spiral(),
_ => Geometry.Parse("M50,0 L100,100 L0,100 Z")
};
private static Geometry Star()
{
var points = new Point[10];
for (int i = 0; i < points.Length; i++)
{
double angle = -Math.PI / 2 + i * Math.PI / 5;
double radius = i % 2 == 0 ? 50 : 20;
points[i] = new Point(50 + radius * Math.Cos(angle), 50 + radius * Math.Sin(angle));
}
return Polygon(points, closed: true);
}
private static Geometry Cloud()
{
var center = new Point(50, 50);
const double rx = 33, ry = 19, bump = 19;
const int count = 6;
Geometry cloud = new EllipseGeometry(center, rx, ry);
for (int i = 0; i < count; i++)
{
double angle = 2 * Math.PI * (i + 0.5) / count;
var p = new Point(center.X + rx * Math.Cos(angle), center.Y + ry * Math.Sin(angle));
cloud = Geometry.Combine(cloud, new EllipseGeometry(p, bump, bump), GeometryCombineMode.Union, null, 0.01, ToleranceType.Absolute);
}
return cloud;
}
private static Geometry Balloon() =>
Geometry.Combine(
new RectangleGeometry(new Rect(0, 0, 100, 72), 14, 14),
Geometry.Parse("M20,68 L10,100 L42,68 Z"),
GeometryCombineMode.Union, null, 0.01, ToleranceType.Absolute);
private static Geometry Spiral()
{
const double turns = 3;
const int steps = 240;
double maxAngle = turns * 2 * Math.PI;
var points = new Point[steps + 1];
for (int i = 0; i <= steps; i++)
{
double angle = maxAngle * i / steps;
double radius = 50 * angle / maxAngle;
points[i] = new Point(50 + radius * Math.Cos(angle), 50 + radius * Math.Sin(angle));
}
return Polygon(points, closed: false);
}
private static Geometry Polygon(IReadOnlyList<Point> points, bool closed)
{
var geometry = new StreamGeometry();
using (StreamGeometryContext ctx = geometry.Open())
{
ctx.BeginFigure(points[0], closed, closed);
for (int i = 1; i < points.Count; i++)
{
ctx.LineTo(points[i], true, true);
}
}
return geometry;
}
}
+405 -21
View File
@@ -1,34 +1,136 @@
using System; using System;
using System.Collections.Generic;
using System.Windows; using System.Windows;
using System.Windows.Media; using System.Windows.Media;
namespace Kniip.Editor.Annotations; namespace Kniip.Editor.Annotations;
/// <summary>Tekst in een kader met achtergrond, rand en afgeronde hoeken.</summary> /// <summary>
/// Tekst in een kader, ballon, ovaal of wolk. Ballon, ovaal en wolk hebben een staart naar <see cref="TailTip"/>.
/// Zonder <see cref="BoxWidth"/> past de vorm zich aan de tekst aan; met een vaste breedte loopt de tekst door naar de volgende regel.
/// </summary>
public sealed class TextAnnotation : Annotation public sealed class TextAnnotation : Annotation
{ {
private const int TailHandle = 8;
private Rect _dragOrigin;
private ShapeKey _cachedKey;
private Geometry? _cachedShape;
public Point Position { get; set; } public Point Position { get; set; }
public string Text { get; set; } = string.Empty; public string Text { get; set; } = string.Empty;
/// <summary>Vaste breedte van de vorm (zonder staart); null = passend om de tekst.</summary>
public double? BoxWidth { get; set; }
/// <summary>Minimale hoogte van de vorm; past de tekst er niet in, dan wordt hij hoger.</summary>
public double? BoxHeight { get; set; }
/// <summary>Punt waar de staart naar wijst, in afbeeldingspixels; null = standaard linksonder de vorm.</summary>
public Point? TailTip { get; set; }
public override ToolKind Kind => ToolKind.Text; public override ToolKind Kind => ToolKind.Text;
public double Padding => Math.Round(Math.Max(4, Style.FontSize * 0.4)) + Math.Max(0, Style.Thickness) / 2; public double Padding => Math.Round(Math.Max(4, Style.FontSize * 0.4)) + Math.Max(0, Style.Thickness) / 2;
public bool HasTail => Style.TextShape != TextShape.Frame;
/// <summary>Straal van de bolletjes langs de rand van een wolk.</summary>
private double BumpRadius => Math.Max(6, Style.FontSize * 0.7);
private readonly record struct Layout(Rect Box, Rect Area, FormattedText Text, Point Origin);
private readonly record struct ShapeKey(Rect Box, Point Tip, TextShape Shape, double Corner, double Bump, double Thickness, int Bubbles);
/// <summary>Van de breedte of hoogte van het tekstvlak naar die van de vorm.</summary>
private double ToBox(double area) => Style.TextShape switch
{
TextShape.Oval => area * Math.Sqrt(2),
TextShape.Cloud => area * Math.Sqrt(2) + 2 * BumpRadius,
_ => area
};
private double ToArea(double box) => Style.TextShape switch
{
TextShape.Oval => box / Math.Sqrt(2),
TextShape.Cloud => Math.Max(0, box - 2 * BumpRadius) / Math.Sqrt(2),
_ => box
};
public double MinBoxWidth => ToBox(Style.FontSize * 2 + 2 * Padding);
private FormattedText Format(string text)
{
FormattedText ft = RenderHelpers.FormatText(text, Style.FontSize, Style.Foreground, Style.Bold);
ft.Trimming = TextTrimming.None;
return ft;
}
private Layout GetLayout()
{
FormattedText ft = Format(Text.Length == 0 ? " " : Text);
double pad = Padding;
double boxWidth;
if (BoxWidth is double fixedWidth)
{
boxWidth = Math.Max(fixedWidth, MinBoxWidth);
ft.MaxTextWidth = Math.Max(1, ToArea(boxWidth) - 2 * pad + 0.01);
}
else
{
boxWidth = ToBox(ft.WidthIncludingTrailingWhitespace + 2 * pad);
}
double boxHeight = Math.Max(BoxHeight ?? 0, ToBox(ft.Height + 2 * pad));
var box = new Rect(Position, new Size(boxWidth, boxHeight));
double areaWidth = ToArea(boxWidth);
double areaHeight = ToArea(boxHeight);
var area = new Rect(box.X + (boxWidth - areaWidth) / 2, box.Y + (boxHeight - areaHeight) / 2, areaWidth, areaHeight);
var origin = new Point(area.X + pad, area.Y + (area.Height - ft.Height) / 2);
return new Layout(box, area, ft, origin);
}
/// <summary>De vorm zonder staart; hier zitten de formaatgrepen.</summary>
public Rect Box => GetLayout().Box;
/// <summary>Linksboven van de eerste regel tekst.</summary>
public Point TextOrigin => GetLayout().Origin;
/// <summary>Breedte waarop de tekst afbreekt, of null als de vorm met de tekst meegroeit.</summary>
public double? WrapWidth => BoxWidth == null ? null : GetLayout().Text.MaxTextWidth;
public Point DefaultTailTip(Rect box) =>
new(box.Left - Style.FontSize, box.Bottom + Style.FontSize * 2);
private Point Tip(Rect box) => TailTip ?? DefaultTailTip(box);
public override Rect Bounds public override Rect Bounds
{ {
get get
{ {
FormattedText ft = Format(Text.Length == 0 ? " " : Text); Rect box = GetLayout().Box;
double pad = Padding; Rect r = ShapeFor(box).Bounds;
return new Rect(Position, new Size(ft.WidthIncludingTrailingWhitespace + 2 * pad, ft.Height + 2 * pad)); r.Union(box);
double half = Math.Max(0, Style.Thickness) / 2;
r.Inflate(half, half);
return r;
} }
} }
public override bool IsDegenerate => string.IsNullOrWhiteSpace(Text); public override bool IsDegenerate => string.IsNullOrWhiteSpace(Text);
private FormattedText Format(string text) => public override bool HitTest(Point point, double tolerance)
RenderHelpers.FormatText(text, Style.FontSize, Style.Foreground, Style.Bold); {
Geometry shape = ShapeFor(GetLayout().Box);
if (shape.FillContains(point))
{
return true;
}
var pen = new Pen(Brushes.Black, 2 * tolerance + Math.Max(0, Style.Thickness));
return shape.StrokeContains(pen, point);
}
public override void Render(DrawingContext dc, EditorDocument document) public override void Render(DrawingContext dc, EditorDocument document)
{ {
@@ -37,25 +139,307 @@ public sealed class TextAnnotation : Annotation
return; return;
} }
FormattedText ft = Format(Text); Layout layout = GetLayout();
double pad = Padding; bool hasShape = RenderShape(dc, layout.Box);
var box = new Rect(Position, new Size(ft.WidthIncludingTrailingWhitespace + 2 * pad, ft.Height + 2 * pad)); if (!hasShape && Style.Shadow)
Brush? fill = RenderHelpers.BrushOrNull(Style.Fill);
Pen? pen = RenderHelpers.CreatePen(Style.Stroke, Style.Thickness, Style.Dash);
if (fill != null || pen != null)
{
RenderHelpers.DrawWithShadow(dc, new RectangleGeometry(box, Style.CornerRadius, Style.CornerRadius),
fill, pen, Style.Shadow);
}
else if (Style.Shadow)
{ {
FormattedText shadow = RenderHelpers.FormatText(Text, Style.FontSize, Color.FromArgb(90, 0, 0, 0), Style.Bold); FormattedText shadow = RenderHelpers.FormatText(Text, Style.FontSize, Color.FromArgb(90, 0, 0, 0), Style.Bold);
dc.DrawText(shadow, new Point(Position.X + pad + 1.5, Position.Y + pad + 1.5)); shadow.Trimming = TextTrimming.None;
if (BoxWidth != null)
{
shadow.MaxTextWidth = layout.Text.MaxTextWidth;
}
dc.DrawText(shadow, new Point(layout.Origin.X + 1.5, layout.Origin.Y + 1.5));
} }
dc.DrawText(ft, new Point(Position.X + pad, Position.Y + pad)); dc.DrawText(layout.Text, layout.Origin);
} }
public override void Move(Vector delta) => Position += delta; /// <summary>Alleen de vorm, zonder tekst; wordt getekend terwijl de tekst in een tekstvak wordt bewerkt.</summary>
public void RenderOutline(DrawingContext dc) => RenderShape(dc, GetLayout().Box);
/// <summary>True als er een rand of vulling is getekend.</summary>
private bool RenderShape(DrawingContext dc, Rect box)
{
Brush? fill = RenderHelpers.BrushOrNull(Style.Fill);
Pen? pen = RenderHelpers.CreatePen(Style.Stroke, Style.Thickness, Style.Dash);
if (fill == null && pen == null)
{
return false;
}
RenderHelpers.DrawWithShadow(dc, ShapeFor(box), fill, pen, Style.Shadow);
return true;
}
// ---------------------------------------------------------------- Vorm
private Geometry ShapeFor(Rect box)
{
var key = new ShapeKey(box, HasTail ? Tip(box) : default, Style.TextShape, Style.CornerRadius, BumpRadius, Style.Thickness, Style.CloudBubbles);
if (_cachedShape == null || key != _cachedKey)
{
_cachedShape = BuildShape(box, key.Tip);
_cachedKey = key;
}
return _cachedShape;
}
private Geometry BuildShape(Rect box, Point tip)
{
Geometry shape = Style.TextShape switch
{
TextShape.Balloon => WithTail(new RectangleGeometry(box, Style.CornerRadius, Style.CornerRadius), BalloonTail(box, tip)),
TextShape.Oval => WithTail(new EllipseGeometry(box), OvalTail(box, tip)),
TextShape.Cloud => Cloud(box, tip),
_ => new RectangleGeometry(box, Style.CornerRadius, Style.CornerRadius)
};
shape.Freeze();
return shape;
}
private static Geometry WithTail(Geometry body, Geometry? tail) =>
tail == null ? body : Geometry.Combine(body, tail, GeometryCombineMode.Union, null);
private static Geometry Triangle(Point a, Point b, Point tip)
{
var geometry = new StreamGeometry();
using (StreamGeometryContext ctx = geometry.Open())
{
ctx.BeginFigure(a, true, true);
ctx.LineTo(tip, true, true);
ctx.LineTo(b, true, true);
}
return geometry;
}
/// <summary>Spitse staart vanaf de zijde die naar de punt kijkt.</summary>
private Geometry? BalloonTail(Rect box, Point tip)
{
if (box.Contains(tip))
{
return null;
}
Point center = new(box.X + box.Width / 2, box.Y + box.Height / 2);
Vector d = tip - center;
bool horizontal = Math.Abs(d.X) / Math.Max(1, box.Width) > Math.Abs(d.Y) / Math.Max(1, box.Height);
double corner = Math.Min(Style.CornerRadius, Math.Min(box.Width, box.Height) / 2);
double inset = Math.Max(1, Style.Thickness) + 1;
double side = horizontal ? box.Height : box.Width;
double half = Math.Max(1, Math.Min(side * 0.125, (side - 2 * corner) / 2));
double low = (horizontal ? box.Top : box.Left) + corner + half;
double high = (horizontal ? box.Bottom : box.Right) - corner - half;
double middle = horizontal ? center.Y : center.X;
double toward = horizontal ? tip.Y : tip.X;
double c = low > high ? middle : Math.Clamp((middle + toward) / 2, low, high);
if (horizontal)
{
double x = d.X < 0 ? box.Left + inset : box.Right - inset;
return Triangle(new Point(x, c - half), new Point(x, c + half), tip);
}
double y = d.Y < 0 ? box.Top + inset : box.Bottom - inset;
return Triangle(new Point(c - half, y), new Point(c + half, y), tip);
}
private static Point OnEllipse(Point center, double rx, double ry, double angle) =>
new(center.X + rx * Math.Cos(angle), center.Y + ry * Math.Sin(angle));
private static bool InsideEllipse(Point center, double rx, double ry, Point p)
{
if (rx <= 0 || ry <= 0)
{
return false;
}
double x = (p.X - center.X) / rx;
double y = (p.Y - center.Y) / ry;
return x * x + y * y <= 1;
}
/// <summary>Afstand van een punt tot de ellips, gemeten langs de lijn vanuit het midden; negatief als het punt erbinnen ligt.</summary>
private static double OutsideDistance(Point center, double rx, double ry, Point p)
{
Vector v = p - center;
double scale = Math.Sqrt(Math.Pow(v.X / rx, 2) + Math.Pow(v.Y / ry, 2));
return scale < 1e-9 ? -Math.Min(rx, ry) : v.Length * (1 - 1 / scale);
}
/// <summary>Hoek op de ellips (in de genormaliseerde ruimte) in de richting van de punt.</summary>
private static double AngleToward(Point center, double rx, double ry, Point tip) =>
Math.Atan2((tip.Y - center.Y) / Math.Max(1, ry), (tip.X - center.X) / Math.Max(1, rx));
private Geometry? OvalTail(Rect box, Point tip)
{
Point center = new(box.X + box.Width / 2, box.Y + box.Height / 2);
double rx = box.Width / 2, ry = box.Height / 2;
if (InsideEllipse(center, rx, ry, tip))
{
return null;
}
double angle = AngleToward(center, rx, ry, tip);
const double spread = 0.24;
const double inward = 0.94;
return Triangle(
OnEllipse(center, rx * inward, ry * inward, angle - spread),
OnEllipse(center, rx * inward, ry * inward, angle + spread),
tip);
}
/// <summary>Bolletjes langs een ellips, met als staart steeds kleinere rondjes naar de punt; automatisch meer rondjes naarmate de punt verder weg ligt.</summary>
private Geometry Cloud(Rect box, Point tip)
{
double r = BumpRadius;
Point center = new(box.X + box.Width / 2, box.Y + box.Height / 2);
double rx = Math.Max(1, box.Width / 2 - r);
double ry = Math.Max(1, box.Height / 2 - r);
double h = Math.Pow((rx - ry) / (rx + ry), 2);
double perimeter = Math.PI * (rx + ry) * (1 + 3 * h / (10 + Math.Sqrt(4 - 3 * h)));
int count = Math.Max(6, (int)Math.Round(perimeter / (r * 1.75)));
Geometry cloud = new EllipseGeometry(center, rx, ry);
for (int i = 0; i < count; i++)
{
Point p = OnEllipse(center, rx, ry, 2 * Math.PI * i / count);
cloud = Geometry.Combine(cloud, new EllipseGeometry(p, r, r), GeometryCombineMode.Union, null);
}
if (!HasTail || InsideEllipse(center, rx + r, ry + r, tip))
{
return cloud;
}
var group = new GeometryGroup { FillRule = FillRule.Nonzero };
group.Children.Add(cloud);
// Vanaf de punt terug naar de wolk, gelijk verdeeld; rondjes die de wolk zouden raken vallen weg.
Vector back = center - tip;
back.Normalize();
double gap = Math.Max(2, r * 0.35);
double smallest = Math.Max(2, r * 0.32);
double largest = Math.Max(smallest, r * 0.72);
double pitch = 2 * largest + gap;
double available = Math.Max(0, OutsideDistance(center, rx + r, ry + r, tip) - gap - largest);
int bubbles = Style.CloudBubbles > 0
? Style.CloudBubbles
: Math.Clamp((int)(available / pitch) + 1, 1, 40);
double spacing = bubbles > 1 ? available / (bubbles - 1) : 0;
double shrink = bubbles > 1 ? Math.Min(1, spacing / pitch) : 1;
for (int i = 0; i < bubbles; i++)
{
double t = bubbles > 1 ? (double)i / (bubbles - 1) : 0.5;
double radius = Math.Max(1, (smallest + (largest - smallest) * t) * shrink);
Point c = tip + back * (spacing * i);
if (OutsideDistance(center, rx + r, ry + r, c) < radius * 0.8)
{
break;
}
group.Children.Add(new EllipseGeometry(c, radius, radius));
}
return group;
}
// ---------------------------------------------------------------- Grepen
public override void Move(Vector delta)
{
Position += delta;
if (TailTip is Point tip)
{
TailTip = tip + delta;
}
}
/// <summary>Volgorde: 8 formaatgrepen zoals bij <see cref="RectAnnotation"/>, daarna de staartpunt.</summary>
public override IReadOnlyList<Point> GetHandles()
{
Rect r = GetLayout().Box;
double cx = r.Left + r.Width / 2;
double cy = r.Top + r.Height / 2;
var handles = new List<Point>
{
r.TopLeft, new Point(cx, r.Top), r.TopRight, new Point(r.Right, cy),
r.BottomRight, new Point(cx, r.Bottom), r.BottomLeft, new Point(r.Left, cy)
};
if (HasTail)
{
handles.Add(Tip(r));
}
return handles;
}
public override void BeginHandleDrag(int index)
{
_dragOrigin = GetLayout().Box;
TailTip ??= DefaultTailTip(_dragOrigin);
}
public override void MoveHandle(int index, Point p, bool constrain)
{
if (index == TailHandle)
{
TailTip = p;
return;
}
Rect o = _dragOrigin;
Rect r = index switch
{
0 => RectAnnotation.FromAnchor(o.BottomRight, p, constrain),
1 => new Rect(new Point(o.Left, p.Y), o.BottomRight),
2 => RectAnnotation.FromAnchor(o.BottomLeft, p, constrain),
3 => new Rect(o.TopLeft, new Point(p.X, o.Bottom)),
4 => RectAnnotation.FromAnchor(o.TopLeft, p, constrain),
5 => new Rect(o.TopLeft, new Point(o.Right, p.Y)),
6 => RectAnnotation.FromAnchor(o.TopRight, p, constrain),
7 => new Rect(new Point(p.X, o.Top), o.BottomRight),
_ => o
};
double minWidth = MinBoxWidth;
if (r.Width < minWidth)
{
bool leftEdge = index is 0 or 6 or 7 && p.X < o.Right;
r = new Rect(leftEdge ? r.Right - minWidth : r.Left, r.Top, minWidth, r.Height);
}
Position = r.TopLeft;
BoxWidth = r.Width;
BoxHeight = r.Height;
}
/// <summary>Dubbelklik op een formaatgreep: terug naar een vorm die met de tekst meegroeit.</summary>
public override bool RemoveHandle(int index)
{
if (index >= TailHandle || (BoxWidth == null && BoxHeight == null))
{
return false;
}
BoxWidth = null;
BoxHeight = null;
return true;
}
public override Annotation Clone()
{
var clone = (TextAnnotation)base.Clone();
clone._cachedShape = null;
return clone;
}
} }
+78
View File
@@ -0,0 +1,78 @@
using System;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
namespace Kniip.Editor;
public enum BackgroundKind { None, Color, Gradient, Image }
public enum GradientDirection { Linear, Radial }
/// <summary>
/// Wat er onder het knipsel ligt; alleen zichtbaar waar het knipsel transparant is.
/// De kleuren en afbeelding blijven bewaard bij het wisselen van soort.
/// </summary>
/// <param name="Angle">Richting van een lineair verloop in graden, met de klok mee vanaf boven: 90 = naar rechts, 180 = naar onder.</param>
public sealed record CanvasBackground(
BackgroundKind Kind,
Color Color,
Color Color2,
GradientDirection Direction,
double Angle,
BitmapSource? Image)
{
public static readonly CanvasBackground Default = new(
BackgroundKind.None, Colors.White, Color.FromRgb(0x00, 0x78, 0xD4), GradientDirection.Linear, 180, null);
public bool IsVisible => Kind switch
{
BackgroundKind.None => false,
BackgroundKind.Image => Image != null,
_ => true
};
public void Render(DrawingContext dc, int width, int height)
{
var area = new Rect(0, 0, width, height);
switch (Kind)
{
case BackgroundKind.Color:
dc.DrawRectangle(new SolidColorBrush(Color), null, area);
break;
case BackgroundKind.Gradient:
dc.DrawRectangle(CreateGradient(), null, area);
break;
case BackgroundKind.Image when Image != null:
// Eigen schaalmodus: de export tekent verder met NearestNeighbor.
var group = new DrawingGroup { ClipGeometry = new RectangleGeometry(area) };
RenderOptions.SetBitmapScalingMode(group, BitmapScalingMode.HighQuality);
group.Children.Add(new ImageDrawing(Image, Cover(Image.PixelWidth, Image.PixelHeight, area)));
dc.DrawDrawing(group);
break;
}
}
private Brush CreateGradient()
{
if (Direction == GradientDirection.Radial)
{
return new RadialGradientBrush(Color, Color2);
}
// In de eenheidsruimte van het vlak, zodat 45° van hoek naar hoek loopt; de halve lengte reikt tot in de hoeken.
double radians = Angle * Math.PI / 180;
var direction = new Vector(Math.Sin(radians), -Math.Cos(radians));
double half = (Math.Abs(direction.X) + Math.Abs(direction.Y)) / 2;
var center = new Point(0.5, 0.5);
return new LinearGradientBrush(Color, Color2, center - direction * half, center + direction * half);
}
/// <summary>Afbeelding zo schalen dat ze het hele vlak vult; wat uitsteekt valt weg.</summary>
private static Rect Cover(double imageWidth, double imageHeight, Rect area)
{
double scale = Math.Max(area.Width / imageWidth, area.Height / imageHeight);
double w = imageWidth * scale, h = imageHeight * scale;
return new Rect(area.X + (area.Width - w) / 2, area.Y + (area.Height - h) / 2, w, h);
}
}
+7 -7
View File
@@ -7,32 +7,32 @@
<DrawingBrush.Drawing> <DrawingBrush.Drawing>
<DrawingGroup> <DrawingGroup>
<GeometryDrawing Brush="White" Geometry="M0,0 H2 V2 H0 Z" /> <GeometryDrawing Brush="White" Geometry="M0,0 H2 V2 H0 Z" />
<GeometryDrawing Brush="#D4D4D4" Geometry="M0,0 H1 V1 H0 Z M1,1 H2 V2 H1 Z" /> <GeometryDrawing Brush="#E4E4E4" Geometry="M0,0 H1 V1 H0 Z M1,1 H2 V2 H1 Z" />
</DrawingGroup> </DrawingGroup>
</DrawingBrush.Drawing> </DrawingBrush.Drawing>
</DrawingBrush> </DrawingBrush>
</UserControl.Resources> </UserControl.Resources>
<Grid> <Grid>
<ToggleButton x:Name="Toggle" Style="{StaticResource FlatButton}" Padding="6,4" MinWidth="0" MinHeight="30" <ToggleButton x:Name="Toggle" Style="{StaticResource FlatButton}" Padding="6,4" MinWidth="0" MinHeight="30"
BorderBrush="#E0E0E0" Background="White"> BorderBrush="{DynamicResource ControlBorderBrush}" Background="{DynamicResource ControlBrush}">
<StackPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal">
<Grid Width="20" Height="20"> <Grid Width="20" Height="20">
<Border x:Name="CheckerBack" CornerRadius="4" Background="{StaticResource Checker}" Visibility="Collapsed" /> <Border x:Name="CheckerBack" CornerRadius="4" Background="{StaticResource Checker}" Visibility="Collapsed" />
<Border x:Name="Swatch" CornerRadius="4" BorderThickness="0" /> <Border x:Name="Swatch" CornerRadius="4" BorderThickness="0" />
<Border x:Name="OutlineHole" CornerRadius="1.5" Margin="5" Background="White" Visibility="Collapsed" /> <Border x:Name="OutlineHole" CornerRadius="1.5" Margin="5" Background="{DynamicResource ControlBrush}" Visibility="Collapsed" />
<Grid x:Name="TextGlyph" Background="White" Visibility="Collapsed"> <Grid x:Name="TextGlyph" Background="{DynamicResource ControlBrush}" Visibility="Collapsed">
<TextBlock Text="A" FontSize="14" HorizontalAlignment="Center" Margin="0,-1,0,0" <TextBlock Text="A" FontSize="14" HorizontalAlignment="Center" Margin="0,-1,0,0"
Foreground="{StaticResource InkBrush}" /> Foreground="{DynamicResource InkBrush}" />
<Border x:Name="TextBar" Height="4" CornerRadius="1" VerticalAlignment="Bottom" Margin="2,0" /> <Border x:Name="TextBar" Height="4" CornerRadius="1" VerticalAlignment="Bottom" Margin="2,0" />
</Grid> </Grid>
</Grid> </Grid>
<TextBlock Text="&#xE70D;" Style="{StaticResource IconText}" FontSize="9" Margin="5,0,1,0" Foreground="#5C5C5C" /> <TextBlock Text="&#xE70D;" Style="{StaticResource IconText}" FontSize="9" Margin="5,0,1,0" Foreground="{DynamicResource SubtleTextBrush}" />
</StackPanel> </StackPanel>
</ToggleButton> </ToggleButton>
<Popup x:Name="PopupPanel" PlacementTarget="{Binding ElementName=Toggle}" Placement="Bottom" StaysOpen="False" <Popup x:Name="PopupPanel" PlacementTarget="{Binding ElementName=Toggle}" Placement="Bottom" StaysOpen="False"
AllowsTransparency="True" PopupAnimation="Fade" AllowsTransparency="True" PopupAnimation="Fade"
IsOpen="{Binding IsChecked, ElementName=Toggle, Mode=TwoWay}"> IsOpen="{Binding IsChecked, ElementName=Toggle, Mode=TwoWay}">
<Border Background="White" BorderBrush="#D6D6D6" BorderThickness="1" CornerRadius="6" Padding="8" Margin="0,4,10,10"> <Border Background="{DynamicResource CardBrush}" BorderBrush="{DynamicResource ControlStrongBorderBrush}" BorderThickness="1" CornerRadius="6" Padding="8" Margin="0,4,10,10">
<Border.Effect> <Border.Effect>
<DropShadowEffect BlurRadius="10" ShadowDepth="2" Opacity="0.18" /> <DropShadowEffect BlurRadius="10" ShadowDepth="2" Opacity="0.18" />
</Border.Effect> </Border.Effect>
+13 -3
View File
@@ -7,8 +7,13 @@ namespace Kniip.Editor;
internal static class DocumentRenderer internal static class DocumentRenderer
{ {
public static void Render(DrawingContext dc, EditorDocument document) public static void Render(DrawingContext dc, EditorDocument document, bool includeBackground = true)
{ {
if (includeBackground && document.HasTransparency)
{
document.Background.Render(dc, document.Width, document.Height);
}
dc.DrawImage(document.Image, new Rect(0, 0, document.Width, document.Height)); dc.DrawImage(document.Image, new Rect(0, 0, document.Width, document.Height));
foreach (Annotation item in document.Items) foreach (Annotation item in document.Items)
{ {
@@ -16,11 +21,16 @@ internal static class DocumentRenderer
{ {
item.Render(dc, document); item.Render(dc, document);
} }
else if (item is TextAnnotation text)
{
text.RenderOutline(dc);
}
} }
} }
/// <summary>Maakt de annotaties plat op de originele resolutie van het knipsel.</summary> /// <summary>Maakt de annotaties plat op de originele resolutie van het knipsel.</summary>
public static BitmapSource Export(EditorDocument document) /// <param name="includeBackground">False bij samenvoegen: de achtergrond blijft dan apart instelbaar.</param>
public static BitmapSource Export(EditorDocument document, bool includeBackground = true)
{ {
var visual = new DrawingVisual(); var visual = new DrawingVisual();
RenderOptions.SetBitmapScalingMode(visual, BitmapScalingMode.NearestNeighbor); RenderOptions.SetBitmapScalingMode(visual, BitmapScalingMode.NearestNeighbor);
@@ -28,7 +38,7 @@ internal static class DocumentRenderer
using (DrawingContext dc = visual.RenderOpen()) using (DrawingContext dc = visual.RenderOpen())
{ {
dc.PushClip(new RectangleGeometry(new Rect(0, 0, document.Width, document.Height))); dc.PushClip(new RectangleGeometry(new Rect(0, 0, document.Width, document.Height)));
Render(dc, document); Render(dc, document, includeBackground);
dc.Pop(); dc.Pop();
} }
+68 -4
View File
@@ -2,20 +2,28 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Windows; using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging; using System.Windows.Media.Imaging;
using Kniip.Editor.Annotations; using Kniip.Editor.Annotations;
using Kniip.Infrastructure; using Kniip.Infrastructure;
namespace Kniip.Editor; namespace Kniip.Editor;
public sealed record DocumentState(BitmapSource Image, byte[] Pixels, List<Annotation> Items); public sealed record DocumentState(BitmapSource Image, byte[] Pixels, List<Annotation> Items, CanvasBackground Background);
/// <summary>Het knipsel (als Bgra32-pixels, 96 dpi zodat 1 DIP = 1 pixel) plus de annotaties erop.</summary> /// <summary>Het knipsel (als Bgra32-pixels, 96 dpi zodat 1 DIP = 1 pixel) plus de annotaties erop.</summary>
public sealed class EditorDocument public sealed class EditorDocument
{ {
public EditorDocument(BitmapSource source) private (BitmapSource Image, byte[] Pixels)? _composite;
private CanvasBackground _background = CanvasBackground.Default;
/// <param name="isBlank">Leeg canvas: volledig transparant laten in plaats van als alpha-loze afbeelding te behandelen.</param>
public EditorDocument(BitmapSource source, bool isBlank = false)
{ {
SetPixels(PixelUtil.ToOpaqueBgra(source), source.PixelWidth, source.PixelHeight); byte[] pixels = isBlank || !PixelUtil.IsFullyTransparent(source)
? PixelUtil.ToBgra(source)
: PixelUtil.ToOpaqueBgra(source);
SetPixels(pixels, source.PixelWidth, source.PixelHeight);
} }
public BitmapSource Image { get; private set; } = null!; public BitmapSource Image { get; private set; } = null!;
@@ -26,21 +34,70 @@ public sealed class EditorDocument
public int Height => Image.PixelHeight; public int Height => Image.PixelHeight;
/// <summary>Of ergens doorheen te kijken is; alleen dan heeft een achtergrond zin.</summary>
public bool HasTransparency { get; private set; }
public List<Annotation> Items { get; private set; } = new(); public List<Annotation> Items { get; private set; } = new();
public CanvasBackground Background
{
get => _background;
set
{
_background = value;
_composite = null;
}
}
/// <summary>Achtergrond plus knipsel zonder annotaties: de bron voor vervagen en pixeleren.</summary>
public BitmapSource EffectImage => Composite().Image;
public byte[] EffectPixels => Composite().Pixels;
private void SetPixels(byte[] pixels, int width, int height) private void SetPixels(byte[] pixels, int width, int height)
{ {
Image = PixelUtil.CreateBitmap(pixels, width, height); Image = PixelUtil.CreateBitmap(pixels, width, height);
Pixels = pixels; Pixels = pixels;
HasTransparency = PixelUtil.HasTransparency(pixels);
_composite = null;
} }
public DocumentState CaptureState() => new(Image, Pixels, Items.Select(i => i.Clone()).ToList()); private (BitmapSource Image, byte[] Pixels) Composite()
{
if (_composite is { } cached)
{
return cached;
}
if (!HasTransparency || !Background.IsVisible)
{
_composite = (Image, Pixels);
return _composite.Value;
}
var visual = new DrawingVisual();
using (DrawingContext dc = visual.RenderOpen())
{
Background.Render(dc, Width, Height);
dc.DrawImage(Image, new Rect(0, 0, Width, Height));
}
var bitmap = new RenderTargetBitmap(Width, Height, 96, 96, PixelFormats.Pbgra32);
bitmap.Render(visual);
byte[] pixels = PixelUtil.ToBgra(bitmap);
_composite = (PixelUtil.CreateBitmap(pixels, Width, Height), pixels);
return _composite.Value;
}
public DocumentState CaptureState() => new(Image, Pixels, Items.Select(i => i.Clone()).ToList(), Background);
public void Restore(DocumentState state) public void Restore(DocumentState state)
{ {
Image = state.Image; Image = state.Image;
Pixels = state.Pixels; Pixels = state.Pixels;
HasTransparency = PixelUtil.HasTransparency(state.Pixels);
Items = state.Items.Select(i => i.Clone()).ToList(); Items = state.Items.Select(i => i.Clone()).ToList();
Background = state.Background;
} }
public void Crop(Int32Rect area) public void Crop(Int32Rect area)
@@ -59,6 +116,13 @@ public sealed class EditorDocument
} }
} }
/// <summary>Vervangt het knipsel door de platgemaakte versie; de annotaties zitten daarna in de pixels.</summary>
public void Flatten(BitmapSource rendered)
{
SetPixels(PixelUtil.ToBgra(rendered), rendered.PixelWidth, rendered.PixelHeight);
Items = new List<Annotation>();
}
public string GetStepLabel(StepAnnotation step) public string GetStepLabel(StepAnnotation step)
{ {
int ordinal = 0; int ordinal = 0;
+411 -47
View File
@@ -2,19 +2,39 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:Kniip.Editor" xmlns:local="clr-namespace:Kniip.Editor"
xmlns:sys="clr-namespace:System;assembly=mscorlib"
Title="kniip" Width="1180" Height="780" MinWidth="760" MinHeight="420" Title="kniip" Width="1180" Height="780" MinWidth="760" MinHeight="420"
WindowStartupLocation="CenterScreen" Background="{StaticResource WindowBrush}" WindowStartupLocation="CenterScreen" Background="{DynamicResource WindowBrush}" Foreground="{DynamicResource InkBrush}"
FontFamily="Segoe UI" FontSize="13" UseLayoutRounding="True" FontFamily="Segoe UI" FontSize="13" UseLayoutRounding="True"
Icon="pack://application:,,,/AppIcon.ico"> Icon="pack://application:,,,/AppIcon.ico"
AllowDrop="True" PreviewDragEnter="Window_PreviewDragOver" PreviewDragOver="Window_PreviewDragOver" PreviewDrop="Window_PreviewDrop">
<Window.Resources> <Window.Resources>
<local:CornerPreviewConverter x:Key="CornerPreview" /> <local:CornerPreviewConverter x:Key="CornerPreview" />
<local:CursorIconConverter x:Key="CursorIcon" /> <local:CursorIconConverter x:Key="CursorIcon" />
<local:ShapeIconConverter x:Key="ShapeIcon" />
<Style x:Key="ArrangeButton" TargetType="Button" BasedOn="{StaticResource FlatButton}">
<Setter Property="MinWidth" Value="30" />
<Setter Property="MinHeight" Value="30" />
<Setter Property="Padding" Value="4" />
<Setter Property="Margin" Value="0" />
</Style>
<Style x:Key="ArrangeAccent" TargetType="Path" BasedOn="{StaticResource IconPath}">
<Setter Property="Stroke" Value="{DynamicResource AccentBrush}" />
<Setter Property="StrokeThickness" Value="1.5" />
</Style>
<Style x:Key="ArrangeDivider" TargetType="Rectangle">
<Setter Property="Width" Value="1" />
<Setter Property="Height" Value="20" />
<Setter Property="Margin" Value="4,0" />
<Setter Property="Fill" Value="{DynamicResource DividerBrush}" />
</Style>
<!-- Dikte: stip die meegroeit met de lijndikte --> <!-- Dikte: stip die meegroeit met de lijndikte -->
<DataTemplate x:Key="ThicknessTemplate"> <DataTemplate x:Key="ThicknessTemplate">
<StackPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal">
<Grid Width="16" Height="16" VerticalAlignment="Center"> <Grid Width="16" Height="16" VerticalAlignment="Center">
<Ellipse x:Name="Dot" Fill="{StaticResource InkBrush}" Width="{Binding}" Height="{Binding}" <Ellipse x:Name="Dot" Fill="{DynamicResource InkBrush}" Width="{Binding}" Height="{Binding}"
MinWidth="2" MinHeight="2" MaxWidth="15" MaxHeight="15" MinWidth="2" MinHeight="2" MaxWidth="15" MaxHeight="15"
HorizontalAlignment="Center" VerticalAlignment="Center" /> HorizontalAlignment="Center" VerticalAlignment="Center" />
<Line x:Name="NoneLine" X1="3" Y1="13" X2="13" Y2="3" Stroke="#E81123" StrokeThickness="1.5" Visibility="Collapsed" /> <Line x:Name="NoneLine" X1="3" Y1="13" X2="13" Y2="3" Stroke="#E81123" StrokeThickness="1.5" Visibility="Collapsed" />
@@ -36,7 +56,7 @@
<!-- Lijnstijl: voorbeeldlijn --> <!-- Lijnstijl: voorbeeldlijn -->
<DataTemplate x:Key="DashTemplate"> <DataTemplate x:Key="DashTemplate">
<StackPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal">
<Line x:Name="Sample" X1="1" Y1="8" X2="35" Y2="8" Width="36" Height="16" Stroke="{StaticResource InkBrush}" <Line x:Name="Sample" X1="1" Y1="8" X2="35" Y2="8" Width="36" Height="16" Stroke="{DynamicResource InkBrush}"
StrokeThickness="2" VerticalAlignment="Center" /> StrokeThickness="2" VerticalAlignment="Center" />
<TextBlock x:Name="Label" Text="Doorgetrokken" Margin="8,0,0,0" VerticalAlignment="Center" FontSize="12" /> <TextBlock x:Name="Label" Text="Doorgetrokken" Margin="8,0,0,0" VerticalAlignment="Center" FontSize="12" />
</StackPanel> </StackPanel>
@@ -64,7 +84,7 @@
<StackPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal">
<Grid Width="18" Height="18" VerticalAlignment="Center" ClipToBounds="True"> <Grid Width="18" Height="18" VerticalAlignment="Center" ClipToBounds="True">
<Border Width="26" Height="26" HorizontalAlignment="Left" VerticalAlignment="Top" Margin="2,2,0,0" <Border Width="26" Height="26" HorizontalAlignment="Left" VerticalAlignment="Top" Margin="2,2,0,0"
BorderBrush="{StaticResource InkBrush}" BorderThickness="2,2,0,0" BorderBrush="{DynamicResource InkBrush}" BorderThickness="2,2,0,0"
CornerRadius="{Binding Converter={StaticResource CornerPreview}}" /> CornerRadius="{Binding Converter={StaticResource CornerPreview}}" />
</Grid> </Grid>
<TextBlock x:Name="Label" Text="{Binding StringFormat={}{0} px}" Margin="8,0,0,0" VerticalAlignment="Center" FontSize="12" /> <TextBlock x:Name="Label" Text="{Binding StringFormat={}{0} px}" Margin="8,0,0,0" VerticalAlignment="Center" FontSize="12" />
@@ -83,7 +103,7 @@
<DataTemplate x:Key="FontSizeTemplate"> <DataTemplate x:Key="FontSizeTemplate">
<StackPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal">
<TextBlock Text="A" FontSize="15" VerticalAlignment="Center" /> <TextBlock Text="A" FontSize="15" VerticalAlignment="Center" />
<TextBlock Text="{Binding}" FontSize="11" Margin="3,4,0,0" VerticalAlignment="Center" Foreground="{StaticResource SubtleTextBrush}" /> <TextBlock Text="{Binding}" FontSize="11" Margin="3,4,0,0" VerticalAlignment="Center" Foreground="{DynamicResource SubtleTextBrush}" />
</StackPanel> </StackPanel>
</DataTemplate> </DataTemplate>
@@ -91,10 +111,10 @@
<DataTemplate x:Key="SizeTemplate"> <DataTemplate x:Key="SizeTemplate">
<StackPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal">
<Grid Width="16" Height="16" VerticalAlignment="Center"> <Grid Width="16" Height="16" VerticalAlignment="Center">
<Ellipse Stroke="{StaticResource InkBrush}" StrokeThickness="1" /> <Ellipse Stroke="{DynamicResource InkBrush}" StrokeThickness="1" />
<Path Data="M5,8 H11 M8,5 V11" Stroke="{StaticResource InkBrush}" StrokeThickness="1" /> <Path Data="M5,8 H11 M8,5 V11" Stroke="{DynamicResource InkBrush}" StrokeThickness="1" />
</Grid> </Grid>
<TextBlock Text="{Binding}" FontSize="11" Margin="4,0,0,0" VerticalAlignment="Center" Foreground="{StaticResource SubtleTextBrush}" /> <TextBlock Text="{Binding}" FontSize="11" Margin="4,0,0,0" VerticalAlignment="Center" Foreground="{DynamicResource SubtleTextBrush}" />
</StackPanel> </StackPanel>
</DataTemplate> </DataTemplate>
@@ -130,7 +150,7 @@
<!-- Soort cursor: het cursoricoon zelf --> <!-- Soort cursor: het cursoricoon zelf -->
<DataTemplate x:Key="CursorKindTemplate"> <DataTemplate x:Key="CursorKindTemplate">
<StackPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal">
<Path Data="{Binding Converter={StaticResource CursorIcon}}" Fill="White" Stroke="{StaticResource InkBrush}" <Path Data="{Binding Converter={StaticResource CursorIcon}}" Fill="{DynamicResource ControlBrush}" Stroke="{DynamicResource InkBrush}"
StrokeThickness="1" StrokeLineJoin="Round" Stretch="Uniform" Width="16" Height="18" VerticalAlignment="Center" /> StrokeThickness="1" StrokeLineJoin="Round" Stretch="Uniform" Width="16" Height="18" VerticalAlignment="Center" />
<TextBlock x:Name="Label" Text="Pijl" Margin="8,0,0,0" VerticalAlignment="Center" FontSize="12" /> <TextBlock x:Name="Label" Text="Pijl" Margin="8,0,0,0" VerticalAlignment="Center" FontSize="12" />
</StackPanel> </StackPanel>
@@ -147,20 +167,107 @@
</DataTemplate.Triggers> </DataTemplate.Triggers>
</DataTemplate> </DataTemplate>
<!-- Vorm van een tekstobject: kader, ballon, ovaal of wolk -->
<DataTemplate x:Key="TextShapeTemplate">
<StackPanel Orientation="Horizontal">
<Path x:Name="Icon" Data="M2.5,3.5 H17.5 V14.5 H2.5 Z" Stroke="{DynamicResource InkBrush}" StrokeThickness="1.3" StrokeLineJoin="Round"
Width="20" Height="18" VerticalAlignment="Center" />
<TextBlock x:Name="Label" Text="Kader" Margin="8,0,0,0" VerticalAlignment="Center" FontSize="12" />
</StackPanel>
<DataTemplate.Triggers>
<DataTrigger Binding="{Binding}" Value="{x:Static local:TextShape.Balloon}">
<Setter TargetName="Icon" Property="Data" Value="M4,2.5 H16 Q17.5,2.5 17.5,4 V11 Q17.5,12.5 16,12.5 H9 L4.5,16.5 L6,12.5 H4 Q2.5,12.5 2.5,11 V4 Q2.5,2.5 4,2.5 Z" />
<Setter TargetName="Label" Property="Text" Value="Ballon" />
</DataTrigger>
<DataTrigger Binding="{Binding}" Value="{x:Static local:TextShape.Oval}">
<Setter TargetName="Icon" Property="Data" Value="M10,2 C14.7,2 18,4.5 18,7.5 C18,10.5 14.7,13 10,13 C9,13 8.1,12.9 7.3,12.7 L3.8,16.5 L4.8,12 C3,11 2,9.4 2,7.5 C2,4.5 5.3,2 10,2 Z" />
<Setter TargetName="Label" Property="Text" Value="Ovale ballon" />
</DataTrigger>
<DataTrigger Binding="{Binding}" Value="{x:Static local:TextShape.Cloud}">
<Setter TargetName="Icon" Property="Data" Value="M6.5,12 A2.8,2.8 0 0 1 4.2,6.8 A3.3,3.3 0 0 1 9.2,3.6 A3.3,3.3 0 0 1 15,4.8 A2.8,2.8 0 0 1 16.2,10.5 A2.8,2.8 0 0 1 12,12.2 A3,3 0 0 1 6.5,12 Z M3.8,14.6 a1.2,1.2 0 1 0 2.4,0 a1.2,1.2 0 1 0 -2.4,0 Z M1.8,17 a0.7,0.7 0 1 0 1.4,0 a0.7,0.7 0 1 0 -1.4,0 Z" />
<Setter TargetName="Label" Property="Text" Value="Wolk" />
</DataTrigger>
<DataTrigger Binding="{Binding RelativeSource={RelativeSource AncestorType=ComboBoxItem}}" Value="{x:Null}">
<Setter TargetName="Label" Property="Visibility" Value="Collapsed" />
</DataTrigger>
</DataTemplate.Triggers>
</DataTemplate>
<!-- Vorm van het vormengereedschap -->
<DataTemplate x:Key="ShapeKindTemplate">
<StackPanel Orientation="Horizontal">
<Path Data="{Binding Converter={StaticResource ShapeIcon}}" Stroke="{DynamicResource InkBrush}" StrokeThickness="1.2"
StrokeLineJoin="Round" Stretch="Uniform" Width="16" Height="16" Margin="2,1" VerticalAlignment="Center" />
<TextBlock x:Name="Label" Text="Driehoek" Margin="8,0,0,0" VerticalAlignment="Center" FontSize="12" />
</StackPanel>
<DataTemplate.Triggers>
<DataTrigger Binding="{Binding}" Value="{x:Static local:ShapeKind.BlockArrow}">
<Setter TargetName="Label" Property="Text" Value="Pijl" />
</DataTrigger>
<DataTrigger Binding="{Binding}" Value="{x:Static local:ShapeKind.Star}">
<Setter TargetName="Label" Property="Text" Value="Ster" />
</DataTrigger>
<DataTrigger Binding="{Binding}" Value="{x:Static local:ShapeKind.Cloud}">
<Setter TargetName="Label" Property="Text" Value="Wolk" />
</DataTrigger>
<DataTrigger Binding="{Binding}" Value="{x:Static local:ShapeKind.Balloon}">
<Setter TargetName="Label" Property="Text" Value="Ballon" />
</DataTrigger>
<DataTrigger Binding="{Binding}" Value="{x:Static local:ShapeKind.Heart}">
<Setter TargetName="Label" Property="Text" Value="Hart" />
</DataTrigger>
<DataTrigger Binding="{Binding}" Value="{x:Static local:ShapeKind.Moon}">
<Setter TargetName="Label" Property="Text" Value="Maan" />
</DataTrigger>
<DataTrigger Binding="{Binding}" Value="{x:Static local:ShapeKind.Drop}">
<Setter TargetName="Label" Property="Text" Value="Druppel" />
</DataTrigger>
<DataTrigger Binding="{Binding}" Value="{x:Static local:ShapeKind.Egg}">
<Setter TargetName="Label" Property="Text" Value="Ei" />
</DataTrigger>
<DataTrigger Binding="{Binding}" Value="{x:Static local:ShapeKind.Lightning}">
<Setter TargetName="Label" Property="Text" Value="Bliksemschicht" />
</DataTrigger>
<DataTrigger Binding="{Binding}" Value="{x:Static local:ShapeKind.Spiral}">
<Setter TargetName="Label" Property="Text" Value="Spiraal" />
</DataTrigger>
<DataTrigger Binding="{Binding RelativeSource={RelativeSource AncestorType=ComboBoxItem}}" Value="{x:Null}">
<Setter TargetName="Label" Property="Visibility" Value="Collapsed" />
</DataTrigger>
</DataTemplate.Triggers>
</DataTemplate>
<!-- Aantal rondjes in de staart van een wolk; 0 = automatisch -->
<DataTemplate x:Key="CloudBubblesTemplate">
<StackPanel Orientation="Horizontal">
<Path Data="M13.5,4.5 a3,3 0 1 0 6,0 a3,3 0 1 0 -6,0 Z M7.5,10 a2,2 0 1 0 4,0 a2,2 0 1 0 -4,0 Z M2.5,15 a1.2,1.2 0 1 0 2.4,0 a1.2,1.2 0 1 0 -2.4,0 Z"
Stroke="{DynamicResource InkBrush}" StrokeThickness="1.2" Width="20" Height="18" VerticalAlignment="Center" />
<TextBlock x:Name="Label" Text="{Binding}" FontSize="11" Margin="4,0,0,0" VerticalAlignment="Center" Foreground="{DynamicResource SubtleTextBrush}" />
</StackPanel>
<DataTemplate.Triggers>
<DataTrigger Binding="{Binding}">
<DataTrigger.Value>
<sys:Int32>0</sys:Int32>
</DataTrigger.Value>
<Setter TargetName="Label" Property="Text" Value="Auto" />
</DataTrigger>
</DataTemplate.Triggers>
</DataTemplate>
<!-- Effect: pixelblokjes of zachte vlek --> <!-- Effect: pixelblokjes of zachte vlek -->
<DataTemplate x:Key="BlurModeTemplate"> <DataTemplate x:Key="BlurModeTemplate">
<StackPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal">
<Grid Width="16" Height="16" VerticalAlignment="Center"> <Grid Width="16" Height="16" VerticalAlignment="Center">
<Path x:Name="PixelIcon" Fill="{StaticResource InkBrush}" <Path x:Name="PixelIcon" Fill="{DynamicResource InkBrush}"
Data="M1,1 h4.5 v4.5 h-4.5 z M10.5,1 h4.5 v4.5 h-4.5 z M5.75,5.75 h4.5 v4.5 h-4.5 z M1,10.5 h4.5 v4.5 h-4.5 z M10.5,10.5 h4.5 v4.5 h-4.5 z" /> Data="M1,1 h4.5 v4.5 h-4.5 z M10.5,1 h4.5 v4.5 h-4.5 z M5.75,5.75 h4.5 v4.5 h-4.5 z M1,10.5 h4.5 v4.5 h-4.5 z M10.5,10.5 h4.5 v4.5 h-4.5 z" />
<Ellipse x:Name="BlurIcon" Visibility="Collapsed"> <Ellipse x:Name="BlurIcon" Visibility="Collapsed" Fill="{DynamicResource InkBrush}">
<Ellipse.Fill> <Ellipse.OpacityMask>
<RadialGradientBrush> <RadialGradientBrush>
<GradientStop Color="#FF1A1A1A" Offset="0" /> <GradientStop Color="#FF000000" Offset="0" />
<GradientStop Color="#801A1A1A" Offset="0.5" /> <GradientStop Color="#80000000" Offset="0.5" />
<GradientStop Color="#001A1A1A" Offset="1" /> <GradientStop Color="#00000000" Offset="1" />
</RadialGradientBrush> </RadialGradientBrush>
</Ellipse.Fill> </Ellipse.OpacityMask>
</Ellipse> </Ellipse>
</Grid> </Grid>
<TextBlock x:Name="Label" Text="Pixeleren" Margin="8,0,0,0" VerticalAlignment="Center" FontSize="12" /> <TextBlock x:Name="Label" Text="Pixeleren" Margin="8,0,0,0" VerticalAlignment="Center" FontSize="12" />
@@ -176,19 +283,72 @@
</DataTrigger> </DataTrigger>
</DataTemplate.Triggers> </DataTemplate.Triggers>
</DataTemplate> </DataTemplate>
<DrawingBrush x:Key="CheckerIcon" TileMode="Tile" Viewport="0,0,8,8" ViewportUnits="Absolute">
<DrawingBrush.Drawing>
<DrawingGroup>
<GeometryDrawing Brush="White" Geometry="M0,0 H2 V2 H0 Z" />
<GeometryDrawing Brush="#E4E4E4" Geometry="M0,0 H1 V1 H0 Z M1,1 H2 V2 H1 Z" />
</DrawingGroup>
</DrawingBrush.Drawing>
</DrawingBrush>
<!-- Achtergrond van het canvas: geen, kleur, verloop of afbeelding -->
<DataTemplate x:Key="BackgroundKindTemplate">
<StackPanel Orientation="Horizontal">
<Grid Width="16" Height="16" VerticalAlignment="Center">
<Border x:Name="Swatch" CornerRadius="3" BorderBrush="{DynamicResource ControlStrongBorderBrush}" BorderThickness="1"
Background="{StaticResource CheckerIcon}" />
<TextBlock x:Name="ImageIcon" Text="&#xE91B;" Style="{StaticResource IconText}" FontSize="15" Visibility="Collapsed" />
</Grid>
<TextBlock x:Name="Label" Text="Geen (transparant)" Margin="8,0,0,0" VerticalAlignment="Center" FontSize="12" />
</StackPanel>
<DataTemplate.Triggers>
<DataTrigger Binding="{Binding}" Value="{x:Static local:BackgroundKind.Color}">
<Setter TargetName="Swatch" Property="Background" Value="{DynamicResource AccentBrush}" />
<Setter TargetName="Label" Property="Text" Value="Kleur" />
</DataTrigger>
<DataTrigger Binding="{Binding}" Value="{x:Static local:BackgroundKind.Gradient}">
<Setter TargetName="Swatch" Property="Background">
<Setter.Value>
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="White" Offset="0" />
<GradientStop Color="#0078D4" Offset="1" />
</LinearGradientBrush>
</Setter.Value>
</Setter>
<Setter TargetName="Label" Property="Text" Value="Verloop" />
</DataTrigger>
<DataTrigger Binding="{Binding}" Value="{x:Static local:BackgroundKind.Image}">
<Setter TargetName="Swatch" Property="Visibility" Value="Collapsed" />
<Setter TargetName="ImageIcon" Property="Visibility" Value="Visible" />
<Setter TargetName="Label" Property="Text" Value="Afbeelding" />
</DataTrigger>
</DataTemplate.Triggers>
</DataTemplate>
</Window.Resources> </Window.Resources>
<DockPanel> <DockPanel>
<!-- Statusbalk --> <!-- Statusbalk -->
<Border DockPanel.Dock="Bottom" Background="{StaticResource ToolbarBrush}" BorderBrush="{StaticResource DividerBrush}" <Border DockPanel.Dock="Bottom" Background="{DynamicResource ToolbarBrush}" BorderBrush="{DynamicResource DividerBrush}"
BorderThickness="0,1,0,0" Padding="12,4"> BorderThickness="0,1,0,0" Padding="12,4">
<DockPanel> <DockPanel>
<TextBlock x:Name="InfoText" DockPanel.Dock="Right" Foreground="{StaticResource SubtleTextBrush}" FontSize="12" /> <TextBlock x:Name="InfoText" DockPanel.Dock="Right" Foreground="{DynamicResource SubtleTextBrush}" FontSize="12" />
<TextBlock x:Name="HintText" Foreground="{StaticResource SubtleTextBrush}" FontSize="12" TextTrimming="CharacterEllipsis" /> <TextBlock x:Name="HintText" Foreground="{DynamicResource SubtleTextBrush}" FontSize="12" TextTrimming="CharacterEllipsis" />
</DockPanel> </DockPanel>
</Border> </Border>
<!-- Onderste werkbalk in de gesplitste indeling; de code verplaatst de acties hierheen -->
<Border x:Name="ActionBar" DockPanel.Dock="Bottom" Background="{DynamicResource ToolbarBrush}" BorderBrush="{DynamicResource DividerBrush}"
BorderThickness="0,1,0,0" Padding="8,6" Visibility="Collapsed"
SizeChanged="Toolbar_SizeChanged" ContextMenuOpening="Toolbar_ContextMenuOpening">
<Border.ContextMenu>
<ContextMenu x:Name="ActionBarMenu" />
</Border.ContextMenu>
<local:ToolbarPanel x:Name="ActionItems" OrderChanged="ToolbarItems_OrderChanged" />
</Border>
<!-- Gereedschappen en acties; rechtsklik voor vergrendelen, positie en zichtbare knoppen --> <!-- Gereedschappen en acties; rechtsklik voor vergrendelen, positie en zichtbare knoppen -->
<Border x:Name="Toolbar" DockPanel.Dock="Top" Background="{StaticResource ToolbarBrush}" BorderBrush="{StaticResource DividerBrush}" <Border x:Name="Toolbar" DockPanel.Dock="Top" Background="{DynamicResource ToolbarBrush}" BorderBrush="{DynamicResource DividerBrush}"
BorderThickness="0,0,0,1" Padding="8,6" SizeChanged="Toolbar_SizeChanged" ContextMenuOpening="Toolbar_ContextMenuOpening"> BorderThickness="0,0,0,1" Padding="8,6" SizeChanged="Toolbar_SizeChanged" ContextMenuOpening="Toolbar_ContextMenuOpening">
<Border.ContextMenu> <Border.ContextMenu>
<ContextMenu x:Name="ToolbarMenu" /> <ContextMenu x:Name="ToolbarMenu" />
@@ -211,6 +371,10 @@
</Path.Data> </Path.Data>
</Path> </Path>
</RadioButton> </RadioButton>
<RadioButton GroupName="Tools" Tag="Shape" local:ToolbarPanel.ItemId="Shape" local:ToolbarPanel.Title="Vormen"
Style="{StaticResource ToolToggle}" ToolTip="Vormen (F) - Shift voor gelijke breedte en hoogte" Checked="Tool_Checked">
<Path x:Name="ShapeToolIcon" Style="{StaticResource IconPath}" Stretch="Uniform" Width="15" Height="15" Margin="2.5" />
</RadioButton>
<RadioButton GroupName="Tools" Tag="Line" local:ToolbarPanel.ItemId="Line" local:ToolbarPanel.Title="Lijn" <RadioButton GroupName="Tools" Tag="Line" local:ToolbarPanel.ItemId="Line" local:ToolbarPanel.Title="Lijn"
Style="{StaticResource ToolToggle}" ToolTip="Lijn (L) - Shift voor hoeken van 45°" Checked="Tool_Checked"> Style="{StaticResource ToolToggle}" ToolTip="Lijn (L) - Shift voor hoeken van 45°" Checked="Tool_Checked">
<Path Style="{StaticResource IconPath}" Data="M4,16 L16,4" /> <Path Style="{StaticResource IconPath}" Data="M4,16 L16,4" />
@@ -235,7 +399,7 @@
</RadioButton> </RadioButton>
<RadioButton GroupName="Tools" Tag="Cursor" local:ToolbarPanel.ItemId="Cursor" local:ToolbarPanel.Title="Cursor" <RadioButton GroupName="Tools" Tag="Cursor" local:ToolbarPanel.ItemId="Cursor" local:ToolbarPanel.Title="Cursor"
Style="{StaticResource ToolToggle}" ToolTip="Cursor plaatsen (C)" Checked="Tool_Checked"> Style="{StaticResource ToolToggle}" ToolTip="Cursor plaatsen (C)" Checked="Tool_Checked">
<Path Style="{StaticResource IconPath}" Fill="White" <Path Style="{StaticResource IconPath}" Fill="{DynamicResource ControlBrush}"
Data="M7,4 L7,16 L9.8,13.4 L11.7,17.6 L13.6,16.8 L11.8,12.6 L15.4,12.6 Z M4,4 L2.5,2.5 M7,1 L7,0 M4,7 L2,7" /> Data="M7,4 L7,16 L9.8,13.4 L11.7,17.6 L13.6,16.8 L11.8,12.6 L15.4,12.6 Z M4,4 L2.5,2.5 M7,1 L7,0 M4,7 L2,7" />
</RadioButton> </RadioButton>
<RadioButton GroupName="Tools" Tag="Emoji" local:ToolbarPanel.ItemId="Emoji" local:ToolbarPanel.Title="Emoji" <RadioButton GroupName="Tools" Tag="Emoji" local:ToolbarPanel.ItemId="Emoji" local:ToolbarPanel.Title="Emoji"
@@ -251,7 +415,7 @@
</RadioButton> </RadioButton>
<RadioButton GroupName="Tools" Tag="Blur" local:ToolbarPanel.ItemId="Blur" local:ToolbarPanel.Title="Vervagen / pixeleren" <RadioButton GroupName="Tools" Tag="Blur" local:ToolbarPanel.ItemId="Blur" local:ToolbarPanel.Title="Vervagen / pixeleren"
Style="{StaticResource ToolToggle}" ToolTip="Vervagen / pixeleren (B)" Checked="Tool_Checked"> Style="{StaticResource ToolToggle}" ToolTip="Vervagen / pixeleren (B)" Checked="Tool_Checked">
<Path Style="{StaticResource IconPath}" Fill="#1A1A1A" StrokeThickness="0" <Path Style="{StaticResource IconPath}" Fill="{DynamicResource InkBrush}" StrokeThickness="0"
Data="M3,3 h4.5 v4.5 h-4.5 z M12.5,3 h4.5 v4.5 h-4.5 z M7.75,7.75 h4.5 v4.5 h-4.5 z M3,12.5 h4.5 v4.5 h-4.5 z M12.5,12.5 h4.5 v4.5 h-4.5 z" /> Data="M3,3 h4.5 v4.5 h-4.5 z M12.5,3 h4.5 v4.5 h-4.5 z M7.75,7.75 h4.5 v4.5 h-4.5 z M3,12.5 h4.5 v4.5 h-4.5 z M12.5,12.5 h4.5 v4.5 h-4.5 z" />
</RadioButton> </RadioButton>
<local:ToolbarSeparator local:ToolbarPanel.ItemId="Sep2" /> <local:ToolbarSeparator local:ToolbarPanel.ItemId="Sep2" />
@@ -276,6 +440,15 @@
<TextBlock x:Name="ScreencastLabel" Text="Screencast" FontSize="12" VerticalAlignment="Center" Margin="6,0,0,0" /> <TextBlock x:Name="ScreencastLabel" Text="Screencast" FontSize="12" VerticalAlignment="Center" Margin="6,0,0,0" />
</StackPanel> </StackPanel>
</Button> </Button>
<Button x:Name="OcrButton" local:ToolbarPanel.ItemId="OcrSnip" local:ToolbarPanel.Title="OCR"
Style="{StaticResource ProminentButton}" Click="Ocr_Click"
ToolTip="OCR: sleep een rechthoek op het scherm en kopieer de tekst daarin. Ctrl+Shift+T leest de tekst van dit knipsel.">
<StackPanel Orientation="Horizontal">
<Path Style="{StaticResource IconPath}" StrokeThickness="1.2"
Data="M3,7 V5.5 A2.5,2.5 0 0 1 5.5,3 H7 M13,3 H14.5 A2.5,2.5 0 0 1 17,5.5 V7 M17,13 V14.5 A2.5,2.5 0 0 1 14.5,17 H13 M7,17 H5.5 A2.5,2.5 0 0 1 3,14.5 V13 M7,7.5 H13 M7,10 H13 M7,12.5 H10.5" />
<TextBlock x:Name="OcrLabel" Text="OCR" FontSize="12" VerticalAlignment="Center" Margin="6,0,0,0" />
</StackPanel>
</Button>
<!-- Wat tussen de twee spacers staat, komt in het midden van de werkbalk --> <!-- Wat tussen de twee spacers staat, komt in het midden van de werkbalk -->
<local:ToolbarSpacer local:ToolbarPanel.ItemId="Spacer2" /> <local:ToolbarSpacer local:ToolbarPanel.ItemId="Spacer2" />
@@ -301,6 +474,12 @@
<TextBlock Text="&#xE74D;" Style="{StaticResource IconText}" /> <TextBlock Text="&#xE74D;" Style="{StaticResource IconText}" />
</Button> </Button>
<local:ToolbarSeparator local:ToolbarPanel.ItemId="Sep4" /> <local:ToolbarSeparator local:ToolbarPanel.ItemId="Sep4" />
<ToggleButton x:Name="RulersToggle" local:ToolbarPanel.ItemId="Rulers" local:ToolbarPanel.Title="Linialen"
Style="{StaticResource ToolToggle}" ToolTip="Linialen en hulplijnen (Ctrl+R) - sleep vanuit een liniaal voor een hulplijn"
Click="Rulers_Click">
<Path Style="{StaticResource IconPath}"
Data="M2.5,6.5 H17.5 V13.5 H2.5 Z M5,6.5 V9 M7.5,6.5 V10.5 M10,6.5 V9 M12.5,6.5 V10.5 M15,6.5 V9" />
</ToggleButton>
<Button local:ToolbarPanel.ItemId="ZoomOut" local:ToolbarPanel.Title="Uitzoomen" <Button local:ToolbarPanel.ItemId="ZoomOut" local:ToolbarPanel.Title="Uitzoomen"
Style="{StaticResource FlatButton}" ToolTip="Uitzoomen (Ctrl+-)" Click="ZoomOut_Click"> Style="{StaticResource FlatButton}" ToolTip="Uitzoomen (Ctrl+-)" Click="ZoomOut_Click">
<TextBlock Text="&#xE71F;" Style="{StaticResource IconText}" /> <TextBlock Text="&#xE71F;" Style="{StaticResource IconText}" />
@@ -314,9 +493,14 @@
<TextBlock Text="&#xE8A3;" Style="{StaticResource IconText}" /> <TextBlock Text="&#xE8A3;" Style="{StaticResource IconText}" />
</Button> </Button>
<local:ToolbarSeparator local:ToolbarPanel.ItemId="Sep5" /> <local:ToolbarSeparator local:ToolbarPanel.ItemId="Sep5" />
<Button local:ToolbarPanel.ItemId="Settings" local:ToolbarPanel.Title="Instellingen" <Button x:Name="SettingsButton" local:ToolbarPanel.ItemId="Settings" local:ToolbarPanel.Title="Instellingen"
Style="{StaticResource FlatButton}" ToolTip="Instellingen" Click="Settings_Click"> Style="{StaticResource FlatButton}" ToolTip="Instellingen" Click="Settings_Click">
<TextBlock Text="&#xE713;" Style="{StaticResource IconText}" /> <Grid>
<TextBlock Text="&#xE713;" Style="{StaticResource IconText}" />
<Ellipse x:Name="SettingsBadge" Width="8" Height="8" Fill="{DynamicResource AccentBrush}" Stroke="{DynamicResource ToolbarBrush}"
StrokeThickness="1" HorizontalAlignment="Right" VerticalAlignment="Top" Margin="0,-2,-3,0"
IsHitTestVisible="False" Visibility="Collapsed" />
</Grid>
</Button> </Button>
<Button x:Name="SaveButton" local:ToolbarPanel.ItemId="Save" local:ToolbarPanel.Title="Opslaan" <Button x:Name="SaveButton" local:ToolbarPanel.ItemId="Save" local:ToolbarPanel.Title="Opslaan"
Style="{StaticResource SecondaryButton}" ToolTip="Opslaan (Ctrl+S); Shift+klik of Ctrl+Shift+S = opslaan als" Click="Save_Click"> Style="{StaticResource SecondaryButton}" ToolTip="Opslaan (Ctrl+S); Shift+klik of Ctrl+Shift+S = opslaan als" Click="Save_Click">
@@ -339,13 +523,27 @@
<TextBlock x:Name="CopyLabel" Text="Kopiëren" Margin="6,0,0,0" /> <TextBlock x:Name="CopyLabel" Text="Kopiëren" Margin="6,0,0,0" />
</StackPanel> </StackPanel>
</Button> </Button>
<!-- Alleen in de gesplitste indeling: gereedschappen in het midden, Instellingen rechts -->
<local:ToolbarSpacer local:ToolbarPanel.ItemId="SplitSpacer1" />
<local:ToolbarSpacer local:ToolbarPanel.ItemId="SplitSpacer2" />
</local:ToolbarPanel> </local:ToolbarPanel>
</Border> </Border>
<!-- Eigenschappen van het actieve gereedschap of het geselecteerde object --> <!-- Eigenschappen van het actieve gereedschap of het geselecteerde object -->
<Border DockPanel.Dock="Top" Background="White" BorderBrush="{StaticResource DividerBrush}" BorderThickness="0,0,0,1" <Border x:Name="PropsBar" DockPanel.Dock="Top" Background="{DynamicResource CardBrush}" BorderBrush="{DynamicResource DividerBrush}" BorderThickness="0,0,0,1"
Padding="12,4" MinHeight="42"> Padding="12,4" MinHeight="42">
<WrapPanel x:Name="PropsPanel" VerticalAlignment="Center" HorizontalAlignment="Center"> <WrapPanel x:Name="PropsPanel" VerticalAlignment="Center" HorizontalAlignment="Center">
<StackPanel x:Name="ShapeGroup" Style="{StaticResource PropGroup}">
<ComboBox x:Name="ShapeKindCombo" Style="{StaticResource MiniCombo}" ItemTemplate="{StaticResource ShapeKindTemplate}"
ToolTip="Vorm" SelectionChanged="ShapeKind_Changed" />
</StackPanel>
<StackPanel x:Name="TextShapeGroup" Style="{StaticResource PropGroup}">
<ComboBox x:Name="TextShapeCombo" Style="{StaticResource MiniCombo}" ItemTemplate="{StaticResource TextShapeTemplate}"
ToolTip="Vorm" SelectionChanged="TextShape_Changed" />
<ComboBox x:Name="CloudBubblesCombo" Style="{StaticResource MiniCombo}" ItemTemplate="{StaticResource CloudBubblesTemplate}"
ToolTip="Aantal bolletjes naar de punt" SelectionChanged="CloudBubbles_Changed" />
</StackPanel>
<StackPanel x:Name="StrokeGroup" Style="{StaticResource PropGroup}"> <StackPanel x:Name="StrokeGroup" Style="{StaticResource PropGroup}">
<local:ColorPickerButton x:Name="StrokePicker" ShowAsOutline="True" ToolTip="Randkleur" /> <local:ColorPickerButton x:Name="StrokePicker" ShowAsOutline="True" ToolTip="Randkleur" />
</StackPanel> </StackPanel>
@@ -392,7 +590,7 @@
<StackPanel x:Name="BlurGroup" Style="{StaticResource PropGroup}"> <StackPanel x:Name="BlurGroup" Style="{StaticResource PropGroup}">
<ComboBox x:Name="BlurModeCombo" Style="{StaticResource MiniCombo}" ItemTemplate="{StaticResource BlurModeTemplate}" <ComboBox x:Name="BlurModeCombo" Style="{StaticResource MiniCombo}" ItemTemplate="{StaticResource BlurModeTemplate}"
ToolTip="Effect" SelectionChanged="BlurMode_Changed" /> ToolTip="Effect" SelectionChanged="BlurMode_Changed" />
<Border Background="White" BorderBrush="#E0E0E0" BorderThickness="1" CornerRadius="4" Height="30" Margin="2,0" Padding="8,0" <Border Background="{DynamicResource ControlBrush}" BorderBrush="{DynamicResource ControlBorderBrush}" BorderThickness="1" CornerRadius="4" Height="30" Margin="2,0" Padding="8,0"
ToolTip="Sterkte"> ToolTip="Sterkte">
<Slider x:Name="BlurStrengthSlider" Width="90" Minimum="1" Maximum="20" IsSnapToTickEnabled="True" TickFrequency="1" <Slider x:Name="BlurStrengthSlider" Width="90" Minimum="1" Maximum="20" IsSnapToTickEnabled="True" TickFrequency="1"
VerticalAlignment="Center" ValueChanged="BlurStrength_Changed" /> VerticalAlignment="Center" ValueChanged="BlurStrength_Changed" />
@@ -401,7 +599,7 @@
<StackPanel x:Name="StraightGroup" Style="{StaticResource PropGroup}"> <StackPanel x:Name="StraightGroup" Style="{StaticResource PropGroup}">
<ToggleButton x:Name="StraightCheck" Style="{StaticResource MiniToggle}" ToolTip="Rechte horizontale streep (Shift wisselt)" <ToggleButton x:Name="StraightCheck" Style="{StaticResource MiniToggle}" ToolTip="Rechte horizontale streep (Shift wisselt)"
Checked="Straight_Changed" Unchecked="Straight_Changed"> Checked="Straight_Changed" Unchecked="Straight_Changed">
<Path Data="M2,9 H16 M2,5 V13 M16,5 V13" Stroke="{StaticResource InkBrush}" StrokeThickness="1.5" <Path Data="M2,9 H16 M2,5 V13 M16,5 V13" Stroke="{DynamicResource InkBrush}" StrokeThickness="1.5"
StrokeStartLineCap="Round" StrokeEndLineCap="Round" Width="18" Height="18" /> StrokeStartLineCap="Round" StrokeEndLineCap="Round" Width="18" Height="18" />
</ToggleButton> </ToggleButton>
</StackPanel> </StackPanel>
@@ -411,33 +609,199 @@
<Grid Width="18" Height="18"> <Grid Width="18" Height="18">
<Rectangle Width="11" Height="11" RadiusX="2" RadiusY="2" Fill="#9A9A9A" Margin="5,5,0,0" <Rectangle Width="11" Height="11" RadiusX="2" RadiusY="2" Fill="#9A9A9A" Margin="5,5,0,0"
HorizontalAlignment="Left" VerticalAlignment="Top" /> HorizontalAlignment="Left" VerticalAlignment="Top" />
<Rectangle Width="11" Height="11" RadiusX="2" RadiusY="2" Fill="White" Stroke="{StaticResource InkBrush}" <Rectangle Width="11" Height="11" RadiusX="2" RadiusY="2" Fill="{DynamicResource ControlBrush}" Stroke="{DynamicResource InkBrush}"
StrokeThickness="1" Margin="2,2,0,0" HorizontalAlignment="Left" VerticalAlignment="Top" /> StrokeThickness="1" Margin="2,2,0,0" HorizontalAlignment="Left" VerticalAlignment="Top" />
</Grid> </Grid>
</ToggleButton> </ToggleButton>
</StackPanel> </StackPanel>
<StackPanel x:Name="ArrangeGroup" Style="{StaticResource PropGroup}">
<Rectangle Style="{StaticResource ArrangeDivider}" />
<ToggleButton x:Name="ArrangeToggle" Style="{StaticResource MiniToggle}" ToolTip="Uitlijnen, spiegelen en volgorde"
Checked="ArrangeToggle_Changed" Unchecked="ArrangeToggle_Changed">
<StackPanel Orientation="Horizontal">
<Grid>
<Path Style="{StaticResource IconPath}" Data="M5.5,5 H15 V8.5 H5.5 Z M5.5,11.5 H11.5 V15 H5.5 Z" />
<Path Style="{StaticResource ArrangeAccent}" Data="M3,2.5 V17.5" />
</Grid>
<TextBlock x:Name="ArrangeChevron" Text="&#xE76C;" Style="{StaticResource IconText}" FontSize="10" Margin="4,0,0,0" />
</StackPanel>
</ToggleButton>
<StackPanel x:Name="ArrangeButtons" Orientation="Horizontal" Visibility="Collapsed" Margin="4,0,0,0">
<Button Style="{StaticResource ArrangeButton}" ToolTip="Links uitlijnen op de afbeelding" Click="AlignLeft_Click">
<Grid>
<Path Style="{StaticResource IconPath}" Data="M5.5,5 H15 V8.5 H5.5 Z M5.5,11.5 H11.5 V15 H5.5 Z" />
<Path Style="{StaticResource ArrangeAccent}" Data="M3,2.5 V17.5" />
</Grid>
</Button>
<Button Style="{StaticResource ArrangeButton}" ToolTip="Horizontaal centreren op de afbeelding" Click="AlignCenter_Click">
<Grid>
<Path Style="{StaticResource IconPath}" Data="M4.5,5 H15.5 V8.5 H4.5 Z M6.5,11.5 H13.5 V15 H6.5 Z" />
<Path Style="{StaticResource ArrangeAccent}" Data="M10,2.5 V17.5" />
</Grid>
</Button>
<Button Style="{StaticResource ArrangeButton}" ToolTip="Rechts uitlijnen op de afbeelding" Click="AlignRight_Click">
<Grid>
<Path Style="{StaticResource IconPath}" Data="M5,5 H14.5 V8.5 H5 Z M8.5,11.5 H14.5 V15 H8.5 Z" />
<Path Style="{StaticResource ArrangeAccent}" Data="M17,2.5 V17.5" />
</Grid>
</Button>
<Button Style="{StaticResource ArrangeButton}" ToolTip="Boven uitlijnen op de afbeelding" Click="AlignTop_Click">
<Grid>
<Path Style="{StaticResource IconPath}" Data="M5,5.5 H8.5 V15 H5 Z M11.5,5.5 H15 V11.5 H11.5 Z" />
<Path Style="{StaticResource ArrangeAccent}" Data="M2.5,3 H17.5" />
</Grid>
</Button>
<Button Style="{StaticResource ArrangeButton}" ToolTip="Verticaal centreren op de afbeelding" Click="AlignMiddle_Click">
<Grid>
<Path Style="{StaticResource IconPath}" Data="M5,4.5 H8.5 V15.5 H5 Z M11.5,6.5 H15 V13.5 H11.5 Z" />
<Path Style="{StaticResource ArrangeAccent}" Data="M2.5,10 H17.5" />
</Grid>
</Button>
<Button Style="{StaticResource ArrangeButton}" ToolTip="Onder uitlijnen op de afbeelding" Click="AlignBottom_Click">
<Grid>
<Path Style="{StaticResource IconPath}" Data="M5,5 H8.5 V14.5 H5 Z M11.5,8.5 H15 V14.5 H11.5 Z" />
<Path Style="{StaticResource ArrangeAccent}" Data="M2.5,17 H17.5" />
</Grid>
</Button>
<Rectangle Style="{StaticResource ArrangeDivider}" />
<Button x:Name="FlipHButton" Style="{StaticResource ArrangeButton}" ToolTip="Horizontaal spiegelen" Click="FlipH_Click">
<Grid>
<Path Style="{StaticResource IconPath}" Fill="{DynamicResource InkBrush}" Data="M8,5 L3,15 H8 Z" />
<Path Style="{StaticResource IconPath}" Data="M12,5 L17,15 H12 Z" />
<Path Style="{StaticResource ArrangeAccent}" Data="M10,2.5 V17.5" />
</Grid>
</Button>
<Button x:Name="FlipVButton" Style="{StaticResource ArrangeButton}" ToolTip="Verticaal spiegelen" Click="FlipV_Click">
<Grid>
<Path Style="{StaticResource IconPath}" Fill="{DynamicResource InkBrush}" Data="M5,8 L15,3 V8 Z" />
<Path Style="{StaticResource IconPath}" Data="M5,12 L15,17 V12 Z" />
<Path Style="{StaticResource ArrangeAccent}" Data="M2.5,10 H17.5" />
</Grid>
</Button>
<Button x:Name="RotateLeftButton" Style="{StaticResource ArrangeButton}" ToolTip="90° linksom draaien" Click="RotateLeft_Click">
<Grid>
<Path Style="{StaticResource IconPath}" Data="M8.5,8.5 H15.5 V15.5 H8.5 Z" />
<Path Style="{StaticResource ArrangeAccent}" Data="M3.5,11 A6.5,6.5 0 0 1 10,4.5 M3.5,11 L1.8,8.6 M3.5,11 L5.9,9.4" />
</Grid>
</Button>
<Button x:Name="RotateRightButton" Style="{StaticResource ArrangeButton}" ToolTip="90° rechtsom draaien (sleep de ronde greep boven het object om vrij te draaien)" Click="RotateRight_Click">
<Grid>
<Path Style="{StaticResource IconPath}" Data="M4.5,8.5 H11.5 V15.5 H4.5 Z" />
<Path Style="{StaticResource ArrangeAccent}" Data="M16.5,11 A6.5,6.5 0 0 0 10,4.5 M16.5,11 L18.2,8.6 M16.5,11 L14.1,9.4" />
</Grid>
</Button>
<Rectangle Style="{StaticResource ArrangeDivider}" />
<Button x:Name="ForwardButton" Style="{StaticResource ArrangeButton}" ToolTip="Naar voren (PageUp)" Click="BringForward_Click">
<TextBlock Text="&#xE74A;" Style="{StaticResource IconText}" />
</Button>
<Button x:Name="BackwardButton" Style="{StaticResource ArrangeButton}" ToolTip="Naar achteren (PageDown)" Click="SendBackward_Click">
<TextBlock Text="&#xE74B;" Style="{StaticResource IconText}" />
</Button>
</StackPanel>
</StackPanel>
<TextBlock x:Name="NoPropsText" Style="{StaticResource PropLabel}" VerticalAlignment="Center" /> <TextBlock x:Name="NoPropsText" Style="{StaticResource PropLabel}" VerticalAlignment="Center" />
<StackPanel x:Name="BackgroundGroup" Style="{StaticResource PropGroup}">
<Rectangle Style="{StaticResource ArrangeDivider}" />
<TextBlock Text="Achtergrond" Style="{StaticResource PropLabel}" Margin="4,0,4,0" />
<ComboBox x:Name="BackgroundKindCombo" Style="{StaticResource MiniCombo}" ItemTemplate="{StaticResource BackgroundKindTemplate}"
ToolTip="Achtergrond onder de transparante delen" SelectionChanged="BackgroundKind_Changed" />
<local:ColorPickerButton x:Name="BackgroundColorPicker" ToolTip="Kleur" />
<local:ColorPickerButton x:Name="BackgroundColor2Picker" ToolTip="Tweede kleur van het verloop" />
<local:GradientAngleDial x:Name="GradientDial" Margin="2,0" />
<Button x:Name="BackgroundImageButton" Style="{StaticResource FlatButton}" Padding="6,4" MinWidth="0" MinHeight="30" Margin="2,0"
BorderBrush="{DynamicResource ControlBorderBrush}" Background="{DynamicResource ControlBrush}"
ToolTip="Afbeelding kiezen; die vult het hele canvas" Click="BackgroundImage_Click">
<StackPanel Orientation="Horizontal">
<Grid Width="20" Height="20">
<TextBlock x:Name="BackgroundImageIcon" Text="&#xE91B;" Style="{StaticResource IconText}" />
<Image x:Name="BackgroundImageThumb" Stretch="UniformToFill" Visibility="Collapsed">
<Image.Clip>
<RectangleGeometry Rect="0,0,20,20" RadiusX="3" RadiusY="3" />
</Image.Clip>
</Image>
</Grid>
<TextBlock Text="Kiezen..." FontSize="12" VerticalAlignment="Center" Margin="6,0,0,0" />
</StackPanel>
</Button>
</StackPanel>
</WrapPanel> </WrapPanel>
</Border> </Border>
<!-- Canvas --> <!-- Extra balk in de drie-split-indeling, aan een kant van het canvas; de code verplaatst Nieuw en Screencast hierheen -->
<ScrollViewer x:Name="Scroller" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto" <Border x:Name="CaptureBar" DockPanel.Dock="Left" Background="{DynamicResource ToolbarBrush}" BorderBrush="{DynamicResource DividerBrush}"
Background="#E9E9E9" PreviewMouseWheel="Scroller_PreviewMouseWheel" Focusable="False"> BorderThickness="0,0,1,0" Padding="6,8" Visibility="Collapsed"
<Grid HorizontalAlignment="Center" VerticalAlignment="Center" Margin="24"> SizeChanged="Toolbar_SizeChanged" ContextMenuOpening="Toolbar_ContextMenuOpening">
<Border BorderBrush="#C8C8C8" BorderThickness="1" Margin="-1" SnapsToDevicePixels="True" /> <Border.ContextMenu>
<Grid x:Name="CanvasHost"> <ContextMenu x:Name="CaptureBarMenu" />
<Grid.LayoutTransform> </Border.ContextMenu>
<ScaleTransform x:Name="ZoomTransform" /> <local:ToolbarPanel x:Name="CaptureItems" Orientation="Vertical" OrderChanged="ToolbarItems_OrderChanged" />
</Grid.LayoutTransform> </Border>
<local:AnnotationSurface x:Name="Surface"
MouseLeftButtonDown="Surface_MouseLeftButtonDown" <!-- Canvas met linialen; sleep vanuit een liniaal om een hulplijn te maken -->
MouseMove="Surface_MouseMove" <Grid>
MouseLeftButtonUp="Surface_MouseLeftButtonUp" <Grid.RowDefinitions>
MouseRightButtonUp="Surface_MouseRightButtonUp" <RowDefinition Height="Auto" />
LostMouseCapture="Surface_LostMouseCapture" /> <RowDefinition />
<Canvas x:Name="TextLayer" /> </Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition />
</Grid.ColumnDefinitions>
<Border x:Name="RulerCorner" Width="20" Height="20" Background="{DynamicResource ToolbarBrush}"
BorderBrush="{DynamicResource DividerBrush}" BorderThickness="0,0,1,1" ToolTip="Rechtsklik om de hulplijnen te wissen"
MouseRightButtonUp="RulerCorner_MouseRightButtonUp" />
<local:RulerControl x:Name="HRuler" Grid.Column="1" Height="20" Orientation="Horizontal"
Background="{DynamicResource ToolbarBrush}" ImageBackground="{DynamicResource CardBrush}"
Foreground="{DynamicResource SubtleTextBrush}" BorderBrush="{DynamicResource DividerBrush}"
Cursor="SizeNS" MouseLeftButtonDown="Ruler_MouseLeftButtonDown" MouseMove="Ruler_MouseMove"
MouseLeftButtonUp="Ruler_MouseLeftButtonUp" LostMouseCapture="Ruler_LostMouseCapture" />
<local:RulerControl x:Name="VRuler" Grid.Row="1" Width="20" Orientation="Vertical"
Background="{DynamicResource ToolbarBrush}" ImageBackground="{DynamicResource CardBrush}"
Foreground="{DynamicResource SubtleTextBrush}" BorderBrush="{DynamicResource DividerBrush}"
Cursor="SizeWE" MouseLeftButtonDown="Ruler_MouseLeftButtonDown" MouseMove="Ruler_MouseMove"
MouseLeftButtonUp="Ruler_MouseLeftButtonUp" LostMouseCapture="Ruler_LostMouseCapture" />
<ScrollViewer x:Name="Scroller" Grid.Row="1" Grid.Column="1" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto"
Background="{DynamicResource CanvasBrush}" PreviewMouseWheel="Scroller_PreviewMouseWheel" Focusable="False">
<Grid HorizontalAlignment="Center" VerticalAlignment="Center" Margin="24">
<Border BorderBrush="{DynamicResource CanvasBorderBrush}" BorderThickness="1" Margin="-1" SnapsToDevicePixels="True" />
<Grid x:Name="CanvasHost" LayoutUpdated="CanvasHost_LayoutUpdated">
<Grid.LayoutTransform>
<ScaleTransform x:Name="ZoomTransform" />
</Grid.LayoutTransform>
<local:AnnotationSurface x:Name="Surface"
MouseLeftButtonDown="Surface_MouseLeftButtonDown"
MouseMove="Surface_MouseMove"
MouseLeave="Surface_MouseLeave"
MouseLeftButtonUp="Surface_MouseLeftButtonUp"
MouseRightButtonUp="Surface_MouseRightButtonUp"
LostMouseCapture="Surface_LostMouseCapture" />
<Canvas x:Name="TextLayer" />
</Grid>
</Grid> </Grid>
</Grid> </ScrollViewer>
</ScrollViewer>
<!-- Zwevende Nieuw en Screencast (drie-split); versleep aan de rand of het greepje -->
<Canvas x:Name="FloatLayer" Grid.Row="1" Grid.Column="1" ClipToBounds="True" SizeChanged="FloatLayer_SizeChanged">
<Border x:Name="CaptureFloat" Visibility="Collapsed" Background="{DynamicResource ToolbarBrush}"
BorderBrush="{DynamicResource ControlStrongBorderBrush}" BorderThickness="1" CornerRadius="8" Padding="4,2,4,4"
SizeChanged="FloatLayer_SizeChanged" ContextMenuOpening="Toolbar_ContextMenuOpening"
MouseLeftButtonDown="CaptureFloat_MouseLeftButtonDown" MouseMove="CaptureFloat_MouseMove"
MouseLeftButtonUp="CaptureFloat_MouseLeftButtonUp" LostMouseCapture="CaptureFloat_LostMouseCapture">
<Border.Effect>
<DropShadowEffect BlurRadius="12" ShadowDepth="2" Opacity="0.18" />
</Border.Effect>
<Border.ContextMenu>
<ContextMenu x:Name="CaptureFloatMenu" />
</Border.ContextMenu>
<StackPanel x:Name="CaptureFloatPanel">
<TextBlock x:Name="CaptureFloatGrip" Text="&#xE76F;" FontFamily="Segoe Fluent Icons, Segoe MDL2 Assets" FontSize="12"
HorizontalAlignment="Center" VerticalAlignment="Center"
Foreground="{DynamicResource SubtleTextBrush}" Background="Transparent" Padding="0,2" Cursor="SizeAll"
ToolTip="Slepen om te verplaatsen; rechtsklik om vast te zetten" />
<Border x:Name="CaptureFloatHost" />
</StackPanel>
</Border>
</Canvas>
</Grid>
</DockPanel> </DockPanel>
</Window> </Window>
File diff suppressed because it is too large. Load diff
+8 -8
View File
@@ -5,20 +5,20 @@
VerticalAlignment="Center"> VerticalAlignment="Center">
<Grid> <Grid>
<ToggleButton x:Name="Toggle" Style="{StaticResource FlatButton}" Padding="6,4" MinWidth="0" MinHeight="30" <ToggleButton x:Name="Toggle" Style="{StaticResource FlatButton}" Padding="6,4" MinWidth="0" MinHeight="30"
BorderBrush="#E0E0E0" Background="White" ToolTip="Emoji kiezen"> BorderBrush="{DynamicResource ControlBorderBrush}" Background="{DynamicResource ControlBrush}" ToolTip="Emoji kiezen">
<StackPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal">
<Image x:Name="Face" Width="20" Height="20" RenderOptions.BitmapScalingMode="HighQuality" /> <Image x:Name="Face" Width="20" Height="20" RenderOptions.BitmapScalingMode="HighQuality" />
<TextBlock Text="&#xE70D;" Style="{StaticResource IconText}" FontSize="9" Margin="5,0,1,0" Foreground="#5C5C5C" /> <TextBlock Text="&#xE70D;" Style="{StaticResource IconText}" FontSize="9" Margin="5,0,1,0" Foreground="{DynamicResource SubtleTextBrush}" />
</StackPanel> </StackPanel>
</ToggleButton> </ToggleButton>
<Popup x:Name="PopupPanel" PlacementTarget="{Binding ElementName=Toggle}" Placement="Bottom" StaysOpen="False" <Popup x:Name="PopupPanel" PlacementTarget="{Binding ElementName=Toggle}" Placement="Bottom" StaysOpen="False"
AllowsTransparency="True" PopupAnimation="Fade" Opened="Popup_Opened" AllowsTransparency="True" PopupAnimation="Fade" Opened="Popup_Opened"
IsOpen="{Binding IsChecked, ElementName=Toggle, Mode=TwoWay}"> IsOpen="{Binding IsChecked, ElementName=Toggle, Mode=TwoWay}">
<Border Background="White" BorderBrush="#D6D6D6" BorderThickness="1" CornerRadius="6" Padding="8" Margin="0,4,10,10" <Border Background="{DynamicResource CardBrush}" BorderBrush="{DynamicResource ControlStrongBorderBrush}" BorderThickness="1" CornerRadius="6" Padding="8" Margin="0,4,10,10"
PreviewKeyDown="Popup_PreviewKeyDown"> PreviewKeyDown="Popup_PreviewKeyDown">
<Border.Resources> <Border.Resources>
<DataTemplate DataType="{x:Type local:EmojiHeaderRow}"> <DataTemplate DataType="{x:Type local:EmojiHeaderRow}">
<TextBlock Text="{Binding Title}" FontSize="12" FontWeight="SemiBold" Foreground="{StaticResource SubtleTextBrush}" <TextBlock Text="{Binding Title}" FontSize="12" FontWeight="SemiBold" Foreground="{DynamicResource SubtleTextBrush}"
Margin="4,8,0,4" /> Margin="4,8,0,4" />
</DataTemplate> </DataTemplate>
<DataTemplate DataType="{x:Type local:EmojiCellRow}"> <DataTemplate DataType="{x:Type local:EmojiCellRow}">
@@ -46,7 +46,7 @@
<!-- Zoeken --> <!-- Zoeken -->
<Grid DockPanel.Dock="Top" Margin="0,0,0,6"> <Grid DockPanel.Dock="Top" Margin="0,0,0,6">
<TextBox x:Name="SearchBox" Height="30" Padding="28,0,6,0" VerticalContentAlignment="Center" FontSize="13" <TextBox x:Name="SearchBox" Height="30" Padding="28,0,6,0" VerticalContentAlignment="Center" FontSize="13"
BorderBrush="#D6D6D6" TextChanged="SearchBox_TextChanged" /> TextChanged="SearchBox_TextChanged" />
<TextBlock Text="&#xE721;" Style="{StaticResource IconText}" FontSize="12" Foreground="#8A8A8A" <TextBlock Text="&#xE721;" Style="{StaticResource IconText}" FontSize="12" Foreground="#8A8A8A"
HorizontalAlignment="Left" Margin="9,0,0,0" IsHitTestVisible="False" /> HorizontalAlignment="Left" Margin="9,0,0,0" IsHitTestVisible="False" />
<TextBlock x:Name="SearchPlaceholder" Text="Zoeken (Nederlands of Engels)" Foreground="#8A8A8A" FontSize="13" <TextBlock x:Name="SearchPlaceholder" Text="Zoeken (Nederlands of Engels)" Foreground="#8A8A8A" FontSize="13"
@@ -57,14 +57,14 @@
<StackPanel x:Name="TabPanel" DockPanel.Dock="Top" Orientation="Horizontal" Margin="0,0,0,4" /> <StackPanel x:Name="TabPanel" DockPanel.Dock="Top" Orientation="Horizontal" Margin="0,0,0,4" />
<!-- Voorbeeld en huidskleur --> <!-- Voorbeeld en huidskleur -->
<Border DockPanel.Dock="Bottom" BorderBrush="{StaticResource DividerBrush}" BorderThickness="0,1,0,0" Padding="2,8,0,0" <Border DockPanel.Dock="Bottom" BorderBrush="{DynamicResource DividerBrush}" BorderThickness="0,1,0,0" Padding="2,8,0,0"
Margin="0,4,0,0"> Margin="0,4,0,0">
<DockPanel> <DockPanel>
<StackPanel x:Name="TonePanel" DockPanel.Dock="Right" Orientation="Horizontal" VerticalAlignment="Center" /> <StackPanel x:Name="TonePanel" DockPanel.Dock="Right" Orientation="Horizontal" VerticalAlignment="Center" />
<Image x:Name="PreviewImage" Width="36" Height="36" RenderOptions.BitmapScalingMode="HighQuality" /> <Image x:Name="PreviewImage" Width="36" Height="36" RenderOptions.BitmapScalingMode="HighQuality" />
<StackPanel VerticalAlignment="Center" Margin="8,0,6,0"> <StackPanel VerticalAlignment="Center" Margin="8,0,6,0">
<TextBlock x:Name="PreviewName" FontSize="13" TextTrimming="CharacterEllipsis" /> <TextBlock x:Name="PreviewName" FontSize="13" TextTrimming="CharacterEllipsis" />
<TextBlock x:Name="PreviewCode" FontSize="11" Foreground="{StaticResource SubtleTextBrush}" <TextBlock x:Name="PreviewCode" FontSize="11" Foreground="{DynamicResource SubtleTextBrush}"
TextTrimming="CharacterEllipsis" /> TextTrimming="CharacterEllipsis" />
</StackPanel> </StackPanel>
</DockPanel> </DockPanel>
@@ -89,7 +89,7 @@
</ControlTemplate> </ControlTemplate>
</ItemsControl.Template> </ItemsControl.Template>
</ItemsControl> </ItemsControl>
<TextBlock x:Name="NoResultsText" Text="Geen emoji gevonden" Foreground="{StaticResource SubtleTextBrush}" <TextBlock x:Name="NoResultsText" Text="Geen emoji gevonden" Foreground="{DynamicResource SubtleTextBrush}"
HorizontalAlignment="Center" VerticalAlignment="Center" Visibility="Collapsed" /> HorizontalAlignment="Center" VerticalAlignment="Center" Visibility="Collapsed" />
</Grid> </Grid>
</DockPanel> </DockPanel>
+64
View File
@@ -0,0 +1,64 @@
<UserControl x:Class="Kniip.Editor.GradientAngleDial"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
VerticalAlignment="Center">
<Grid>
<ToggleButton x:Name="Toggle" Style="{StaticResource FlatButton}" Padding="6,4" MinWidth="0" MinHeight="30"
BorderBrush="{DynamicResource ControlBorderBrush}" Background="{DynamicResource ControlBrush}"
MouseWheel="Dial_MouseWheel">
<StackPanel Orientation="Horizontal">
<Grid Width="20" Height="20">
<Ellipse Stroke="{DynamicResource InkBrush}" StrokeThickness="1.2" Margin="1" />
<Path x:Name="SmallArrow" Data="M10,15 V5 M7,8 L10,5 L13,8" Stroke="{DynamicResource InkBrush}" StrokeThickness="1.4"
StrokeLineJoin="Round" StrokeStartLineCap="Round" StrokeEndLineCap="Round" RenderTransformOrigin="0.5,0.5">
<Path.RenderTransform>
<RotateTransform x:Name="SmallRotate" />
</Path.RenderTransform>
</Path>
<Ellipse x:Name="SmallRadial" Width="5" Height="5" Fill="{DynamicResource InkBrush}" Visibility="Collapsed" />
</Grid>
<TextBlock Text="&#xE70D;" Style="{StaticResource IconText}" FontSize="9" Margin="5,0,1,0" Foreground="{DynamicResource SubtleTextBrush}" />
</StackPanel>
</ToggleButton>
<Popup x:Name="PopupPanel" PlacementTarget="{Binding ElementName=Toggle}" Placement="Bottom" StaysOpen="False"
AllowsTransparency="True" PopupAnimation="Fade"
IsOpen="{Binding IsChecked, ElementName=Toggle, Mode=TwoWay}">
<Border Background="{DynamicResource CardBrush}" BorderBrush="{DynamicResource ControlStrongBorderBrush}" BorderThickness="1" CornerRadius="6" Padding="10" Margin="0,4,10,10">
<Border.Effect>
<DropShadowEffect BlurRadius="10" ShadowDepth="2" Opacity="0.18" />
</Border.Effect>
<StackPanel>
<!-- Sleep de knop rond; klik ergens op de cirkel om de richting meteen te kiezen -->
<Canvas x:Name="DialCanvas" Width="140" Height="140" Background="Transparent" Cursor="Hand"
MouseLeftButtonDown="Dial_MouseDown" MouseMove="Dial_MouseMove" MouseLeftButtonUp="Dial_MouseUp"
LostMouseCapture="Dial_LostMouseCapture" MouseWheel="Dial_MouseWheel">
<Ellipse x:Name="Track" Canvas.Left="20" Canvas.Top="20" Width="100" Height="100"
Stroke="{DynamicResource ControlStrongBorderBrush}" StrokeThickness="2" />
<Canvas x:Name="Ticks" IsHitTestVisible="False" />
<Line x:Name="Needle" X1="70" Y1="70" Stroke="{DynamicResource AccentBrush}" StrokeThickness="2"
StrokeStartLineCap="Round" StrokeEndLineCap="Round" IsHitTestVisible="False" />
<Ellipse Canvas.Left="66" Canvas.Top="66" Width="8" Height="8" Fill="{DynamicResource AccentBrush}" IsHitTestVisible="False" />
<Ellipse x:Name="Knob" Width="16" Height="16" Fill="White" Stroke="{DynamicResource AccentBrush}" StrokeThickness="2"
IsHitTestVisible="False" />
</Canvas>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Margin="0,8,0,0">
<TextBox x:Name="AngleBox" Width="48" VerticalContentAlignment="Center" HorizontalContentAlignment="Right" FontSize="12"
Padding="2,3" KeyDown="AngleBox_KeyDown" LostKeyboardFocus="AngleBox_LostKeyboardFocus"
ToolTip="Hoek in graden: 0 = naar boven, 90 = naar rechts, 180 = naar onder" />
<TextBlock Text="°" FontSize="12" VerticalAlignment="Center" Margin="3,0,0,0" Foreground="{DynamicResource InkBrush}" />
</StackPanel>
<ToggleButton x:Name="RadialToggle" Style="{StaticResource ToolToggle}" Margin="0,8,0,0" HorizontalAlignment="Stretch"
Click="Radial_Click" ToolTip="Verloop vanuit het midden naar buiten">
<StackPanel Orientation="Horizontal">
<Path Data="M9,3 A6,6 0 1 1 8.99,3 Z M9,7.5 A1.5,1.5 0 1 1 8.99,7.5 Z" Stroke="{DynamicResource InkBrush}" StrokeThickness="1.4"
Width="18" Height="18" VerticalAlignment="Center" />
<TextBlock Text="Vanuit het midden" FontSize="12" VerticalAlignment="Center" Margin="6,0,0,0" />
</StackPanel>
</ToggleButton>
<TextBlock Text="Shift = stappen van 15°, Alt = niet vastklikken" FontSize="11" Margin="0,6,0,0" HorizontalAlignment="Center"
Foreground="{DynamicResource SubtleTextBrush}" />
</StackPanel>
</Border>
</Popup>
</Grid>
</UserControl>
+250
View File
@@ -0,0 +1,250 @@
using System;
using System.Globalization;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Shapes;
namespace Kniip.Editor;
/// <summary>
/// Richting van een verloop: een cirkel met een knop die je rondsleept, plus een hoekveld en "vanuit het midden".
/// Hoeken in graden met de klok mee vanaf boven, zoals <see cref="CanvasBackground.Angle"/>.
/// </summary>
public partial class GradientAngleDial : UserControl
{
private const double Center = 70;
private const double Radius = 50;
private double _angle = 180;
private bool _isRadial;
private bool _dragging;
/// <summary>Tijdens het slepen, voor een live voorbeeld; nog geen stap in Ongedaan maken.</summary>
public event Action<double>? AngleChanging;
/// <summary>Definitieve hoek: bij loslaten, scrollen of typen. Kiest ook een lineair verloop.</summary>
public event Action<double>? AngleCommitted;
/// <summary>"Vanuit het midden" aan- of uitgezet.</summary>
public event Action<bool>? RadialChanged;
public GradientAngleDial()
{
InitializeComponent();
BuildTicks();
UpdateVisuals();
}
public double Angle
{
get => _angle;
set
{
_angle = Normalize(value);
UpdateVisuals();
}
}
public bool IsRadial
{
get => _isRadial;
set
{
_isRadial = value;
UpdateVisuals();
}
}
private static double Normalize(double angle)
{
angle %= 360;
if (angle < 0)
{
angle += 360;
}
return Math.Round(angle, 1) >= 360 ? 0 : Math.Round(angle, 1);
}
private static Vector Direction(double angle)
{
double radians = angle * Math.PI / 180;
return new Vector(Math.Sin(radians), -Math.Cos(radians));
}
private void BuildTicks()
{
for (int i = 0; i < 8; i++)
{
Vector d = Direction(i * 45);
double inner = Radius + 4, outer = Radius + (i % 2 == 0 ? 11 : 8);
Ticks.Children.Add(new Line
{
X1 = Center + d.X * inner,
Y1 = Center + d.Y * inner,
X2 = Center + d.X * outer,
Y2 = Center + d.Y * outer,
StrokeThickness = 1.5,
StrokeStartLineCap = PenLineCap.Round,
StrokeEndLineCap = PenLineCap.Round
});
((Line)Ticks.Children[i]).SetResourceReference(Shape.StrokeProperty, "SubtleTextBrush");
}
}
private void UpdateVisuals()
{
Vector d = Direction(_angle);
Point knob = new Point(Center, Center) + d * Radius;
Canvas.SetLeft(Knob, knob.X - Knob.Width / 2);
Canvas.SetTop(Knob, knob.Y - Knob.Height / 2);
Needle.X2 = knob.X;
Needle.Y2 = knob.Y;
DialCanvas.Opacity = _isRadial ? 0.45 : 1;
SmallRotate.Angle = _angle;
SmallArrow.Visibility = _isRadial ? Visibility.Collapsed : Visibility.Visible;
SmallRadial.Visibility = _isRadial ? Visibility.Visible : Visibility.Collapsed;
RadialToggle.IsChecked = _isRadial;
if (!AngleBox.IsKeyboardFocusWithin)
{
AngleBox.Text = _angle.ToString("0.#", CultureInfo.CurrentCulture);
}
Toggle.ToolTip = _isRadial
? "Richting van het verloop: vanuit het midden"
: $"Richting van het verloop: {_angle.ToString("0.#", CultureInfo.CurrentCulture)}° (scroll om te draaien)";
}
private double AngleAt(Point p)
{
Vector v = p - new Point(Center, Center);
if (v.Length < 1)
{
return _angle;
}
double angle = Normalize(Math.Atan2(v.X, -v.Y) * 180 / Math.PI);
ModifierKeys modifiers = Keyboard.Modifiers;
if (modifiers.HasFlag(ModifierKeys.Alt))
{
return Normalize(Math.Round(angle));
}
if (modifiers.HasFlag(ModifierKeys.Shift))
{
return Normalize(Math.Round(angle / 15) * 15);
}
double nearest = Math.Round(angle / 45) * 45;
return Normalize(Math.Abs(angle - nearest) <= 6 ? nearest : Math.Round(angle));
}
private void Dial_MouseDown(object sender, MouseButtonEventArgs e)
{
double angle = AngleAt(e.GetPosition(DialCanvas));
_dragging = DialCanvas.CaptureMouse();
if (_dragging)
{
Preview(angle);
}
else
{
Commit(angle);
}
e.Handled = true;
}
private void Dial_MouseMove(object sender, MouseEventArgs e)
{
if (_dragging)
{
Preview(AngleAt(e.GetPosition(DialCanvas)));
}
}
private void Dial_MouseUp(object sender, MouseButtonEventArgs e)
{
if (_dragging)
{
DialCanvas.ReleaseMouseCapture();
}
}
private void Dial_LostMouseCapture(object sender, MouseEventArgs e)
{
if (!_dragging)
{
return;
}
_dragging = false;
Commit(_angle);
}
private void Dial_MouseWheel(object sender, MouseWheelEventArgs e)
{
double step = Keyboard.Modifiers.HasFlag(ModifierKeys.Alt) ? 1 : 15;
double angle = e.Delta > 0 ? _angle + step : _angle - step;
Commit(step == 15 ? Math.Round(angle / 15) * 15 : angle);
e.Handled = true;
}
private void Preview(double angle)
{
_isRadial = false;
Angle = angle;
AngleChanging?.Invoke(_angle);
}
private void Commit(double angle)
{
_isRadial = false;
Angle = angle;
AngleCommitted?.Invoke(_angle);
}
private void AngleBox_KeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.Enter)
{
ApplyTypedAngle();
AngleBox.SelectAll();
e.Handled = true;
}
else if (e.Key == Key.Escape)
{
AngleBox.Text = _angle.ToString("0.#", CultureInfo.CurrentCulture);
Toggle.IsChecked = false;
e.Handled = true;
}
}
private void AngleBox_LostKeyboardFocus(object sender, KeyboardFocusChangedEventArgs e) => ApplyTypedAngle();
private void ApplyTypedAngle()
{
string text = AngleBox.Text.Trim().TrimEnd('°');
if (double.TryParse(text, NumberStyles.Float, CultureInfo.CurrentCulture, out double angle)
|| double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out angle))
{
if (Normalize(angle) != _angle || _isRadial)
{
Commit(angle);
return;
}
}
AngleBox.Text = _angle.ToString("0.#", CultureInfo.CurrentCulture);
}
private void Radial_Click(object sender, RoutedEventArgs e)
{
_isRadial = RadialToggle.IsChecked == true;
UpdateVisuals();
RadialChanged?.Invoke(_isRadial);
}
}
+37 -12
View File
@@ -9,7 +9,7 @@ internal static class ImageEffects
{ {
public static BitmapSource Pixelate(EditorDocument doc, Int32Rect area, int blockSize) public static BitmapSource Pixelate(EditorDocument doc, Int32Rect area, int blockSize)
{ {
byte[] src = doc.Pixels; byte[] src = doc.EffectPixels;
int srcStride = doc.Width * 4; int srcStride = doc.Width * 4;
var dst = new byte[area.Width * area.Height * 4]; var dst = new byte[area.Width * area.Height * 4];
int dstStride = area.Width * 4; int dstStride = area.Width * 4;
@@ -20,21 +20,31 @@ internal static class ImageEffects
for (int bx = 0; bx < area.Width; bx += blockSize) for (int bx = 0; bx < area.Width; bx += blockSize)
{ {
int bw = Math.Min(blockSize, area.Width - bx); int bw = Math.Min(blockSize, area.Width - bx);
long b = 0, g = 0, r = 0; long b = 0, g = 0, r = 0, a = 0;
for (int y = 0; y < bh; y++) for (int y = 0; y < bh; y++)
{ {
int row = (area.Y + by + y) * srcStride + (area.X + bx) * 4; int row = (area.Y + by + y) * srcStride + (area.X + bx) * 4;
for (int x = 0; x < bw; x++) for (int x = 0; x < bw; x++)
{ {
int i = row + x * 4; int i = row + x * 4;
b += src[i]; int alpha = src[i + 3];
g += src[i + 1]; b += src[i] * alpha;
r += src[i + 2]; g += src[i + 1] * alpha;
r += src[i + 2] * alpha;
a += alpha;
} }
} }
int count = bw * bh; int count = bw * bh;
byte ab = (byte)(b / count), ag = (byte)(g / count), ar = (byte)(r / count); byte ab = 0, ag = 0, ar = 0;
if (a > 0)
{
ab = (byte)(b / a);
ag = (byte)(g / a);
ar = (byte)(r / a);
}
byte aa = (byte)(a / count);
for (int y = 0; y < bh; y++) for (int y = 0; y < bh; y++)
{ {
int row = (by + y) * dstStride + bx * 4; int row = (by + y) * dstStride + bx * 4;
@@ -44,7 +54,7 @@ internal static class ImageEffects
dst[i] = ab; dst[i] = ab;
dst[i + 1] = ag; dst[i + 1] = ag;
dst[i + 2] = ar; dst[i + 2] = ar;
dst[i + 3] = 255; dst[i + 3] = aa;
} }
} }
} }
@@ -69,7 +79,16 @@ internal static class ImageEffects
int srcStride = doc.Width * 4; int srcStride = doc.Width * 4;
for (int y = 0; y < h; y++) for (int y = 0; y < h; y++)
{ {
Buffer.BlockCopy(doc.Pixels, (top + y) * srcStride + left * 4, buffer, y * w * 4, w * 4); Buffer.BlockCopy(doc.EffectPixels, (top + y) * srcStride + left * 4, buffer, y * w * 4, w * 4);
}
// Voorvermenigvuldigd vervagen, anders lopen de (zwarte) kleuren van transparante pixels mee.
for (int i = 0; i < buffer.Length; i += 4)
{
int alpha = buffer[i + 3];
buffer[i] = (byte)(buffer[i] * alpha / 255);
buffer[i + 1] = (byte)(buffer[i + 1] * alpha / 255);
buffer[i + 2] = (byte)(buffer[i + 2] * alpha / 255);
} }
var temp = new byte[buffer.Length]; var temp = new byte[buffer.Length];
@@ -87,9 +106,15 @@ internal static class ImageEffects
Buffer.BlockCopy(buffer, ((offsetY + y) * w + offsetX) * 4, dst, y * area.Width * 4, area.Width * 4); Buffer.BlockCopy(buffer, ((offsetY + y) * w + offsetX) * 4, dst, y * area.Width * 4, area.Width * 4);
} }
for (int i = 3; i < dst.Length; i += 4) for (int i = 0; i < dst.Length; i += 4)
{ {
dst[i] = 255; int alpha = dst[i + 3];
if (alpha is > 0 and < 255)
{
dst[i] = (byte)Math.Min(255, dst[i] * 255 / alpha);
dst[i + 1] = (byte)Math.Min(255, dst[i + 1] * 255 / alpha);
dst[i + 2] = (byte)Math.Min(255, dst[i + 2] * 255 / alpha);
}
} }
return PixelUtil.CreateBitmap(dst, area.Width, area.Height); return PixelUtil.CreateBitmap(dst, area.Width, area.Height);
@@ -101,7 +126,7 @@ internal static class ImageEffects
for (int y = 0; y < h; y++) for (int y = 0; y < h; y++)
{ {
int row = y * w * 4; int row = y * w * 4;
for (int c = 0; c < 3; c++) for (int c = 0; c < 4; c++)
{ {
int sum = 0; int sum = 0;
for (int i = -r; i <= r; i++) for (int i = -r; i <= r; i++)
@@ -127,7 +152,7 @@ internal static class ImageEffects
for (int x = 0; x < w; x++) for (int x = 0; x < w; x++)
{ {
int col = x * 4; int col = x * 4;
for (int c = 0; c < 3; c++) for (int c = 0; c < 4; c++)
{ {
int sum = 0; int sum = 0;
for (int i = -r; i <= r; i++) for (int i = -r; i <= r; i++)
+96 -2
View File
@@ -4,9 +4,12 @@ using System.IO;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Threading; using System.Threading;
using System.Windows; using System.Windows;
using System.Windows.Interop;
using System.Windows.Media; using System.Windows.Media;
using System.Windows.Media.Imaging; using System.Windows.Media.Imaging;
using Kniip.Capture; using Kniip.Capture;
using Kniip.Infrastructure;
using Kniip.Interop;
namespace Kniip.Editor; namespace Kniip.Editor;
@@ -20,7 +23,7 @@ internal static class ImageExporter
: new PngBitmapEncoder(); : new PngBitmapEncoder();
BitmapSource frame = encoder is JpegBitmapEncoder BitmapSource frame = encoder is JpegBitmapEncoder
? new FormatConvertedBitmap(image, PixelFormats.Bgr24, null, 0) ? new FormatConvertedBitmap(PixelUtil.OnWhite(image), PixelFormats.Bgr24, null, 0)
: image; : image;
encoder.Frames.Add(BitmapFrame.Create(frame)); encoder.Frames.Add(BitmapFrame.Create(frame));
@@ -45,11 +48,12 @@ internal static class ClipboardWriter
/// Zet de afbeelding als bitmap én als PNG op het klembord, met een markering zodat /// Zet de afbeelding als bitmap én als PNG op het klembord, met een markering zodat
/// de eigen klembordbewaking dit niet als nieuw knipsel ziet. /// de eigen klembordbewaking dit niet als nieuw knipsel ziet.
/// Met een pad staat ook het bestand zelf op het klembord. /// Met een pad staat ook het bestand zelf op het klembord.
/// De bitmap krijgt wit onder transparante delen (veel programma's lezen daar de alpha niet van); de PNG houdt ze transparant.
/// </summary> /// </summary>
public static bool SetImage(BitmapSource image, string? filePath = null) public static bool SetImage(BitmapSource image, string? filePath = null)
{ {
var data = new DataObject(); var data = new DataObject();
data.SetImage(image); data.SetImage(PixelUtil.OnWhite(image));
data.SetData("PNG", ImageExporter.EncodePng(image), false); data.SetData("PNG", ImageExporter.EncodePng(image), false);
data.SetData(ClipboardImageReader.ExportMarkerFormat, "1"); data.SetData(ClipboardImageReader.ExportMarkerFormat, "1");
if (!string.IsNullOrEmpty(filePath)) if (!string.IsNullOrEmpty(filePath))
@@ -68,13 +72,103 @@ internal static class ClipboardWriter
return TrySet(data); return TrySet(data);
} }
/// <summary>Alleen een eigen markering op het klembord, zonder afbeelding of tekst.</summary>
public static bool SetMarker(string format, string value)
{
var data = new DataObject();
data.SetData(format, value);
return TrySet(data);
}
/// <summary>
/// Tekst gaat rechtstreeks via Win32 op het klembord. De OLE-route van WPF moet het klembord
/// daarna nog leegspoelen, en dat mislukt zodra een klembordbeheerder het meteen opent.
/// </summary>
public static bool SetText(string text) public static bool SetText(string text)
{ {
if (TrySetUnicodeText(text))
{
return true;
}
var data = new DataObject(); var data = new DataObject();
data.SetText(text); data.SetText(text);
return TrySet(data); return TrySet(data);
} }
private static HwndSource? _owner;
private static IntPtr OwnerHandle
{
get
{
// Een venster als eigenaar is verplicht: zonder eigenaar weigert SetClipboardData na EmptyClipboard.
_owner ??= new HwndSource(new HwndSourceParameters("KniipClipboardWriter")
{
ParentWindow = NativeMethods.HWND_MESSAGE,
WindowStyle = 0
});
return _owner.Handle;
}
}
private static bool TrySetUnicodeText(string text)
{
IntPtr owner = OwnerHandle;
for (int attempt = 0; attempt < 20; attempt++)
{
if (NativeMethods.OpenClipboard(owner))
{
try
{
return NativeMethods.EmptyClipboard() && PutUnicodeText(text);
}
finally
{
NativeMethods.CloseClipboard();
}
}
Thread.Sleep(50);
}
return false;
}
private static bool PutUnicodeText(string text)
{
IntPtr handle = NativeMethods.GlobalAlloc(NativeMethods.GMEM_MOVEABLE, (UIntPtr)((text.Length + 1) * 2));
if (handle == IntPtr.Zero)
{
return false;
}
IntPtr target = NativeMethods.GlobalLock(handle);
if (target == IntPtr.Zero)
{
NativeMethods.GlobalFree(handle);
return false;
}
try
{
Marshal.Copy(text.ToCharArray(), 0, target, text.Length);
Marshal.WriteInt16(target, text.Length * 2, 0);
}
finally
{
NativeMethods.GlobalUnlock(handle);
}
if (NativeMethods.SetClipboardData(NativeMethods.CF_UNICODETEXT, handle) == IntPtr.Zero)
{
NativeMethods.GlobalFree(handle);
return false;
}
return true;
}
private static bool TrySet(DataObject data) private static bool TrySet(DataObject data)
{ {
for (int attempt = 0; attempt < 5; attempt++) for (int attempt = 0; attempt < 5; attempt++)
+31
View File
@@ -0,0 +1,31 @@
<Window x:Class="Kniip.Editor.OcrResultWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="kniip tekst herkennen" Width="560" Height="420" MinWidth="360" MinHeight="240"
WindowStartupLocation="CenterScreen" Background="{DynamicResource WindowBrush}" Foreground="{DynamicResource InkBrush}"
FontFamily="Segoe UI" FontSize="13" Icon="pack://application:,,,/AppIcon.ico">
<DockPanel Margin="14">
<DockPanel DockPanel.Dock="Bottom" Margin="0,12,0,0" LastChildFill="False">
<TextBlock x:Name="StatusText" DockPanel.Dock="Left" VerticalAlignment="Center" FontSize="12"
Foreground="{DynamicResource SubtleTextBrush}" TextTrimming="CharacterEllipsis" />
<Button DockPanel.Dock="Right" Style="{StaticResource SecondaryButton}" Content="Sluiten" IsCancel="True" Click="Close_Click" />
<Button x:Name="CopyButton" DockPanel.Dock="Right" Style="{StaticResource AccentButton}" ToolTip="Alles kopiëren, of alleen de geselecteerde tekst"
Click="Copy_Click">
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#xE8C8;" Style="{StaticResource IconText}" FontSize="14" Foreground="White" />
<TextBlock x:Name="CopyLabel" Text="Kopiëren" Margin="6,0,0,0" />
</StackPanel>
</Button>
</DockPanel>
<Grid>
<TextBox x:Name="ResultBox" AcceptsReturn="True" AcceptsTab="True" TextWrapping="Wrap" Padding="6"
VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled"
FontFamily="Segoe UI" FontSize="13" SelectionChanged="ResultBox_SelectionChanged" />
<StackPanel x:Name="EmptyPanel" HorizontalAlignment="Center" VerticalAlignment="Center" Visibility="Collapsed" IsHitTestVisible="False">
<TextBlock Text="&#xE8D2;" Style="{StaticResource IconText}" FontSize="28" Foreground="{DynamicResource SubtleTextBrush}" />
<TextBlock x:Name="EmptyText" Text="Geen tekst gevonden in deze afbeelding." Margin="0,8,0,0" TextWrapping="Wrap"
TextAlignment="Center" MaxWidth="380" Foreground="{DynamicResource SubtleTextBrush}" />
</StackPanel>
</Grid>
</DockPanel>
</Window>
+110
View File
@@ -0,0 +1,110 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Media.Imaging;
namespace Kniip.Editor;
/// <summary>Toont de herkende tekst van een afbeelding, om aan te passen en te kopiëren.</summary>
public partial class OcrResultWindow : Window
{
private OcrResultWindow()
{
InitializeComponent();
Infrastructure.ThemeManager.Attach(this);
}
/// <summary>Klaar zodra de tekst herkend is of het herkennen is mislukt.</summary>
public Task Completion { get; private set; } = Task.CompletedTask;
/// <summary>Opent het venster meteen en vult het zodra de tekst herkend is.</summary>
public static OcrResultWindow Open(BitmapSource image, Window? owner = null)
{
var window = new OcrResultWindow();
if (owner is { IsVisible: true })
{
window.Owner = owner;
window.WindowStartupLocation = WindowStartupLocation.CenterOwner;
}
window.SetBusy(true);
window.Show();
window.Activate();
window.Completion = window.RecognizeAsync(image);
return window;
}
private async Task RecognizeAsync(BitmapSource image)
{
try
{
ShowText(await OcrService.RecognizeAsync(image));
}
catch (Exception ex)
{
ShowError(ex is InvalidOperationException ? ex.Message : $"Tekst herkennen is mislukt.\n\n{ex.Message}");
}
finally
{
SetBusy(false);
}
}
private void SetBusy(bool busy)
{
ResultBox.IsReadOnly = busy;
CopyButton.IsEnabled = !busy && ResultBox.Text.Length > 0;
if (busy)
{
StatusText.Text = "Tekst herkennen...";
}
}
private void ShowText(string text)
{
ResultBox.Text = text;
bool empty = text.Trim().Length == 0;
EmptyPanel.Visibility = empty ? Visibility.Visible : Visibility.Collapsed;
int lines = text.Split('\n').Count(line => line.Trim().Length > 0);
StatusText.Text = empty ? string.Empty
: $"{lines} {(lines == 1 ? "regel" : "regels")} herkend. Je kunt de tekst hier aanpassen.";
if (!empty)
{
ResultBox.Focus();
ResultBox.SelectAll();
}
}
private void ShowError(string message)
{
ResultBox.Text = string.Empty;
ResultBox.IsEnabled = false;
EmptyText.Text = message;
EmptyPanel.Visibility = Visibility.Visible;
StatusText.Text = string.Empty;
}
private void ResultBox_SelectionChanged(object sender, RoutedEventArgs e)
{
if (!IsLoaded)
{
return;
}
CopyLabel.Text = ResultBox.SelectionLength > 0 && ResultBox.SelectionLength < ResultBox.Text.Length
? "Selectie kopiëren"
: "Kopiëren";
CopyButton.IsEnabled = !ResultBox.IsReadOnly && ResultBox.Text.Length > 0;
}
private void Copy_Click(object sender, RoutedEventArgs e)
{
string text = ResultBox.SelectionLength > 0 ? ResultBox.SelectedText : ResultBox.Text;
StatusText.Text = ClipboardWriter.SetText(text)
? "Gekopieerd naar het klembord."
: "Kopiëren mislukt: het klembord is bezet. Probeer het opnieuw.";
}
private void Close_Click(object sender, RoutedEventArgs e) => Close();
}
+162
View File
@@ -0,0 +1,162 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using Windows.Globalization;
using Windows.Media.Ocr;
using BitmapAlphaMode = Windows.Graphics.Imaging.BitmapAlphaMode;
using BitmapPixelFormat = Windows.Graphics.Imaging.BitmapPixelFormat;
using SoftwareBitmap = Windows.Graphics.Imaging.SoftwareBitmap;
using WinBitmapDecoder = Windows.Graphics.Imaging.BitmapDecoder;
namespace Kniip.Editor;
/// <summary>Tekst herkennen met de OCR die in Windows zit; de taal volgt de talen van het gebruikersprofiel.</summary>
internal static class OcrService
{
/// <summary>Kleinere afbeeldingen worden vergroot: kleine schermtekst mist de OCR anders vaak.</summary>
private const int UpscaleBelowPixels = 2_000_000;
/// <summary>Herkende regels, gescheiden door regeleinden; leeg als er geen tekst is gevonden.</summary>
/// <exception cref="InvalidOperationException">Er is geen taal voor tekstherkenning geïnstalleerd.</exception>
public static async Task<string> RecognizeAsync(BitmapSource image)
{
OcrEngine engine = CreateEngine();
using SoftwareBitmap bitmap = await ToSoftwareBitmapAsync(Prepare(image));
OcrResult result = await engine.RecognizeAsync(bitmap);
return Layout(result);
}
/// <summary>
/// Regels opnieuw opbouwen uit de plek van elk woord. Windows geeft de tekst per blok terug; bij afwisselende
/// kleuren of opmaak valt één regel dan in stukken die ver uit elkaar in het resultaat staan.
/// </summary>
private static string Layout(OcrResult result)
{
var words = result.Lines
.SelectMany(line => line.Words)
.Select(word => (word.Text, Box: new Rect(word.BoundingRect.X, word.BoundingRect.Y, word.BoundingRect.Width, word.BoundingRect.Height)))
.Where(word => word.Text.Length > 0 && word.Box.Height > 0)
.OrderBy(word => word.Box.Top + word.Box.Height / 2)
.ToList();
var rows = new List<Row>();
foreach ((string text, Rect box) in words)
{
double center = box.Top + box.Height / 2;
Row? row = rows.LastOrDefault(r => center >= r.Top && center <= r.Bottom
&& Math.Abs(r.Center - center) <= Math.Min(r.Height, box.Height) * 0.6);
if (row == null)
{
row = new Row();
rows.Add(row);
}
row.Add(text, box);
}
rows.Sort((a, b) => a.Center.CompareTo(b.Center));
double lineHeight = rows.Count == 0 ? 0 : rows.Select(r => r.Height).OrderBy(h => h).ElementAt(rows.Count / 2);
var output = new StringBuilder();
for (int i = 0; i < rows.Count; i++)
{
if (i > 0)
{
output.AppendLine();
// Een duidelijk grotere witruimte dan een gewone regelafstand: een lege regel tussen alinea's.
if (rows[i].Top - rows[i - 1].Bottom > lineHeight * 0.9)
{
output.AppendLine();
}
}
output.Append(rows[i].Text);
}
return output.ToString();
}
private sealed class Row
{
private readonly List<(string Text, Rect Box)> _words = new();
public double Top { get; private set; } = double.MaxValue;
public double Bottom { get; private set; } = double.MinValue;
public double Height => Bottom - Top;
public double Center => (Top + Bottom) / 2;
public void Add(string text, Rect box)
{
_words.Add((text, box));
Top = Math.Min(Top, box.Top);
Bottom = Math.Max(Bottom, box.Bottom);
}
public string Text => string.Join(" ", _words.OrderBy(w => w.Box.Left).Select(w => w.Text));
}
private static OcrEngine CreateEngine()
{
if (OcrEngine.TryCreateFromUserProfileLanguages() is { } engine)
{
return engine;
}
var english = new Language("en-US");
if (OcrEngine.IsLanguageSupported(english) && OcrEngine.TryCreateFromLanguage(english) is { } fallback)
{
return fallback;
}
throw new InvalidOperationException(
"Windows heeft geen taal voor tekstherkenning. Voeg er een toe via Instellingen > Tijd en taal > Taal en regio: " +
"kies een taal en zet bij de taalopties Optische tekenherkenning aan.");
}
/// <summary>Op wit leggen (transparant leest de OCR als zwart) en zo nodig vergroten of verkleinen.</summary>
private static BitmapSource Prepare(BitmapSource image)
{
int width = image.PixelWidth, height = image.PixelHeight;
long pixels = (long)width * height;
double scale = pixels < UpscaleBelowPixels / 4 ? 3 : pixels < UpscaleBelowPixels ? 2 : 1;
double max = OcrEngine.MaxImageDimension;
scale = Math.Min(scale, Math.Min(max / width, max / height));
int w = Math.Max(1, (int)Math.Round(width * scale));
int h = Math.Max(1, (int)Math.Round(height * scale));
var visual = new DrawingVisual();
using (DrawingContext dc = visual.RenderOpen())
{
var area = new Rect(0, 0, w, h);
dc.DrawRectangle(Brushes.White, null, area);
dc.DrawImage(image, area);
}
RenderOptions.SetBitmapScalingMode(visual, BitmapScalingMode.HighQuality);
var target = new RenderTargetBitmap(w, h, 96, 96, PixelFormats.Pbgra32);
target.Render(visual);
target.Freeze();
return target;
}
private static async Task<SoftwareBitmap> ToSoftwareBitmapAsync(BitmapSource image)
{
var encoder = new PngBitmapEncoder();
encoder.Frames.Add(BitmapFrame.Create(image));
using var png = new MemoryStream();
encoder.Save(png);
png.Position = 0;
using var stream = png.AsRandomAccessStream();
WinBitmapDecoder decoder = await WinBitmapDecoder.CreateAsync(stream);
return await decoder.GetSoftwareBitmapAsync(BitmapPixelFormat.Bgra8, BitmapAlphaMode.Premultiplied);
}
}
+203
View File
@@ -0,0 +1,203 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
namespace Kniip.Editor;
/// <summary>
/// Liniaal langs het canvas met een schaalverdeling in pixels van de afbeelding.
/// Sleep vanuit de liniaal om een hulplijn te maken; de editor regelt het slepen zelf.
/// </summary>
public sealed class RulerControl : FrameworkElement
{
private static readonly double[] Steps = { 1, 2, 5, 10, 20, 25, 50, 100, 200, 250, 500, 1000, 2000, 2500, 5000, 10000 };
private static readonly Typeface LabelFace = new("Segoe UI");
public static readonly DependencyProperty BackgroundProperty = DependencyProperty.Register(
nameof(Background), typeof(Brush), typeof(RulerControl), new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender));
public static readonly DependencyProperty ForegroundProperty = DependencyProperty.Register(
nameof(Foreground), typeof(Brush), typeof(RulerControl), new FrameworkPropertyMetadata(Brushes.Gray, FrameworkPropertyMetadataOptions.AffectsRender));
public static readonly DependencyProperty BorderBrushProperty = DependencyProperty.Register(
nameof(BorderBrush), typeof(Brush), typeof(RulerControl), new FrameworkPropertyMetadata(Brushes.LightGray, FrameworkPropertyMetadataOptions.AffectsRender));
public static readonly DependencyProperty ImageBackgroundProperty = DependencyProperty.Register(
nameof(ImageBackground), typeof(Brush), typeof(RulerControl), new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender));
private double _origin;
private double _scale = 1;
private double _length;
private double? _marker;
private IReadOnlyList<double> _guides = Array.Empty<double>();
public Orientation Orientation { get; set; } = Orientation.Horizontal;
public Brush? Background
{
get => (Brush?)GetValue(BackgroundProperty);
set => SetValue(BackgroundProperty, value);
}
public Brush Foreground
{
get => (Brush)GetValue(ForegroundProperty);
set => SetValue(ForegroundProperty, value);
}
public Brush BorderBrush
{
get => (Brush)GetValue(BorderBrushProperty);
set => SetValue(BorderBrushProperty, value);
}
/// <summary>Achtergrond van het stuk liniaal naast de afbeelding.</summary>
public Brush? ImageBackground
{
get => (Brush?)GetValue(ImageBackgroundProperty);
set => SetValue(ImageBackgroundProperty, value);
}
/// <summary>Plaats van afbeeldingspixel 0 in de liniaal (DIP), de schaal (DIP per pixel) en de lengte van de afbeelding in pixels.</summary>
public void SetView(double origin, double scale, double length)
{
if (Math.Abs(origin - _origin) < 0.01 && Math.Abs(scale - _scale) < 1e-6 && Math.Abs(length - _length) < 0.01)
{
return;
}
_origin = origin;
_scale = scale;
_length = length;
InvalidateVisual();
}
/// <summary>Positie van de muis in afbeeldingspixels, of null als de muis niet boven het canvas is.</summary>
public void SetMarker(double? value)
{
if (_marker == value)
{
return;
}
_marker = value;
InvalidateVisual();
}
public void SetGuides(IReadOnlyList<double> guides)
{
_guides = guides;
InvalidateVisual();
}
/// <summary>Afbeeldingscoördinaat bij een positie (DIP) in de liniaal.</summary>
public double ToImage(double position) => (position - _origin) / _scale;
private bool Horizontal => Orientation == Orientation.Horizontal;
protected override void OnRender(DrawingContext dc)
{
double along = Horizontal ? ActualWidth : ActualHeight;
double across = Horizontal ? ActualHeight : ActualWidth;
if (along <= 0 || across <= 0 || _scale <= 0)
{
return;
}
// Ook transparant tekenen, zodat de hele liniaal muisklikken ontvangt.
dc.DrawRectangle(Background ?? Brushes.Transparent, null, new Rect(0, 0, ActualWidth, ActualHeight));
if (ImageBackground != null && _length > 0)
{
double start = Math.Max(0, _origin), end = Math.Min(along, _origin + _length * _scale);
if (end > start)
{
dc.DrawRectangle(ImageBackground, null, Span(start, end - start, 0, across));
}
}
double dpi = VisualTreeHelper.GetDpi(this).PixelsPerDip;
var tickPen = new Pen(Foreground, 1 / dpi);
tickPen.Freeze();
double major = Steps[^1];
foreach (double step in Steps)
{
if (step * _scale >= 50)
{
major = step;
break;
}
}
double minor = major;
foreach (int divisions in new[] { 10, 5, 2 })
{
if (major % divisions == 0 && major / divisions * _scale >= 5)
{
minor = major / divisions;
break;
}
}
double first = Math.Floor(ToImage(0) / minor) * minor;
double last = ToImage(along);
for (double value = first; value <= last; value += minor)
{
double pos = Snap(_origin + value * _scale, dpi);
bool isMajor = Math.Abs(value % major) < 1e-6;
bool isHalf = !isMajor && Math.Abs(value % (major / 2)) < 1e-6;
double tick = isMajor ? across : isHalf ? across * 0.45 : across * 0.25;
dc.DrawLine(tickPen, Point(pos, across - tick), Point(pos, across));
if (isMajor)
{
DrawLabel(dc, value.ToString(CultureInfo.CurrentCulture), pos, dpi);
}
}
var guidePen = new Pen(new SolidColorBrush(AnnotationSurface.GuideColor), 1 / dpi);
guidePen.Freeze();
foreach (double guide in _guides)
{
double pos = Snap(_origin + guide * _scale, dpi);
dc.DrawLine(guidePen, Point(pos, 0), Point(pos, across));
}
if (_marker is double marker)
{
var markerPen = new Pen((Brush)FindResource("AccentBrush"), 1 / dpi);
markerPen.Freeze();
double pos = Snap(_origin + marker * _scale, dpi);
dc.DrawLine(markerPen, Point(pos, 0), Point(pos, across));
}
var border = new Pen(BorderBrush, 1 / dpi);
border.Freeze();
double edge = across - 0.5 / dpi;
dc.DrawLine(border, Point(0, edge), Point(along, edge));
}
private void DrawLabel(DrawingContext dc, string text, double pos, double dpi)
{
var ft = new FormattedText(text, CultureInfo.CurrentUICulture, FlowDirection.LeftToRight, LabelFace, 9, Foreground, dpi);
if (Horizontal)
{
dc.DrawText(ft, new Point(pos + 3, 1));
return;
}
dc.PushTransform(new RotateTransform(-90, 1, pos - 3));
dc.DrawText(ft, new Point(1, pos - 3));
dc.Pop();
}
private static double Snap(double value, double dpi) => (Math.Round(value * dpi) + 0.5) / dpi;
private Point Point(double along, double across) => Horizontal ? new Point(along, across) : new Point(across, along);
private Rect Span(double along, double alongLength, double across, double acrossLength) =>
Horizontal ? new Rect(along, across, alongLength, acrossLength) : new Rect(across, along, acrossLength, alongLength);
}
+16
View File
@@ -0,0 +1,16 @@
using System;
using System.Globalization;
using System.Windows.Data;
using Kniip.Editor.Annotations;
namespace Kniip.Editor;
/// <summary>Geeft de vectorvorm van een vormsoort, voor het icoon in de vormkeuze en op de werkbalkknop.</summary>
public sealed class ShapeIconConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture) =>
ShapeGeometries.For(value is ShapeKind kind ? kind : ShapeKind.Triangle);
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
throw new NotSupportedException();
}
+16 -3
View File
@@ -16,7 +16,8 @@ public enum ToolKind
Emoji, Emoji,
Highlight, Highlight,
Blur, Blur,
Crop Crop,
Shape
} }
public enum LineDash { Solid, Dash, Dot } public enum LineDash { Solid, Dash, Dot }
@@ -29,6 +30,10 @@ public enum CursorKind { Arrow, Hand, Click }
public enum SkinTone { Default, Light, MediumLight, Medium, MediumDark, Dark } public enum SkinTone { Default, Light, MediumLight, Medium, MediumDark, Dark }
public enum TextShape { Frame, Balloon, Oval, Cloud }
public enum ShapeKind { Triangle, BlockArrow, Star, Cloud, Balloon, Heart, Moon, Drop, Egg, Lightning, Spiral }
[Flags] [Flags]
public enum StyleProps public enum StyleProps
{ {
@@ -46,7 +51,9 @@ public enum StyleProps
CursorKind = 1024, CursorKind = 1024,
Blur = 2048, Blur = 2048,
Straight = 4096, Straight = 4096,
Emoji = 8192 Emoji = 8192,
TextShape = 16384,
ShapeKind = 32768
} }
public sealed class StyleSet public sealed class StyleSet
@@ -65,6 +72,11 @@ public sealed class StyleSet
public BlurMode BlurMode { get; set; } = BlurMode.Pixelate; public BlurMode BlurMode { get; set; } = BlurMode.Pixelate;
public int BlurStrength { get; set; } = 6; public int BlurStrength { get; set; } = 6;
public CursorKind CursorKind { get; set; } = CursorKind.Arrow; public CursorKind CursorKind { get; set; } = CursorKind.Arrow;
public TextShape TextShape { get; set; } = TextShape.Frame;
public ShapeKind ShapeKind { get; set; } = ShapeKind.Triangle;
/// <summary>Aantal rondjes in de staart van een wolk; 0 = automatisch naar de afstand.</summary>
public int CloudBubbles { get; set; }
/// <summary>Unicode-codepunten van de emoji zonder huidskleur, zoals "1f44d".</summary> /// <summary>Unicode-codepunten van de emoji zonder huidskleur, zoals "1f44d".</summary>
public string Emoji { get; set; } = "1f44d"; public string Emoji { get; set; } = "1f44d";
@@ -97,9 +109,10 @@ public sealed class StyleSet
{ {
ToolKind.Rectangle => StyleProps.Stroke | StyleProps.Fill | StyleProps.Thickness | StyleProps.Dash | StyleProps.Corner | StyleProps.Shadow, ToolKind.Rectangle => StyleProps.Stroke | StyleProps.Fill | StyleProps.Thickness | StyleProps.Dash | StyleProps.Corner | StyleProps.Shadow,
ToolKind.Ellipse => StyleProps.Stroke | StyleProps.Fill | StyleProps.Thickness | StyleProps.Dash | StyleProps.Shadow, ToolKind.Ellipse => StyleProps.Stroke | StyleProps.Fill | StyleProps.Thickness | StyleProps.Dash | StyleProps.Shadow,
ToolKind.Shape => StyleProps.Stroke | StyleProps.Fill | StyleProps.Thickness | StyleProps.Dash | StyleProps.Shadow | StyleProps.ShapeKind,
ToolKind.Line or ToolKind.Arrow => StyleProps.Stroke | StyleProps.Thickness | StyleProps.Dash | StyleProps.Shadow, ToolKind.Line or ToolKind.Arrow => StyleProps.Stroke | StyleProps.Thickness | StyleProps.Dash | StyleProps.Shadow,
ToolKind.Text => StyleProps.Stroke | StyleProps.Fill | StyleProps.Foreground | StyleProps.Thickness | StyleProps.Dash ToolKind.Text => StyleProps.Stroke | StyleProps.Fill | StyleProps.Foreground | StyleProps.Thickness | StyleProps.Dash
| StyleProps.Corner | StyleProps.Shadow | StyleProps.Font, | StyleProps.Corner | StyleProps.Shadow | StyleProps.Font | StyleProps.TextShape,
ToolKind.Step => StyleProps.Fill | StyleProps.Foreground | StyleProps.Numbering | StyleProps.Size | StyleProps.Shadow, ToolKind.Step => StyleProps.Fill | StyleProps.Foreground | StyleProps.Numbering | StyleProps.Size | StyleProps.Shadow,
ToolKind.Cursor => StyleProps.CursorKind | StyleProps.Size | StyleProps.Shadow, ToolKind.Cursor => StyleProps.CursorKind | StyleProps.Size | StyleProps.Shadow,
ToolKind.Emoji => StyleProps.Emoji | StyleProps.Size | StyleProps.Shadow, ToolKind.Emoji => StyleProps.Emoji | StyleProps.Size | StyleProps.Shadow,
+9 -1
View File
@@ -477,6 +477,14 @@ public sealed class ToolbarSeparator : FrameworkElement
{ {
private const double Thickness = 13; private const double Thickness = 13;
private static readonly DependencyProperty LineBrushProperty = DependencyProperty.Register(
nameof(LineBrush), typeof(Brush), typeof(ToolbarSeparator),
new FrameworkPropertyMetadata(Brushes.LightGray, FrameworkPropertyMetadataOptions.AffectsRender));
public ToolbarSeparator() => SetResourceReference(LineBrushProperty, "DividerBrush");
private Brush LineBrush => (Brush)GetValue(LineBrushProperty);
private bool Vertical => VisualTreeHelper.GetParent(this) is ToolbarPanel { Orientation: Orientation.Vertical }; private bool Vertical => VisualTreeHelper.GetParent(this) is ToolbarPanel { Orientation: Orientation.Vertical };
protected override Size MeasureOverride(Size availableSize) => protected override Size MeasureOverride(Size availableSize) =>
@@ -489,7 +497,7 @@ public sealed class ToolbarSeparator : FrameworkElement
return; return;
} }
Brush brush = TryFindResource("DividerBrush") as Brush ?? Brushes.LightGray; Brush brush = LineBrush;
Rect line = Vertical Rect line = Vertical
? new Rect(6, Math.Floor(RenderSize.Height / 2), Math.Max(0, RenderSize.Width - 12), 1) ? new Rect(6, Math.Floor(RenderSize.Height / 2), Math.Max(0, RenderSize.Width - 12), 1)
: new Rect(Math.Floor(RenderSize.Width / 2), 6, 1, Math.Max(0, RenderSize.Height - 12)); : new Rect(Math.Floor(RenderSize.Width / 2), 6, 1, Math.Max(0, RenderSize.Height - 12));
+102
View File
@@ -0,0 +1,102 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Windows;
using Kniip.Editor;
using Kniip.Infrastructure;
namespace Kniip.History;
/// <param name="Glyph">Teken uit Segoe Fluent Icons.</param>
public sealed record HistoryCommand(string Label, string Glyph, bool Enabled, Action Run);
/// <summary>Wat je met een item uit de geschiedenis kunt doen, gedeeld door het systeemvakmenu en het geschiedenisvenster.</summary>
public static class HistoryActions
{
/// <summary>De acties voor een item; null staat voor een scheidingslijn.</summary>
public static IReadOnlyList<HistoryCommand?> CommandsFor(HistoryEntry entry)
{
bool file = entry.FileExists;
bool url = entry.Url is { Length: > 0 };
bool image = file && entry.Kind == HistoryKind.Snip;
return new HistoryCommand?[]
{
new("Openen", "\uE8E5", file || url, () => Open(entry)),
new("Openen in de editor", "\uE70F", image, () => App.Instance.OpenFile(entry.FilePath!)),
new("Link openen", "\uE774", url, () => Launch(entry.Url!)),
null,
new("Link kopiëren", "\uE71B", url, () => Copy(entry.Url!)),
new("Afbeelding kopiëren", "\uE8C8", image, () => CopyImage(entry.FilePath!)),
new("Pad kopiëren", "\uE8C8", file, () => Copy(entry.FilePath!)),
new("Tonen in map", "\uE838", file, () => ShowInFolder(entry.FilePath!))
};
}
/// <summary>Bestand in het standaardprogramma, anders de link in de browser.</summary>
public static void Open(HistoryEntry entry)
{
if (entry.FileExists)
{
Launch(entry.FilePath!);
}
else if (entry.Url is { Length: > 0 } url)
{
Launch(url);
}
else
{
MessageBox.Show($"Het bestand bestaat niet meer:\n{entry.FilePath}", "kniip", MessageBoxButton.OK, MessageBoxImage.Information);
}
}
public static void OpenFolder(string folder)
{
Directory.CreateDirectory(folder);
Launch(folder);
}
private static void ShowInFolder(string path) =>
Run(() => Process.Start("explorer.exe", $"/select,\"{path}\""));
private static void Launch(string target) =>
Run(() => Process.Start(new ProcessStartInfo(target) { UseShellExecute = true }));
private static void Copy(string text)
{
if (!ClipboardWriter.SetText(text))
{
ClipboardBusy();
}
}
private static void CopyImage(string path)
{
try
{
if (!ClipboardWriter.SetImage(PixelUtil.LoadFile(path), path))
{
ClipboardBusy();
}
}
catch (Exception ex)
{
MessageBox.Show($"Kan '{Path.GetFileName(path)}' niet kopiëren.\n\n{ex.Message}", "kniip", MessageBoxButton.OK, MessageBoxImage.Warning);
}
}
private static void ClipboardBusy() =>
MessageBox.Show("Kopiëren is mislukt: het klembord is bezet. Probeer het opnieuw.", "kniip", MessageBoxButton.OK, MessageBoxImage.Warning);
private static void Run(Action action)
{
try
{
action();
}
catch (Exception ex)
{
MessageBox.Show($"Openen is mislukt.\n\n{ex.Message}", "kniip", MessageBoxButton.OK, MessageBoxImage.Warning);
}
}
}
+47
View File
@@ -0,0 +1,47 @@
using System;
using System.IO;
using System.Text.Json.Serialization;
namespace Kniip.History;
public enum HistoryKind
{
Snip,
Mp4,
Gif
}
public sealed class HistoryEntry
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
public DateTime Time { get; set; } = DateTime.Now;
public HistoryKind Kind { get; set; }
/// <summary>Opgeslagen bestand; leeg als het alleen is geüpload.</summary>
public string? FilePath { get; set; }
/// <summary>Link na het uploaden; leeg als het alleen is opgeslagen.</summary>
public string? Url { get; set; }
/// <summary>Bestandsnaam van de miniatuur in <see cref="HistoryStore.ThumbnailFolder"/>.</summary>
public string? Thumbnail { get; set; }
public int Width { get; set; }
public int Height { get; set; }
[JsonIgnore]
public string Name => FilePath is { Length: > 0 }
? Path.GetFileName(FilePath)
: Url is { Length: > 0 } ? Url[(Url.TrimEnd('/').LastIndexOf('/') + 1)..] : "";
[JsonIgnore]
public string KindText => Kind switch
{
HistoryKind.Mp4 => "Screencast (MP4)",
HistoryKind.Gif => "Screencast (GIF)",
_ => "Knipsel"
};
[JsonIgnore]
public bool FileExists => FilePath is { Length: > 0 } && File.Exists(FilePath);
}
+169
View File
@@ -0,0 +1,169 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using Kniip.Infrastructure;
using Kniip.Settings;
namespace Kniip.History;
/// <summary>Wat er in kniip is opgeslagen of geüpload, nieuwste eerst.</summary>
public static class HistoryStore
{
private const int MaxEntries = 500;
private const int ThumbnailSize = 240;
private static readonly JsonSerializerOptions Options = new()
{
Converters = { new JsonStringEnumConverter() }
};
private static List<HistoryEntry>? _entries;
private static string FilePath => Path.Combine(SettingsStore.Folder, "history.json");
public static string ThumbnailFolder => Path.Combine(SettingsStore.Folder, "history");
public static event Action? Changed;
public static IReadOnlyList<HistoryEntry> Entries => Load();
public static void Add(HistoryKind kind, string? filePath, string? url, BitmapSource? preview)
{
var entry = new HistoryEntry { Kind = kind, FilePath = filePath, Url = url };
if (preview != null)
{
entry.Width = preview.PixelWidth;
entry.Height = preview.PixelHeight;
entry.Thumbnail = SaveThumbnail(entry.Id, preview);
}
List<HistoryEntry> entries = Load();
entries.Insert(0, entry);
while (entries.Count > MaxEntries)
{
DeleteThumbnail(entries[^1]);
entries.RemoveAt(entries.Count - 1);
}
Save();
}
public static void Remove(HistoryEntry entry)
{
if (Load().Remove(entry))
{
DeleteThumbnail(entry);
Save();
}
}
public static void Clear()
{
foreach (HistoryEntry entry in Load())
{
DeleteThumbnail(entry);
}
Load().Clear();
Save();
}
public static string? ThumbnailPath(HistoryEntry entry) =>
entry.Thumbnail is { Length: > 0 } name ? Path.Combine(ThumbnailFolder, name) : null;
/// <summary>Eerste beeld van een afbeelding of GIF, of null als dat niet lukt.</summary>
public static BitmapSource? PreviewOf(string file)
{
try
{
return PixelUtil.LoadFile(file);
}
catch (Exception)
{
return null;
}
}
private static List<HistoryEntry> Load()
{
if (_entries != null)
{
return _entries;
}
try
{
if (File.Exists(FilePath))
{
_entries = JsonSerializer.Deserialize<List<HistoryEntry>>(File.ReadAllText(FilePath), Options);
}
}
catch (Exception)
{
// Beschadigd bestand: met een lege geschiedenis verder.
}
_entries ??= new List<HistoryEntry>();
_entries.RemoveAll(entry => entry == null!);
return _entries;
}
private static void Save()
{
try
{
Directory.CreateDirectory(SettingsStore.Folder);
File.WriteAllText(FilePath, JsonSerializer.Serialize(Load(), Options));
}
catch (Exception)
{
// De geschiedenis bijwerken mag opslaan of uploaden nooit laten mislukken.
}
Changed?.Invoke();
}
private static string? SaveThumbnail(string id, BitmapSource image)
{
try
{
double scale = Math.Min(1.0, (double)ThumbnailSize / Math.Max(image.PixelWidth, image.PixelHeight));
BitmapSource thumb = scale < 1 ? new TransformedBitmap(image, new ScaleTransform(scale, scale)) : image;
var encoder = new PngBitmapEncoder();
encoder.Frames.Add(BitmapFrame.Create(thumb));
Directory.CreateDirectory(ThumbnailFolder);
string name = id + ".png";
using var stream = new FileStream(Path.Combine(ThumbnailFolder, name), FileMode.Create, FileAccess.Write);
encoder.Save(stream);
return name;
}
catch (Exception)
{
return null;
}
}
private static void DeleteThumbnail(HistoryEntry entry)
{
if (ThumbnailPath(entry) is not { } path)
{
return;
}
try
{
File.Delete(path);
}
catch (Exception)
{
// Blijft staan; een losse miniatuur kan geen kwaad.
}
}
public static IEnumerable<HistoryEntry> Recent(int count) => Load().Take(count);
}
+136
View File
@@ -0,0 +1,136 @@
<Window x:Class="Kniip.History.HistoryWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="kniip geschiedenis" Width="980" Height="620" MinWidth="560" MinHeight="360"
WindowStartupLocation="CenterScreen" Background="{DynamicResource WindowBrush}" Foreground="{DynamicResource InkBrush}"
FontFamily="Segoe UI" FontSize="13" Icon="pack://application:,,,/AppIcon.ico">
<Window.Resources>
<Style x:Key="ViewToggle" TargetType="RadioButton" BasedOn="{StaticResource ToolToggle}">
<Setter Property="Padding" Value="10,4" />
</Style>
<Style x:Key="RowStyle" TargetType="ListViewItem">
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<EventSetter Event="MouseDoubleClick" Handler="Item_DoubleClick" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsAvailable}" Value="False">
<Setter Property="Opacity" Value="0.5" />
</DataTrigger>
</Style.Triggers>
</Style>
<Style x:Key="TileStyle" TargetType="ListBoxItem">
<Setter Property="Padding" Value="0" />
<Setter Property="Margin" Value="4" />
<EventSetter Event="MouseDoubleClick" Handler="Item_DoubleClick" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<Border x:Name="Bd" Background="{DynamicResource CardBrush}" BorderBrush="{DynamicResource ControlBorderBrush}" BorderThickness="1" CornerRadius="6" Padding="6">
<ContentPresenter />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource ControlHoverBrush}" />
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource SelectedBrush}" />
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource AccentBrush}" />
</Trigger>
<DataTrigger Binding="{Binding IsAvailable}" Value="False">
<Setter Property="Opacity" Value="0.5" />
</DataTrigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<DataTemplate x:Key="SmallThumb">
<Grid Width="40" Height="28">
<Image Source="{Binding Thumbnail}" Stretch="Uniform" RenderOptions.BitmapScalingMode="HighQuality" />
<TextBlock Text="&#xE714;" Style="{StaticResource IconText}" Foreground="{DynamicResource SubtleTextBrush}"
Visibility="{Binding NoThumbnailVisibility}" />
</Grid>
</DataTemplate>
</Window.Resources>
<DockPanel Margin="12">
<DockPanel DockPanel.Dock="Top" Margin="0,0,0,10" LastChildFill="False">
<RadioButton x:Name="ListToggle" Style="{StaticResource ViewToggle}" GroupName="View" Checked="View_Checked">
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#xE8FD;" Style="{StaticResource IconText}" Margin="0,0,6,0" />
<TextBlock Text="Lijst" VerticalAlignment="Center" />
</StackPanel>
</RadioButton>
<RadioButton x:Name="GridToggle" Style="{StaticResource ViewToggle}" GroupName="View" Checked="View_Checked">
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#xE91B;" Style="{StaticResource IconText}" Margin="0,0,6,0" />
<TextBlock Text="Miniaturen" VerticalAlignment="Center" />
</StackPanel>
</RadioButton>
<Grid DockPanel.Dock="Right" Width="260" VerticalAlignment="Center">
<TextBox x:Name="SearchBox" Height="30" Padding="26,0,6,0" VerticalContentAlignment="Center"
TextChanged="SearchBox_TextChanged" />
<TextBlock Text="&#xE721;" Style="{StaticResource IconText}" FontSize="13" HorizontalAlignment="Left" Margin="8,0,0,0"
Foreground="{DynamicResource SubtleTextBrush}" IsHitTestVisible="False" />
<TextBlock x:Name="SearchHint" Text="Zoeken op naam of link" Margin="27,0,0,0" VerticalAlignment="Center"
Foreground="{DynamicResource SubtleTextBrush}" IsHitTestVisible="False" />
</Grid>
</DockPanel>
<DockPanel DockPanel.Dock="Bottom" Margin="0,10,0,0" LastChildFill="False">
<TextBlock x:Name="CountText" VerticalAlignment="Center" Foreground="{DynamicResource SubtleTextBrush}" />
<Button DockPanel.Dock="Right" Content="Sluiten" Style="{StaticResource SecondaryButton}" IsCancel="True" Click="Close_Click" />
<Button x:Name="ClearButton" DockPanel.Dock="Right" Content="Geschiedenis wissen..." Style="{StaticResource SecondaryButton}"
Click="Clear_Click" />
</DockPanel>
<Grid>
<ListView x:Name="ListItems" ItemContainerStyle="{StaticResource RowStyle}" SelectionMode="Single"
Background="{DynamicResource CardBrush}" Foreground="{DynamicResource InkBrush}"
BorderBrush="{DynamicResource ControlBorderBrush}" ContextMenuOpening="Items_ContextMenuOpening">
<ListView.ContextMenu>
<ContextMenu />
</ListView.ContextMenu>
<ListView.View>
<GridView>
<GridViewColumn Width="56" CellTemplate="{StaticResource SmallThumb}" />
<GridViewColumn Header="Datum" Width="140" DisplayMemberBinding="{Binding TimeText}" />
<GridViewColumn Header="Soort" Width="120" DisplayMemberBinding="{Binding Entry.KindText}" />
<GridViewColumn Header="Naam" Width="200" DisplayMemberBinding="{Binding Entry.Name}" />
<GridViewColumn Header="Map" Width="200" DisplayMemberBinding="{Binding Folder}" />
<GridViewColumn Header="Link" Width="220" DisplayMemberBinding="{Binding Entry.Url}" />
</GridView>
</ListView.View>
</ListView>
<ListBox x:Name="GridItems" ItemContainerStyle="{StaticResource TileStyle}" SelectionMode="Single" Visibility="Collapsed"
Background="Transparent" BorderThickness="0" ScrollViewer.HorizontalScrollBarVisibility="Disabled"
ContextMenuOpening="Items_ContextMenuOpening">
<ListBox.ContextMenu>
<ContextMenu />
</ListBox.ContextMenu>
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel />
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel Width="180" ToolTip="{Binding ToolTip}">
<Grid Height="120" Background="{DynamicResource WindowBrush}">
<Image Source="{Binding Thumbnail}" Stretch="Uniform" RenderOptions.BitmapScalingMode="HighQuality" />
<TextBlock Text="&#xE714;" Style="{StaticResource IconText}" FontSize="32"
Foreground="{DynamicResource SubtleTextBrush}" Visibility="{Binding NoThumbnailVisibility}" />
</Grid>
<TextBlock Text="{Binding Entry.Name}" Margin="0,6,0,0" TextTrimming="CharacterEllipsis" />
<TextBlock Text="{Binding TimeText}" FontSize="12" Foreground="{DynamicResource SubtleTextBrush}" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock x:Name="EmptyText" HorizontalAlignment="Center" VerticalAlignment="Center" TextAlignment="Center"
Foreground="{DynamicResource SubtleTextBrush}" Visibility="Collapsed"
Text="Nog niets in de geschiedenis.&#x0a;Wat je in kniip opslaat of uploadt, komt hier te staan." />
</Grid>
</DockPanel>
</Window>
+174
View File
@@ -0,0 +1,174 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.IO;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
namespace Kniip.History;
public partial class HistoryWindow : Window
{
private List<HistoryRow> _rows = new();
public HistoryWindow(bool images)
{
InitializeComponent();
Infrastructure.ThemeManager.Attach(this);
Reload();
ShowImages(images);
HistoryStore.Changed += Reload;
Closed += (_, _) => HistoryStore.Changed -= Reload;
}
public void ShowImages(bool images)
{
(images ? GridToggle : ListToggle).IsChecked = true;
}
private void Reload()
{
_rows = HistoryStore.Entries.Select(entry => new HistoryRow(entry)).ToList();
ListItems.ItemsSource = _rows;
GridItems.ItemsSource = _rows;
ApplyFilter();
}
private void ApplyFilter()
{
string query = SearchBox.Text.Trim();
ICollectionView view = System.Windows.Data.CollectionViewSource.GetDefaultView(_rows);
view.Filter = query.Length == 0 ? null : item => ((HistoryRow)item).Matches(query);
int shown = view.Cast<object>().Count();
CountText.Text = query.Length == 0 ? $"{_rows.Count} items" : $"{shown} van {_rows.Count} items";
EmptyText.Visibility = _rows.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
ClearButton.IsEnabled = _rows.Count > 0;
}
private void View_Checked(object sender, RoutedEventArgs e)
{
bool images = GridToggle.IsChecked == true;
GridItems.Visibility = images ? Visibility.Visible : Visibility.Collapsed;
ListItems.Visibility = images ? Visibility.Collapsed : Visibility.Visible;
Title = images ? "kniip afbeeldingsgeschiedenis" : "kniip geschiedenis";
}
private void SearchBox_TextChanged(object sender, TextChangedEventArgs e)
{
SearchHint.Visibility = SearchBox.Text.Length == 0 ? Visibility.Visible : Visibility.Collapsed;
ApplyFilter();
}
private void Item_DoubleClick(object sender, MouseButtonEventArgs e)
{
if (sender is FrameworkElement { DataContext: HistoryRow row })
{
HistoryActions.Open(row.Entry);
}
}
private void Items_ContextMenuOpening(object sender, ContextMenuEventArgs e)
{
var list = (Selector)sender;
if (e.OriginalSource is DependencyObject source
&& ItemsControl.ContainerFromElement(list, source) is FrameworkElement { DataContext: HistoryRow clicked })
{
list.SelectedItem = clicked;
}
if (list.SelectedItem is not HistoryRow row || list.ContextMenu is not { } menu)
{
e.Handled = true;
return;
}
menu.Items.Clear();
foreach (HistoryCommand? command in HistoryActions.CommandsFor(row.Entry))
{
menu.Items.Add(command == null ? new Separator() : MenuItemFor(command.Label, command.Glyph, command.Enabled, command.Run));
}
menu.Items.Add(new Separator());
menu.Items.Add(MenuItemFor("Uit geschiedenis verwijderen", "\uE74D", true, () => HistoryStore.Remove(row.Entry)));
}
private static MenuItem MenuItemFor(string label, string glyph, bool enabled, Action run)
{
var item = new MenuItem
{
Header = label,
IsEnabled = enabled,
Icon = new TextBlock { Text = glyph, Style = (Style)Application.Current.FindResource("IconText"), FontSize = 14 }
};
item.Click += (_, _) => run();
return item;
}
private void Clear_Click(object sender, RoutedEventArgs e)
{
if (MessageBox.Show(this, "De hele geschiedenis wissen? Je opgeslagen bestanden en uploads blijven gewoon bestaan.", "kniip",
MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No) == MessageBoxResult.Yes)
{
HistoryStore.Clear();
}
}
private void Close_Click(object sender, RoutedEventArgs e) => Close();
private sealed class HistoryRow
{
public HistoryRow(HistoryEntry entry)
{
Entry = entry;
IsAvailable = entry.FileExists || entry.Url is { Length: > 0 };
Thumbnail = LoadThumbnail(entry);
}
public HistoryEntry Entry { get; }
public bool IsAvailable { get; }
public ImageSource? Thumbnail { get; }
public Visibility NoThumbnailVisibility => Thumbnail == null ? Visibility.Visible : Visibility.Collapsed;
public string TimeText => Entry.Time.ToString("dd-MM-yyyy HH:mm:ss");
public string? Folder => Entry.FilePath is { Length: > 0 } path ? Path.GetDirectoryName(path) : null;
public string ToolTip => string.Join("\n", new[]
{
Entry.KindText + (Entry.Width > 0 ? $", {Entry.Width} x {Entry.Height}" : ""),
Entry.FilePath,
Entry.Url
}.Where(line => !string.IsNullOrEmpty(line)));
public bool Matches(string query) =>
Entry.Name.Contains(query, StringComparison.OrdinalIgnoreCase)
|| (Entry.FilePath?.Contains(query, StringComparison.OrdinalIgnoreCase) ?? false)
|| (Entry.Url?.Contains(query, StringComparison.OrdinalIgnoreCase) ?? false);
private static ImageSource? LoadThumbnail(HistoryEntry entry)
{
if (HistoryStore.ThumbnailPath(entry) is not { } path || !File.Exists(path))
{
return null;
}
try
{
var image = new BitmapImage();
image.BeginInit();
image.CacheOption = BitmapCacheOption.OnLoad;
image.StreamSource = new MemoryStream(File.ReadAllBytes(path));
image.EndInit();
image.Freeze();
return image;
}
catch (Exception)
{
return null;
}
}
}
}
+19
View File
@@ -0,0 +1,19 @@
namespace Kniip.Infrastructure;
/// <summary>
/// Namen waaronder kniip zich in Windows inschrijft. De testbuild (<c>-p:KniipTest=true</c>) gebruikt eigen
/// namen voor register, instellingen, mutex en tijdelijke bestanden, zodat hij naast de geïnstalleerde
/// kniip kan draaien en die nooit overschrijft.
/// </summary>
internal static class AppIdentity
{
#if KNIIP_TEST
public static bool IsTest => true;
public const string Key = "KniipTest";
public const string DisplayName = "kniip test";
#else
public static bool IsTest => false;
public const string Key = "Kniip";
public const string DisplayName = "kniip";
#endif
}
+66
View File
@@ -29,6 +29,72 @@ internal static class PixelUtil
return pixels; return pixels;
} }
/// <summary>Zet een afbeelding om naar Bgra32 en behoudt de transparantie.</summary>
public static byte[] ToBgra(BitmapSource source)
{
BitmapSource converted = source.Format == PixelFormats.Bgra32
? source
: new FormatConvertedBitmap(source, PixelFormats.Bgra32, null, 0);
int stride = converted.PixelWidth * 4;
var pixels = new byte[stride * converted.PixelHeight];
converted.CopyPixels(pixels, stride, 0);
return pixels;
}
public static bool HasTransparency(byte[] bgra)
{
for (int i = 3; i < bgra.Length; i += 4)
{
if (bgra[i] != 255)
{
return true;
}
}
return false;
}
/// <summary>
/// Een afbeelding waarvan alle pixels alpha 0 hebben, is vrijwel altijd een DIB zonder echte alpha.
/// </summary>
public static bool IsFullyTransparent(BitmapSource source)
{
if (!source.Format.Equals(PixelFormats.Bgra32) && !source.Format.Equals(PixelFormats.Pbgra32))
{
return false;
}
byte[] pixels = ToBgra(source);
for (int i = 3; i < pixels.Length; i += 4)
{
if (pixels[i] != 0)
{
return false;
}
}
return true;
}
/// <summary>Transparante delen wit maken, voor formaten en programma's zonder alpha (JPEG, bitmap op het klembord).</summary>
public static BitmapSource OnWhite(BitmapSource image)
{
var visual = new DrawingVisual();
using (DrawingContext dc = visual.RenderOpen())
{
var area = new System.Windows.Rect(0, 0, image.PixelWidth, image.PixelHeight);
dc.DrawRectangle(Brushes.White, null, area);
dc.DrawImage(image, area);
}
var bitmap = new RenderTargetBitmap(image.PixelWidth, image.PixelHeight, 96, 96, PixelFormats.Pbgra32);
bitmap.Render(visual);
var opaque = new FormatConvertedBitmap(bitmap, PixelFormats.Bgr32, null, 0);
opaque.Freeze();
return opaque;
}
public static BitmapSource CreateBitmap(byte[] pixels, int width, int height) public static BitmapSource CreateBitmap(byte[] pixels, int width, int height)
{ {
var bitmap = BitmapSource.Create(width, height, 96, 96, PixelFormats.Bgra32, null, pixels, width * 4); var bitmap = BitmapSource.Create(width, height, 96, 96, PixelFormats.Bgra32, null, pixels, width * 4);
+11 -6
View File
@@ -9,10 +9,10 @@ namespace Kniip.Infrastructure;
/// </summary> /// </summary>
internal static class ShellIntegration internal static class ShellIntegration
{ {
private const string ProgId = "Kniip.Image"; private const string ProgId = AppIdentity.Key + ".Image";
private const string AppKeyName = "Kniip.exe"; private const string AppKeyName = AppIdentity.Key + ".exe";
private const string RunKeyPath = @"Software\Microsoft\Windows\CurrentVersion\Run"; private const string RunKeyPath = @"Software\Microsoft\Windows\CurrentVersion\Run";
private const string RunValueName = "Kniip"; private const string RunValueName = AppIdentity.Key;
public static readonly string[] ImageExtensions = { ".png", ".jpg", ".jpeg", ".bmp", ".gif" }; public static readonly string[] ImageExtensions = { ".png", ".jpg", ".jpeg", ".bmp", ".gif" };
@@ -32,8 +32,8 @@ internal static class ShellIntegration
{ {
using (var prog = Registry.CurrentUser.CreateSubKey($@"Software\Classes\{ProgId}")) using (var prog = Registry.CurrentUser.CreateSubKey($@"Software\Classes\{ProgId}"))
{ {
prog.SetValue(null, "kniip afbeelding"); prog.SetValue(null, $"{AppIdentity.DisplayName} afbeelding");
prog.SetValue("FriendlyTypeName", "kniip afbeelding"); prog.SetValue("FriendlyTypeName", $"{AppIdentity.DisplayName} afbeelding");
} }
using (var icon = Registry.CurrentUser.CreateSubKey($@"Software\Classes\{ProgId}\DefaultIcon")) using (var icon = Registry.CurrentUser.CreateSubKey($@"Software\Classes\{ProgId}\DefaultIcon"))
{ {
@@ -46,7 +46,7 @@ internal static class ShellIntegration
using (var app = Registry.CurrentUser.CreateSubKey($@"Software\Classes\Applications\{AppKeyName}")) using (var app = Registry.CurrentUser.CreateSubKey($@"Software\Classes\Applications\{AppKeyName}"))
{ {
app.SetValue("FriendlyAppName", "kniip editor"); app.SetValue("FriendlyAppName", $"{AppIdentity.DisplayName} editor");
} }
using (var appCmd = Registry.CurrentUser.CreateSubKey($@"Software\Classes\Applications\{AppKeyName}\shell\open\command")) using (var appCmd = Registry.CurrentUser.CreateSubKey($@"Software\Classes\Applications\{AppKeyName}\shell\open\command"))
{ {
@@ -89,6 +89,11 @@ internal static class ShellIntegration
/// </summary> /// </summary>
public static void MigrateLegacyRegistration() public static void MigrateLegacyRegistration()
{ {
if (AppIdentity.IsTest)
{
return;
}
const string legacyProgId = "KnipPlus.Image"; const string legacyProgId = "KnipPlus.Image";
const string legacyAppKeyName = "KnipPlus.exe"; const string legacyAppKeyName = "KnipPlus.exe";
const string legacyRunValueName = "KnipPlus"; const string legacyRunValueName = "KnipPlus";
+1 -1
View File
@@ -13,7 +13,7 @@ namespace Kniip.Infrastructure;
/// </summary> /// </summary>
internal sealed class SingleInstance : IDisposable internal sealed class SingleInstance : IDisposable
{ {
private static readonly string BaseName = "Kniip-" + Environment.UserName; private static readonly string BaseName = AppIdentity.Key + "-" + Environment.UserName;
private static readonly string PipeName = BaseName + "-pipe"; private static readonly string PipeName = BaseName + "-pipe";
private readonly CancellationTokenSource _cts = new(); private readonly CancellationTokenSource _cts = new();
+121
View File
@@ -0,0 +1,121 @@
using System;
using System.Windows;
using System.Windows.Interop;
using Kniip.Interop;
using Kniip.Settings;
using Microsoft.Win32;
namespace Kniip.Infrastructure;
/// <summary>Wisselt de kleuren van alle vensters tussen licht en donker (Themes/Light.xaml en Themes/Dark.xaml).</summary>
internal static class ThemeManager
{
private const string PersonalizeKey = @"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize";
private static readonly Uri LightUri = new("pack://application:,,,/Themes/Light.xaml");
private static readonly Uri DarkUri = new("pack://application:,,,/Themes/Dark.xaml");
private static ResourceDictionary? _light;
private static ResourceDictionary? _dark;
private static ColorMode _mode;
private static bool _listening;
public static bool IsDark { get; private set; }
/// <summary>Na elke wissel tussen licht en donker.</summary>
public static event Action? Changed;
public static void Apply(ColorMode mode)
{
_mode = mode;
if (mode == ColorMode.System && !_listening)
{
_listening = true;
SystemEvents.UserPreferenceChanged += OnUserPreferenceChanged;
}
Refresh();
}
/// <summary>Geeft het venster een titelbalk in de huidige kleurmodus, ook na een latere wissel.</summary>
public static void Attach(Window window) =>
window.SourceInitialized += (_, _) => NativeMethods.SetDarkTitleBar(new WindowInteropHelper(window).Handle, IsDark);
private static void Refresh()
{
bool dark = _mode switch
{
ColorMode.Dark => true,
ColorMode.System => IsSystemDark(),
_ => false
};
if (dark == IsDark && _light != null)
{
return;
}
IsDark = dark;
_light ??= new ResourceDictionary { Source = LightUri };
_dark ??= new ResourceDictionary { Source = DarkUri };
var merged = Application.Current.Resources.MergedDictionaries;
int index = -1;
for (int i = 0; i < merged.Count; i++)
{
if (merged[i].Source is { } source && (source.OriginalString.EndsWith("Light.xaml", StringComparison.OrdinalIgnoreCase)
|| source.OriginalString.EndsWith("Dark.xaml", StringComparison.OrdinalIgnoreCase)))
{
index = i;
break;
}
}
ResourceDictionary theme = dark ? _dark : _light;
if (index < 0)
{
merged.Insert(0, theme);
}
else if (merged[index] != theme)
{
merged[index] = theme;
}
foreach (Window window in Application.Current.Windows)
{
IntPtr hwnd = new WindowInteropHelper(window).Handle;
if (hwnd != IntPtr.Zero && window.WindowStyle != WindowStyle.None)
{
NativeMethods.SetDarkTitleBar(hwnd, dark);
}
}
Changed?.Invoke();
}
private static void OnUserPreferenceChanged(object? sender, UserPreferenceChangedEventArgs e)
{
if (e.Category == UserPreferenceCategory.General)
{
Application.Current?.Dispatcher.BeginInvoke(() =>
{
if (_mode == ColorMode.System)
{
Refresh();
}
});
}
}
private static bool IsSystemDark()
{
try
{
using RegistryKey? key = Registry.CurrentUser.OpenSubKey(PersonalizeKey);
return key?.GetValue("AppsUseLightTheme") is int light && light == 0;
}
catch (Exception)
{
return false;
}
}
}
+307 -10
View File
@@ -1,6 +1,10 @@
using System; using System;
using System.Collections.Generic;
using System.Drawing; using System.Drawing;
using System.IO;
using System.Linq;
using System.Windows.Forms; using System.Windows.Forms;
using Kniip.History;
namespace Kniip.Infrastructure; namespace Kniip.Infrastructure;
@@ -9,15 +13,30 @@ internal sealed class TrayIcon : IDisposable
private readonly NotifyIcon _icon; private readonly NotifyIcon _icon;
private const string RecordText = "Screencast opnemen (MP4)..."; private const string RecordText = "Screencast opnemen (MP4)...";
private readonly ToolStripMenuItem _clipboardItem;
private readonly ToolStripMenuItem _pauseItem; private readonly ToolStripMenuItem _pauseItem;
private readonly ToolStripMenuItem _snipItem; private readonly ToolStripMenuItem _snipItem;
private readonly ToolStripMenuItem _windowItem; private readonly ToolStripMenuItem _windowItem;
private readonly ToolStripMenuItem _ocrItem;
private readonly ToolStripMenuItem _recordItem; private readonly ToolStripMenuItem _recordItem;
private readonly ToolStripMenuItem _gifItem; private readonly ToolStripMenuItem _gifItem;
private readonly ToolStripMenuItem _foldersItem;
private readonly ToolStripMenuItem _recentItem;
private readonly ToolStripMenuItem _delayItem;
private Panel _delayPanel = null!;
private TrackBar _delaySlider = null!;
private NumericUpDown _delayNumber = null!;
private Label _delayUnit = null!;
private int _delaySeconds;
private int _savedDelaySeconds;
/// <summary>Nieuwe vertraging in seconden, afgevuurd bij het sluiten van het submenu als de waarde is gewijzigd.</summary>
public event Action<int>? CaptureDelayChanged;
public event Action? OpenFromClipboardRequested; public event Action? OpenFromClipboardRequested;
public event Action? SnipRequested; public event Action? SnipRequested;
public event Action? WindowSnipRequested; public event Action? WindowSnipRequested;
public event Action? TextRecognitionRequested;
public event Action? RecordRequested; public event Action? RecordRequested;
public event Action? GifRequested; public event Action? GifRequested;
public event Action? OpenFileRequested; public event Action? OpenFileRequested;
@@ -25,26 +44,51 @@ internal sealed class TrayIcon : IDisposable
public event Action? ExitRequested; public event Action? ExitRequested;
public event Action<bool>? PauseChanged; public event Action<bool>? PauseChanged;
/// <summary>Geschiedenisvenster openen; true = met miniaturen.</summary>
public event Action<bool>? HistoryRequested;
/// <summary>Mappen voor het submenu Map openen, opgevraagd bij het openen; een pad van null staat grijs.</summary>
public Func<IReadOnlyList<(string Label, string? Path)>>? FoldersProvider { get; set; }
public TrayIcon() public TrayIcon()
{ {
var menu = new ContextMenuStrip(); var menu = new ContextMenuStrip();
var openClipboard = new ToolStripMenuItem("Bewerken vanaf klembord", null, (_, _) => OpenFromClipboardRequested?.Invoke()) _clipboardItem = new ToolStripMenuItem("Bewerken vanaf klembord", null, (_, _) => OpenFromClipboardRequested?.Invoke());
menu.Items.Add(_clipboardItem);
menu.Items.Add("Afbeelding openen...", null, (_, _) => OpenFileRequested?.Invoke());
menu.Items.Add(new ToolStripSeparator());
_snipItem = new ToolStripMenuItem("Rechthoek knippen", null, (_, _) => SnipRequested?.Invoke())
{ {
Font = new Font(SystemFonts.MenuFont ?? Control.DefaultFont, FontStyle.Bold) Font = new Font(SystemFonts.MenuFont ?? Control.DefaultFont, FontStyle.Bold)
}; };
menu.Items.Add(openClipboard);
menu.Items.Add("Afbeelding openen...", null, (_, _) => OpenFileRequested?.Invoke());
menu.Items.Add(new ToolStripSeparator());
_snipItem = new ToolStripMenuItem("Rechthoek knippen", null, (_, _) => SnipRequested?.Invoke());
menu.Items.Add(_snipItem); menu.Items.Add(_snipItem);
_windowItem = new ToolStripMenuItem("Venster knippen", null, (_, _) => WindowSnipRequested?.Invoke()); _windowItem = new ToolStripMenuItem("Venster knippen", null, (_, _) => WindowSnipRequested?.Invoke());
menu.Items.Add(_windowItem); menu.Items.Add(_windowItem);
_ocrItem = new ToolStripMenuItem("OCR (tekst herkennen)...", null, (_, _) => TextRecognitionRequested?.Invoke())
{
ToolTipText = "Sleep een rechthoek; de tekst daarin opent in een apart venster om te kopiëren"
};
menu.Items.Add(_ocrItem);
_delayItem = BuildDelayItem();
menu.Items.Add(_delayItem);
_recordItem = new ToolStripMenuItem(RecordText, null, (_, _) => RecordRequested?.Invoke()); _recordItem = new ToolStripMenuItem(RecordText, null, (_, _) => RecordRequested?.Invoke());
menu.Items.Add(_recordItem); menu.Items.Add(_recordItem);
_gifItem = new ToolStripMenuItem("Screencast opnemen (GIF)...", null, (_, _) => GifRequested?.Invoke()); _gifItem = new ToolStripMenuItem("Screencast opnemen (GIF)...", null, (_, _) => GifRequested?.Invoke());
menu.Items.Add(_gifItem); menu.Items.Add(_gifItem);
menu.Items.Add(new ToolStripSeparator()); menu.Items.Add(new ToolStripSeparator());
_foldersItem = new ToolStripMenuItem("Map openen");
_foldersItem.DropDownItems.Add(new ToolStripMenuItem("..."));
_foldersItem.DropDownOpening += (_, _) => BuildFolders();
menu.Items.Add(_foldersItem);
menu.Items.Add("Geschiedenis...", null, (_, _) => HistoryRequested?.Invoke(false));
menu.Items.Add("Afbeeldingsgeschiedenis...", null, (_, _) => HistoryRequested?.Invoke(true));
_recentItem = new ToolStripMenuItem("Recente items");
_recentItem.DropDownItems.Add(new ToolStripMenuItem("..."));
_recentItem.DropDownOpening += (_, _) => BuildRecent();
menu.Items.Add(_recentItem);
menu.Items.Add(new ToolStripSeparator());
_pauseItem = new ToolStripMenuItem("Knipsels niet automatisch oppikken") { CheckOnClick = true }; _pauseItem = new ToolStripMenuItem("Knipsels niet automatisch oppikken") { CheckOnClick = true };
_pauseItem.CheckedChanged += (_, _) => PauseChanged?.Invoke(_pauseItem.Checked); _pauseItem.CheckedChanged += (_, _) => PauseChanged?.Invoke(_pauseItem.Checked);
menu.Items.Add(_pauseItem); menu.Items.Add(_pauseItem);
@@ -55,7 +99,7 @@ internal sealed class TrayIcon : IDisposable
_icon = new NotifyIcon _icon = new NotifyIcon
{ {
Icon = LoadIcon(), Icon = LoadIcon(),
Text = "kniip - annotaties voor het Knipprogramma", Text = IdleText,
ContextMenuStrip = menu, ContextMenuStrip = menu,
Visible = true Visible = true
}; };
@@ -63,7 +107,7 @@ internal sealed class TrayIcon : IDisposable
{ {
if (e.Button == MouseButtons.Left) if (e.Button == MouseButtons.Left)
{ {
OpenFromClipboardRequested?.Invoke(); SnipRequested?.Invoke();
} }
}; };
} }
@@ -71,8 +115,10 @@ internal sealed class TrayIcon : IDisposable
public bool IsPaused => _pauseItem.Checked; public bool IsPaused => _pauseItem.Checked;
/// <summary>Toont de sneltoetsen naast de menu-items; null = geen sneltoets.</summary> /// <summary>Toont de sneltoetsen naast de menu-items; null = geen sneltoets.</summary>
public void SetHotkeys(string? snip, string? window, string? record, string? gif) public void SetHotkeys(string? snip, string? window, string? record, string? gif, string? clipboard, string? ocr)
{ {
_clipboardItem.ShortcutKeyDisplayString = clipboard;
_ocrItem.ShortcutKeyDisplayString = ocr;
_snipItem.ShortcutKeyDisplayString = snip; _snipItem.ShortcutKeyDisplayString = snip;
_windowItem.ShortcutKeyDisplayString = window; _windowItem.ShortcutKeyDisplayString = window;
_recordItem.ShortcutKeyDisplayString = record; _recordItem.ShortcutKeyDisplayString = record;
@@ -84,7 +130,193 @@ internal sealed class TrayIcon : IDisposable
{ {
_recordItem.Text = text ?? RecordText; _recordItem.Text = text ?? RecordText;
_gifItem.Visible = text == null; _gifItem.Visible = text == null;
_icon.Text = recording ? "kniip - opname loopt" : "kniip - annotaties voor het Knipprogramma"; _icon.Text = recording ? $"{AppIdentity.DisplayName} - opname loopt" : IdleText;
}
public void SetCaptureDelay(int seconds)
{
_savedDelaySeconds = seconds;
ShowCaptureDelay(seconds);
}
private ToolStripMenuItem BuildDelayItem()
{
var item = new ToolStripMenuItem();
_delaySlider = new TrackBar
{
Minimum = 0,
Maximum = Kniip.Settings.AppSettings.MaxCaptureDelaySeconds,
TickFrequency = 5,
LargeChange = 5,
SmallChange = 1,
AutoSize = false,
Width = 200,
Height = 32,
Location = new Point(4, 6)
};
_delayNumber = new NumericUpDown
{
Minimum = 0,
Maximum = Kniip.Settings.AppSettings.MaxCaptureDelaySeconds,
Width = 52,
Location = new Point(_delaySlider.Right + 6, 10),
TextAlign = HorizontalAlignment.Right
};
_delayUnit = new Label
{
Text = "seconden",
AutoSize = true,
Location = new Point(_delayNumber.Right + 4, 13)
};
var panel = new Panel
{
Size = new Size(_delayUnit.Left + _delayUnit.PreferredWidth + 10, 44),
BackColor = ProfessionalColors.ToolStripDropDownBackground
};
_delayPanel = panel;
_delaySlider.BackColor = panel.BackColor;
panel.Controls.AddRange(new Control[] { _delaySlider, _delayNumber, _delayUnit });
_delaySlider.ValueChanged += (_, _) => ShowCaptureDelay(_delaySlider.Value);
_delayNumber.ValueChanged += (_, _) => ShowCaptureDelay((int)_delayNumber.Value);
_delayNumber.KeyDown += (_, e) =>
{
if (e.KeyCode is Keys.Enter or Keys.Escape)
{
e.SuppressKeyPress = true;
_icon.ContextMenuStrip?.Close();
}
};
item.DropDownItems.Add(new ToolStripControlHost(panel) { AutoSize = false, Size = panel.Size, Margin = Padding.Empty });
item.DropDownClosed += (_, _) =>
{
ShowCaptureDelay((int)_delayNumber.Value);
if (_delaySeconds != _savedDelaySeconds)
{
_savedDelaySeconds = _delaySeconds;
CaptureDelayChanged?.Invoke(_delaySeconds);
}
};
item.Text = "Schermafdruk vertraging: 0s";
return item;
}
private void ShowCaptureDelay(int seconds)
{
_delaySeconds = seconds;
if (_delaySlider.Value != seconds)
{
_delaySlider.Value = seconds;
}
if ((int)_delayNumber.Value != seconds)
{
_delayNumber.Value = seconds;
}
_delayItem.Text = $"Schermafdruk vertraging: {seconds}s";
}
/// <summary>Menu in de kleurmodus van kniip.</summary>
public void ApplyTheme(bool dark)
{
ToolStripManager.Renderer = dark ? new DarkMenuRenderer() : new ToolStripProfessionalRenderer();
Color back = dark ? DarkMenuColors.Background : ProfessionalColors.ToolStripDropDownBackground;
Color fore = dark ? DarkMenuColors.Text : SystemColors.ControlText;
_delayPanel.BackColor = back;
_delaySlider.BackColor = back;
_delayUnit.ForeColor = fore;
_delayNumber.BackColor = dark ? DarkMenuColors.Input : SystemColors.Window;
_delayNumber.ForeColor = dark ? DarkMenuColors.Text : SystemColors.WindowText;
}
private static string IdleText => AppIdentity.IsTest
? $"{AppIdentity.DisplayName} {Kniip.Update.UpdateChecker.CurrentVersion}"
: AppIdentity.DisplayName;
private void BuildFolders()
{
_foldersItem.DropDownItems.Clear();
foreach ((string label, string? path) in FoldersProvider?.Invoke() ?? Array.Empty<(string, string?)>())
{
var item = new ToolStripMenuItem(label) { Enabled = path != null, ToolTipText = path };
if (path != null)
{
item.Click += (_, _) => HistoryActions.OpenFolder(path);
}
_foldersItem.DropDownItems.Add(item);
}
}
private void BuildRecent()
{
foreach (ToolStripItem old in _recentItem.DropDownItems)
{
old.Image?.Dispose();
}
_recentItem.DropDownItems.Clear();
List<HistoryEntry> entries = HistoryStore.Recent(10).ToList();
if (entries.Count == 0)
{
_recentItem.DropDownItems.Add(new ToolStripMenuItem("Nog niets opgeslagen") { Enabled = false });
return;
}
foreach (HistoryEntry entry in entries)
{
var item = new ToolStripMenuItem($"[{entry.Time:HH:mm:ss}] {entry.Url ?? entry.Name}")
{
Image = LoadThumbnail(entry),
ToolTipText = entry.FilePath
};
item.Click += (_, _) =>
{
_icon.ContextMenuStrip?.Close();
HistoryActions.Open(entry);
};
foreach (HistoryCommand? command in HistoryActions.CommandsFor(entry))
{
if (command == null)
{
item.DropDownItems.Add(new ToolStripSeparator());
}
else
{
item.DropDownItems.Add(new ToolStripMenuItem(command.Label, null, (_, _) => command.Run()) { Enabled = command.Enabled });
}
}
_recentItem.DropDownItems.Add(item);
}
}
private static Image? LoadThumbnail(HistoryEntry entry)
{
if (HistoryStore.ThumbnailPath(entry) is not { } path || !File.Exists(path))
{
return null;
}
try
{
using var stream = new MemoryStream(File.ReadAllBytes(path));
using var image = Image.FromStream(stream);
Size size = SystemInformation.SmallIconSize;
double scale = Math.Min((double)size.Width / image.Width, (double)size.Height / image.Height);
var thumb = new Bitmap(size.Width, size.Height);
using Graphics g = Graphics.FromImage(thumb);
g.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.HighQualityBicubic;
int w = Math.Max(1, (int)(image.Width * scale)), h = Math.Max(1, (int)(image.Height * scale));
g.DrawImage(image, (size.Width - w) / 2, (size.Height - h) / 2, w, h);
return thumb;
}
catch (Exception)
{
return null;
}
} }
public void ShowBalloon(string title, string text) => public void ShowBalloon(string title, string text) =>
@@ -92,7 +324,8 @@ internal sealed class TrayIcon : IDisposable
private static Icon LoadIcon() private static Icon LoadIcon()
{ {
var info = System.Windows.Application.GetResourceStream(new Uri("pack://application:,,,/AppIcon.ico")); // Uitsnede van AppIcon.ico zonder snijlijnen (tools\make-tray-icon.ps1): op 16px vult het kader zo het vak.
var info = System.Windows.Application.GetResourceStream(new Uri("pack://application:,,,/TrayIcon.ico"));
if (info == null) if (info == null)
{ {
return SystemIcons.Application; return SystemIcons.Application;
@@ -107,4 +340,68 @@ internal sealed class TrayIcon : IDisposable
_icon.Visible = false; _icon.Visible = false;
_icon.Dispose(); _icon.Dispose();
} }
private sealed class DarkMenuColors : ProfessionalColorTable
{
public static readonly Color Background = Color.FromArgb(0x2B, 0x2B, 0x2B);
public static readonly Color Highlight = Color.FromArgb(0x3D, 0x3D, 0x3D);
public static readonly Color Border = Color.FromArgb(0x45, 0x45, 0x45);
public static readonly Color Input = Color.FromArgb(0x2D, 0x2D, 0x2D);
public static readonly Color Text = Color.FromArgb(0xF2, 0xF2, 0xF2);
public override Color ToolStripDropDownBackground => Background;
public override Color ImageMarginGradientBegin => Background;
public override Color ImageMarginGradientMiddle => Background;
public override Color ImageMarginGradientEnd => Background;
public override Color MenuBorder => Border;
public override Color MenuItemBorder => Highlight;
public override Color MenuItemSelected => Highlight;
public override Color MenuItemSelectedGradientBegin => Highlight;
public override Color MenuItemSelectedGradientEnd => Highlight;
public override Color MenuItemPressedGradientBegin => Highlight;
public override Color MenuItemPressedGradientEnd => Highlight;
public override Color SeparatorDark => Border;
public override Color SeparatorLight => Background;
public override Color CheckBackground => Highlight;
public override Color CheckSelectedBackground => Highlight;
public override Color CheckPressedBackground => Highlight;
}
private sealed class DarkMenuRenderer : ToolStripProfessionalRenderer
{
public DarkMenuRenderer() : base(new DarkMenuColors())
{
RoundedEdges = false;
}
protected override void OnRenderItemText(ToolStripItemTextRenderEventArgs e)
{
e.TextColor = e.Item.Enabled ? DarkMenuColors.Text : Color.FromArgb(0x80, 0x80, 0x80);
base.OnRenderItemText(e);
}
protected override void OnRenderArrow(ToolStripArrowRenderEventArgs e)
{
e.ArrowColor = DarkMenuColors.Text;
base.OnRenderArrow(e);
}
protected override void OnRenderItemCheck(ToolStripItemImageRenderEventArgs e)
{
// Het standaardvinkje is zwart en valt weg op de donkere achtergrond.
Rectangle r = e.ImageRectangle;
using var background = new SolidBrush(DarkMenuColors.Highlight);
e.Graphics.FillRectangle(background, r);
using var pen = new Pen(DarkMenuColors.Text, 1.6f);
System.Drawing.Drawing2D.SmoothingMode smoothing = e.Graphics.SmoothingMode;
e.Graphics.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias;
e.Graphics.DrawLines(pen, new[]
{
new PointF(r.Left + r.Width * 0.22f, r.Top + r.Height * 0.52f),
new PointF(r.Left + r.Width * 0.42f, r.Top + r.Height * 0.72f),
new PointF(r.Left + r.Width * 0.78f, r.Top + r.Height * 0.30f)
});
e.Graphics.SmoothingMode = smoothing;
}
}
} }
+47
View File
@@ -26,6 +26,37 @@ internal static class NativeMethods
[DllImport("user32.dll")] [DllImport("user32.dll")]
public static extern uint GetClipboardSequenceNumber(); public static extern uint GetClipboardSequenceNumber();
public const uint CF_UNICODETEXT = 13;
public const uint GMEM_MOVEABLE = 0x0002;
[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool OpenClipboard(IntPtr hWndNewOwner);
[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool CloseClipboard();
[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool EmptyClipboard();
[DllImport("user32.dll", SetLastError = true)]
public static extern IntPtr SetClipboardData(uint format, IntPtr hMem);
[DllImport("kernel32.dll", SetLastError = true)]
public static extern IntPtr GlobalAlloc(uint flags, UIntPtr bytes);
[DllImport("kernel32.dll", SetLastError = true)]
public static extern IntPtr GlobalLock(IntPtr hMem);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool GlobalUnlock(IntPtr hMem);
[DllImport("kernel32.dll", SetLastError = true)]
public static extern IntPtr GlobalFree(IntPtr hMem);
[DllImport("user32.dll")] [DllImport("user32.dll")]
public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId); public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
@@ -193,6 +224,22 @@ internal static class NativeMethods
[DllImport("dwmapi.dll")] [DllImport("dwmapi.dll")]
private static extern int DwmGetWindowAttribute(IntPtr hwnd, int attribute, out int value, int size); private static extern int DwmGetWindowAttribute(IntPtr hwnd, int attribute, out int value, int size);
[DllImport("dwmapi.dll")]
private static extern int DwmSetWindowAttribute(IntPtr hwnd, int attribute, ref int value, int size);
private const int DWMWA_USE_IMMERSIVE_DARK_MODE = 20;
private const int DWMWA_USE_IMMERSIVE_DARK_MODE_BEFORE_20H1 = 19;
/// <summary>Donkere of lichte titelbalk; doet niets op Windows-versies die dat niet kennen.</summary>
public static void SetDarkTitleBar(IntPtr hwnd, bool dark)
{
int value = dark ? 1 : 0;
if (DwmSetWindowAttribute(hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE, ref value, sizeof(int)) != 0)
{
DwmSetWindowAttribute(hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE_BEFORE_20H1, ref value, sizeof(int));
}
}
public static void AddExtendedStyle(IntPtr hwnd, int style) public static void AddExtendedStyle(IntPtr hwnd, int style)
{ {
long current = GetWindowLongPtr(hwnd, GWL_EXSTYLE).ToInt64(); long current = GetWindowLongPtr(hwnd, GWL_EXSTYLE).ToInt64();
+9 -1
View File
@@ -18,7 +18,7 @@
<ApplicationManifest>app.manifest</ApplicationManifest> <ApplicationManifest>app.manifest</ApplicationManifest>
<Product>kniip</Product> <Product>kniip</Product>
<Description>Annotatie-editor naast het Windows Knipprogramma</Description> <Description>Annotatie-editor naast het Windows Knipprogramma</Description>
<Version>1.0.0</Version> <Version>2.0.0</Version>
<SatelliteResourceLanguages>nl;en</SatelliteResourceLanguages> <SatelliteResourceLanguages>nl;en</SatelliteResourceLanguages>
<!-- WPF regelt DPI via app.manifest; de WinForms-analyzer is hier niet van toepassing. --> <!-- WPF regelt DPI via app.manifest; de WinForms-analyzer is hier niet van toepassing. -->
<NoWarn>$(NoWarn);WFAC010</NoWarn> <NoWarn>$(NoWarn);WFAC010</NoWarn>
@@ -26,12 +26,20 @@
<AllowedReferenceRelatedFileExtensions>.none</AllowedReferenceRelatedFileExtensions> <AllowedReferenceRelatedFileExtensions>.none</AllowedReferenceRelatedFileExtensions>
</PropertyGroup> </PropertyGroup>
<!-- Testbuild (build.ps1, -p:KniipTest=true): eigen register- en instellingennamen, zie AppIdentity. -->
<PropertyGroup Condition="'$(KniipTest)' == 'true'">
<DefineConstants>$(DefineConstants);KNIIP_TEST</DefineConstants>
<Product>kniip test</Product>
</PropertyGroup>
<ItemGroup> <ItemGroup>
<Resource Include="AppIcon.ico" /> <Resource Include="AppIcon.ico" />
<Resource Include="TrayIcon.ico" />
<Resource Include="Assets\Emoji\emoji.json;Assets\Emoji\twemoji-64.png" /> <Resource Include="Assets\Emoji\emoji.json;Assets\Emoji\twemoji-64.png" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Markdig" Version="1.6.0" />
<PackageReference Include="ScreenRecorderLib" Version="7.0.1" /> <PackageReference Include="ScreenRecorderLib" Version="7.0.1" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" /> <PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
</ItemGroup> </ItemGroup>
+6 -6
View File
@@ -3,8 +3,8 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="kniip" WindowStyle="None" AllowsTransparency="True" Background="Transparent" Title="kniip" WindowStyle="None" AllowsTransparency="True" Background="Transparent"
Topmost="True" ShowInTaskbar="False" ShowActivated="False" ResizeMode="NoResize" Topmost="True" ShowInTaskbar="False" ShowActivated="False" ResizeMode="NoResize"
SizeToContent="WidthAndHeight" FontFamily="Segoe UI" UseLayoutRounding="True"> SizeToContent="WidthAndHeight" FontFamily="Segoe UI" Foreground="{DynamicResource InkBrush}" UseLayoutRounding="True">
<Border Margin="16" Background="#FCFCFC" CornerRadius="8" BorderBrush="#DADADA" BorderThickness="1"> <Border Margin="16" Background="{DynamicResource ToastBrush}" CornerRadius="8" BorderBrush="{DynamicResource ToastBorderBrush}" BorderThickness="1">
<Border.Effect> <Border.Effect>
<DropShadowEffect BlurRadius="18" ShadowDepth="3" Opacity="0.22" /> <DropShadowEffect BlurRadius="18" ShadowDepth="3" Opacity="0.22" />
</Border.Effect> </Border.Effect>
@@ -21,20 +21,20 @@
<StackPanel Grid.ColumnSpan="2" Orientation="Horizontal" Margin="0,0,0,10"> <StackPanel Grid.ColumnSpan="2" Orientation="Horizontal" Margin="0,0,0,10">
<Image Source="pack://application:,,,/AppIcon.ico" Width="16" Height="16" /> <Image Source="pack://application:,,,/AppIcon.ico" Width="16" Height="16" />
<TextBlock Text="kniip" Margin="6,0,0,0" FontSize="12" Foreground="#5C5C5C" VerticalAlignment="Center" /> <TextBlock Text="kniip" Margin="6,0,0,0" FontSize="12" Foreground="{DynamicResource SubtleTextBrush}" VerticalAlignment="Center" />
</StackPanel> </StackPanel>
<Button Grid.Column="2" Style="{StaticResource FlatButton}" MinWidth="28" MinHeight="24" Padding="4,2" <Button Grid.Column="2" Style="{StaticResource FlatButton}" MinWidth="28" MinHeight="24" Padding="4,2"
VerticalAlignment="Top" Click="Close_Click" ToolTip="Sluiten"> VerticalAlignment="Top" Click="Close_Click" ToolTip="Sluiten">
<TextBlock Text="&#xE711;" Style="{StaticResource IconText}" FontSize="10" /> <TextBlock Text="&#xE711;" Style="{StaticResource IconText}" FontSize="10" />
</Button> </Button>
<Border Grid.Row="1" Width="110" Height="72" CornerRadius="4" BorderBrush="#E0E0E0" BorderThickness="1" <Border Grid.Row="1" Width="110" Height="72" CornerRadius="4" BorderBrush="{DynamicResource ControlBorderBrush}" BorderThickness="1"
Background="#EBEBEB" Cursor="Hand" MouseLeftButtonUp="Thumbnail_Click" ClipToBounds="True"> Background="{DynamicResource ControlPressedBrush}" Cursor="Hand" MouseLeftButtonUp="Thumbnail_Click" ClipToBounds="True">
<Image x:Name="Thumbnail" Stretch="Uniform" RenderOptions.BitmapScalingMode="HighQuality" /> <Image x:Name="Thumbnail" Stretch="Uniform" RenderOptions.BitmapScalingMode="HighQuality" />
</Border> </Border>
<StackPanel Grid.Row="1" Grid.Column="1" Grid.ColumnSpan="2" Margin="12,0,4,0" VerticalAlignment="Center"> <StackPanel Grid.Row="1" Grid.Column="1" Grid.ColumnSpan="2" Margin="12,0,4,0" VerticalAlignment="Center">
<TextBlock Text="Nieuw knipsel" FontWeight="SemiBold" FontSize="14" /> <TextBlock Text="Nieuw knipsel" FontWeight="SemiBold" FontSize="14" />
<TextBlock x:Name="DetailText" Foreground="#5C5C5C" FontSize="12" Margin="0,2,0,8" /> <TextBlock x:Name="DetailText" Foreground="{DynamicResource SubtleTextBrush}" FontSize="12" Margin="0,2,0,8" />
<StackPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal">
<Button Style="{StaticResource AccentButton}" Margin="0,0,6,0" Click="Edit_Click"> <Button Style="{StaticResource AccentButton}" Margin="0,0,6,0" Click="Edit_Click">
<StackPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal">
+35 -3
View File
@@ -1,6 +1,8 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics;
using System.Drawing; using System.Drawing;
using System.Linq;
using System.Text; using System.Text;
using System.Windows; using System.Windows;
using System.Windows.Interop; using System.Windows.Interop;
@@ -39,9 +41,37 @@ internal static class PhysicalWindow
NativeMethods.SetWindowDisplayAffinity(Handle(window), NativeMethods.WDA_EXCLUDEFROMCAPTURE); NativeMethods.SetWindowDisplayAffinity(Handle(window), NativeMethods.WDA_EXCLUDEFROMCAPTURE);
/// <summary>Zichtbare topniveauvensters, van boven naar onder; die van kniip zelf alleen met <paramref name="includeOwn"/>.</summary> /// <summary>Zichtbare topniveauvensters, van boven naar onder; die van kniip zelf alleen met <paramref name="includeOwn"/>.</summary>
public static List<Rectangle> SnapshotWindows(bool includeOwn = false) public static List<Rectangle> SnapshotWindows(bool includeOwn = false) =>
Snapshot(includeOwn).Select(w => w.Bounds).ToList();
/// <summary>Programmanaam van het bovenste venster uit <paramref name="windows"/> onder het midden van <paramref name="area"/>.</summary>
public static string? ProgramNameAt(IEnumerable<SnappedWindow> windows, Rectangle area)
{ {
var result = new List<Rectangle>(); var point = new System.Drawing.Point(area.X + area.Width / 2, area.Y + area.Height / 2);
foreach (SnappedWindow window in windows)
{
if (!window.Bounds.Contains(point))
{
continue;
}
try
{
using Process process = Process.GetProcessById((int)window.ProcessId);
return process.ProcessName;
}
catch (Exception)
{
return null;
}
}
return null;
}
public static List<SnappedWindow> Snapshot(bool includeOwn = false)
{
var result = new List<SnappedWindow>();
int ownPid = Environment.ProcessId; int ownPid = Environment.ProcessId;
var className = new StringBuilder(64); var className = new StringBuilder(64);
NativeMethods.EnumWindows((hwnd, _) => NativeMethods.EnumWindows((hwnd, _) =>
@@ -69,7 +99,7 @@ internal static class PhysicalWindow
NativeMethods.RECT rect = NativeMethods.GetVisibleWindowRect(hwnd); NativeMethods.RECT rect = NativeMethods.GetVisibleWindowRect(hwnd);
if (rect.Width > 8 && rect.Height > 8) if (rect.Width > 8 && rect.Height > 8)
{ {
result.Add(new Rectangle(rect.Left, rect.Top, rect.Width, rect.Height)); result.Add(new SnappedWindow(new Rectangle(rect.Left, rect.Top, rect.Width, rect.Height), pid));
} }
return true; return true;
@@ -78,3 +108,5 @@ internal static class PhysicalWindow
return result; return result;
} }
} }
internal sealed record SnappedWindow(Rectangle Bounds, uint ProcessId);
+19 -6
View File
@@ -52,8 +52,11 @@ internal sealed class RecordingController
public event Action<RecordingStage>? StageChanged; public event Action<RecordingStage>? StageChanged;
/// <summary>Pad van een geslaagde opname (tijdelijk MP4-bestand) en het voorgestelde formaat.</summary> /// <summary>Pad van een geslaagde opname (tijdelijk MP4-bestand), het voorgestelde formaat en de programmanaam van het opgenomen venster.</summary>
public event Action<string, ScreencastFormat?>? RecordingFinished; public event Action<string, ScreencastFormat?, string?>? RecordingFinished;
private List<SnappedWindow> _snapshot = new();
private string? _windowName;
public event Action<string, string>? Notify; public event Action<string, string>? Notify;
@@ -124,13 +127,18 @@ internal sealed class RecordingController
{ {
_format = format; _format = format;
SetStage(RecordingStage.Selecting); SetStage(RecordingStage.Selecting);
List<System.Drawing.Rectangle> windows = PhysicalWindow.SnapshotWindows(); _snapshot = PhysicalWindow.Snapshot();
List<System.Drawing.Rectangle> windows = _snapshot.Select(w => w.Bounds).ToList();
NativeMethods.GetCursorPos(out NativeMethods.POINT cursor); NativeMethods.GetCursorPos(out NativeMethods.POINT cursor);
var cursorPoint = new System.Drawing.Point(cursor.X, cursor.Y); var cursorPoint = new System.Drawing.Point(cursor.X, cursor.Y);
foreach (Screen screen in Screen.AllScreens) // Vastleggen voordat de overlays er staan, anders zit hun grijze laag in het vergrootglas.
var frozen = Screen.AllScreens
.Select(screen => (screen, image: _settings.ShowMagnifier ? Kniip.Capture.ScreenGrabber.Capture(screen.Bounds) : null))
.ToList();
foreach ((Screen screen, System.Windows.Media.Imaging.BitmapSource? image) in frozen)
{ {
var selector = new RegionSelectorWindow(screen, windows); var selector = new RegionSelectorWindow(screen, windows, image);
selector.Finished += OnRegionChosen; selector.Finished += OnRegionChosen;
_selectors.Add(selector); _selectors.Add(selector);
selector.Show(); selector.Show();
@@ -150,6 +158,8 @@ internal sealed class RecordingController
} }
_selectors.Clear(); _selectors.Clear();
List<SnappedWindow> snapshot = _snapshot;
_snapshot = new();
if (region == null) if (region == null)
{ {
SetStage(RecordingStage.Idle); SetStage(RecordingStage.Idle);
@@ -157,6 +167,7 @@ internal sealed class RecordingController
} }
_region = region; _region = region;
_windowName = PhysicalWindow.ProgramNameAt(snapshot, region.Bounds);
Screen screen = Screen.AllScreens.FirstOrDefault(s => s.DeviceName == region.DeviceName) ?? Screen.PrimaryScreen!; Screen screen = Screen.AllScreens.FirstOrDefault(s => s.DeviceName == region.DeviceName) ?? Screen.PrimaryScreen!;
_frame = new RegionFrameWindow(region.Bounds); _frame = new RegionFrameWindow(region.Bounds);
@@ -299,6 +310,7 @@ internal sealed class RecordingController
{ {
bool discard = _discard; bool discard = _discard;
ScreencastFormat? format = _format; ScreencastFormat? format = _format;
string? windowName = _windowName;
EndAll(); EndAll();
if (discard) if (discard)
{ {
@@ -306,7 +318,7 @@ internal sealed class RecordingController
return; return;
} }
RecordingFinished?.Invoke(path, format); RecordingFinished?.Invoke(path, format, windowName);
} }
private void OnFailed(string error, bool started) private void OnFailed(string error, bool started)
@@ -360,6 +372,7 @@ internal sealed class RecordingController
_frame?.Close(); _frame?.Close();
_frame = null; _frame = null;
_region = null; _region = null;
_windowName = null;
_format = null; _format = null;
_discard = false; _discard = false;
_retriedWithoutAudio = false; _retriedWithoutAudio = false;
+1 -1
View File
@@ -15,7 +15,7 @@ internal sealed record RecordingOptions(bool SystemAudio, bool Microphone, bool
/// <summary>Eén opname naar een tijdelijk MP4-bestand. Gebeurtenissen komen binnen op de UI-thread.</summary> /// <summary>Eén opname naar een tijdelijk MP4-bestand. Gebeurtenissen komen binnen op de UI-thread.</summary>
internal sealed class RecordingSession : IDisposable internal sealed class RecordingSession : IDisposable
{ {
public static string TempFolder { get; } = Path.Combine(Path.GetTempPath(), "Kniip"); public static string TempFolder { get; } = Path.Combine(Path.GetTempPath(), Infrastructure.AppIdentity.Key);
private readonly Dispatcher _dispatcher; private readonly Dispatcher _dispatcher;
private readonly RecordingRegion _region; private readonly RecordingRegion _region;
+4 -4
View File
@@ -3,7 +3,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="kniip opname" WindowStyle="None" AllowsTransparency="True" Background="Transparent" Title="kniip opname" WindowStyle="None" AllowsTransparency="True" Background="Transparent"
ResizeMode="NoResize" SizeToContent="WidthAndHeight" ShowInTaskbar="False" Topmost="True" ResizeMode="NoResize" SizeToContent="WidthAndHeight" ShowInTaskbar="False" Topmost="True"
FontFamily="Segoe UI" FontSize="13"> FontFamily="Segoe UI" FontSize="13" Foreground="{DynamicResource InkBrush}">
<Window.Resources> <Window.Resources>
<Style x:Key="RecordStyle" TargetType="Button" BasedOn="{StaticResource AccentButton}"> <Style x:Key="RecordStyle" TargetType="Button" BasedOn="{StaticResource AccentButton}">
<Setter Property="Template"> <Setter Property="Template">
@@ -25,7 +25,7 @@
</Setter> </Setter>
</Style> </Style>
</Window.Resources> </Window.Resources>
<Border Margin="10" Background="White" BorderBrush="#D6D6D6" BorderThickness="1" CornerRadius="8" Padding="6" <Border Margin="10" Background="{DynamicResource CardBrush}" BorderBrush="{DynamicResource ControlStrongBorderBrush}" BorderThickness="1" CornerRadius="8" Padding="6"
MouseLeftButtonDown="Background_MouseLeftButtonDown"> MouseLeftButtonDown="Background_MouseLeftButtonDown">
<Border.Effect> <Border.Effect>
<DropShadowEffect BlurRadius="14" ShadowDepth="2" Opacity="0.25" /> <DropShadowEffect BlurRadius="14" ShadowDepth="2" Opacity="0.25" />
@@ -33,7 +33,7 @@
<StackPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal">
<StackPanel x:Name="TimePanel" Orientation="Horizontal" VerticalAlignment="Center" Margin="6,0,8,0" Visibility="Collapsed"> <StackPanel x:Name="TimePanel" Orientation="Horizontal" VerticalAlignment="Center" Margin="6,0,8,0" Visibility="Collapsed">
<Ellipse x:Name="RecordDot" Width="10" Height="10" Fill="#E81123" Margin="0,0,6,0" /> <Ellipse x:Name="RecordDot" Width="10" Height="10" Fill="#E81123" Margin="0,0,6,0" />
<TextBlock x:Name="TimeText" Text="0:00" FontWeight="SemiBold" MinWidth="40" Foreground="{StaticResource InkBrush}" /> <TextBlock x:Name="TimeText" Text="0:00" FontWeight="SemiBold" MinWidth="40" Foreground="{DynamicResource InkBrush}" />
</StackPanel> </StackPanel>
<ToggleButton x:Name="SystemAudioToggle" Style="{StaticResource MiniToggle}" Height="34" MinWidth="34" <ToggleButton x:Name="SystemAudioToggle" Style="{StaticResource MiniToggle}" Height="34" MinWidth="34"
@@ -66,7 +66,7 @@
</Button> </Button>
<TextBlock x:Name="BusyText" Text="Opname afronden..." VerticalAlignment="Center" Margin="8,0" Visibility="Collapsed" <TextBlock x:Name="BusyText" Text="Opname afronden..." VerticalAlignment="Center" Margin="8,0" Visibility="Collapsed"
Foreground="{StaticResource SubtleTextBrush}" /> Foreground="{DynamicResource SubtleTextBrush}" />
<Button x:Name="CancelButton" Style="{StaticResource FlatButton}" Click="Cancel_Click" ToolTip="Annuleren (opname weggooien)"> <Button x:Name="CancelButton" Style="{StaticResource FlatButton}" Click="Cancel_Click" ToolTip="Annuleren (opname weggooien)">
<TextBlock Text="&#xE711;" Style="{StaticResource IconText}" FontSize="14" /> <TextBlock Text="&#xE711;" Style="{StaticResource IconText}" FontSize="14" />
@@ -1,25 +1,27 @@
<Window x:Class="Kniip.Recording.RegionSelectorWindow" <Window x:Class="Kniip.Recording.RegionSelectorWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:capture="clr-namespace:Kniip.Capture"
Title="kniip opnamegebied kiezen" WindowStyle="None" AllowsTransparency="True" ResizeMode="NoResize" Title="kniip opnamegebied kiezen" WindowStyle="None" AllowsTransparency="True" ResizeMode="NoResize"
ShowInTaskbar="False" Topmost="True" Cursor="Cross" FontFamily="Segoe UI" FontSize="13" ShowInTaskbar="False" Topmost="True" Cursor="Cross" FontFamily="Segoe UI" FontSize="13"
Background="#01000000"> Background="#01000000">
<Grid> <Grid>
<Path x:Name="Dim" Fill="#73000000" /> <Path x:Name="Dim" Fill="#73000000" />
<Rectangle x:Name="SelectionRect" Stroke="{StaticResource AccentBrush}" StrokeThickness="2" <Rectangle x:Name="SelectionRect" Stroke="{DynamicResource AccentBrush}" StrokeThickness="2"
HorizontalAlignment="Left" VerticalAlignment="Top" Visibility="Collapsed" IsHitTestVisible="False" /> HorizontalAlignment="Left" VerticalAlignment="Top" Visibility="Collapsed" IsHitTestVisible="False" />
<Border x:Name="SizeBadge" HorizontalAlignment="Left" VerticalAlignment="Top" Background="#E6202020" <Border x:Name="SizeBadge" HorizontalAlignment="Left" VerticalAlignment="Top" Background="#E6202020"
CornerRadius="4" Padding="6,2" Visibility="Collapsed" IsHitTestVisible="False"> CornerRadius="4" Padding="6,2" Visibility="Collapsed" IsHitTestVisible="False">
<TextBlock x:Name="SizeText" Foreground="White" FontSize="12" /> <TextBlock x:Name="SizeText" Foreground="White" FontSize="12" />
</Border> </Border>
<Border x:Name="Hint" HorizontalAlignment="Center" VerticalAlignment="Top" Margin="0,28,0,0" Background="#F2FFFFFF" <capture:MagnifierControl x:Name="Magnifier" />
<Border x:Name="Hint" HorizontalAlignment="Center" VerticalAlignment="Top" Margin="0,28,0,0" Background="{DynamicResource ToastBrush}"
CornerRadius="6" Padding="16,9" IsHitTestVisible="False"> CornerRadius="6" Padding="16,9" IsHitTestVisible="False">
<Border.Effect> <Border.Effect>
<DropShadowEffect BlurRadius="12" ShadowDepth="2" Opacity="0.25" /> <DropShadowEffect BlurRadius="12" ShadowDepth="2" Opacity="0.25" />
</Border.Effect> </Border.Effect>
<StackPanel> <StackPanel>
<TextBlock Text="Screencast: kies wat je wilt opnemen" FontWeight="SemiBold" Foreground="{StaticResource InkBrush}" /> <TextBlock Text="Screencast: kies wat je wilt opnemen" FontWeight="SemiBold" Foreground="{DynamicResource InkBrush}" />
<TextBlock Foreground="{StaticResource SubtleTextBrush}" FontSize="12" Margin="0,2,0,0" <TextBlock Foreground="{DynamicResource SubtleTextBrush}" FontSize="12" Margin="0,2,0,0"
Text="Sleep een gebied · klik op een venster · Enter = heel scherm · Esc = annuleren" /> Text="Sleep een gebied · klik op een venster · Enter = heel scherm · Esc = annuleren" />
</StackPanel> </StackPanel>
</Border> </Border>
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Windows; using System.Windows;
using System.Windows.Input; using System.Windows.Input;
using System.Windows.Media; using System.Windows.Media;
using System.Windows.Media.Imaging;
using Rectangle = System.Drawing.Rectangle; using Rectangle = System.Drawing.Rectangle;
using Screen = System.Windows.Forms.Screen; using Screen = System.Windows.Forms.Screen;
@@ -19,6 +20,7 @@ public partial class RegionSelectorWindow : Window
private readonly Screen _screen; private readonly Screen _screen;
private readonly IReadOnlyList<Rectangle> _windows; private readonly IReadOnlyList<Rectangle> _windows;
private readonly BitmapSource? _frozen;
private Point? _dragStart; private Point? _dragStart;
private bool _dragging; private bool _dragging;
private Rectangle? _hover; private Rectangle? _hover;
@@ -26,15 +28,18 @@ public partial class RegionSelectorWindow : Window
/// <summary>Gekozen gebied, of null bij annuleren.</summary> /// <summary>Gekozen gebied, of null bij annuleren.</summary>
internal event Action<RecordingRegion?>? Finished; internal event Action<RecordingRegion?>? Finished;
internal RegionSelectorWindow(Screen screen, IReadOnlyList<Rectangle> windows) /// <param name="frozen">Beeld van het scherm voor het vergrootglas; null betekent geen vergrootglas.</param>
internal RegionSelectorWindow(Screen screen, IReadOnlyList<Rectangle> windows, BitmapSource? frozen = null)
{ {
InitializeComponent(); InitializeComponent();
_screen = screen; _screen = screen;
_windows = windows; _windows = windows;
_frozen = frozen;
SourceInitialized += (_, _) => PhysicalWindow.Place(this, screen.Bounds); SourceInitialized += (_, _) => PhysicalWindow.Place(this, screen.Bounds);
Loaded += (_, _) => ShowSelection(null); Loaded += (_, _) => ShowSelection(null);
MouseLeave += (_, _) => MouseLeave += (_, _) =>
{ {
Magnifier.Hide();
if (!_dragging) if (!_dragging)
{ {
_hover = null; _hover = null;
@@ -49,6 +54,13 @@ public partial class RegionSelectorWindow : Window
{ {
base.OnMouseMove(e); base.OnMouseMove(e);
Point position = e.GetPosition(this); Point position = e.GetPosition(this);
if (_frozen != null)
{
System.Drawing.Point physical = ToPhysical(position);
var relative = new System.Drawing.Point(physical.X - _screen.Bounds.X, physical.Y - _screen.Bounds.Y);
Magnifier.Show(_frozen, relative, physical, position, new Size(ActualWidth, ActualHeight));
}
if (_dragStart is Point start && e.LeftButton == MouseButtonState.Pressed) if (_dragStart is Point start && e.LeftButton == MouseButtonState.Pressed)
{ {
if (!_dragging && (position - start).Length >= DragThreshold) if (!_dragging && (position - start).Length >= DragThreshold)
+9 -9
View File
@@ -2,12 +2,12 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="kniip screencast" Width="980" Height="720" MinWidth="680" MinHeight="500" Title="kniip screencast" Width="980" Height="720" MinWidth="680" MinHeight="500"
WindowStartupLocation="CenterScreen" Background="{StaticResource WindowBrush}" WindowStartupLocation="CenterScreen" Background="{DynamicResource WindowBrush}" Foreground="{DynamicResource InkBrush}"
FontFamily="Segoe UI" FontSize="13" Icon="pack://application:,,,/AppIcon.ico"> FontFamily="Segoe UI" FontSize="13" Icon="pack://application:,,,/AppIcon.ico">
<Window.Resources> <Window.Resources>
<Style x:Key="Handle" TargetType="Border"> <Style x:Key="Handle" TargetType="Border">
<Setter Property="Width" Value="12" /> <Setter Property="Width" Value="12" />
<Setter Property="Background" Value="{StaticResource AccentBrush}" /> <Setter Property="Background" Value="{DynamicResource AccentBrush}" />
<Setter Property="Cursor" Value="SizeWE" /> <Setter Property="Cursor" Value="SizeWE" />
</Style> </Style>
<Style x:Key="Grip" TargetType="Rectangle"> <Style x:Key="Grip" TargetType="Rectangle">
@@ -43,10 +43,10 @@
MouseLeftButtonDown="TrimArea_MouseLeftButtonDown" MouseMove="TrimArea_MouseMove" MouseLeftButtonDown="TrimArea_MouseLeftButtonDown" MouseMove="TrimArea_MouseMove"
MouseLeftButtonUp="TrimArea_MouseLeftButtonUp" SizeChanged="TrimArea_SizeChanged"> MouseLeftButtonUp="TrimArea_MouseLeftButtonUp" SizeChanged="TrimArea_SizeChanged">
<Canvas x:Name="TrimCanvas"> <Canvas x:Name="TrimCanvas">
<Border x:Name="TrackBorder" Background="#DADADA" CornerRadius="5" Height="44" /> <Border x:Name="TrackBorder" Background="{DynamicResource TrackBrush}" CornerRadius="5" Height="44" />
<Border x:Name="SelectionBorder" Background="#330067C0" BorderBrush="{StaticResource AccentBrush}" <Border x:Name="SelectionBorder" Background="#330067C0" BorderBrush="{DynamicResource AccentBrush}"
BorderThickness="0,2" Height="44" /> BorderThickness="0,2" Height="44" />
<Rectangle x:Name="Playhead" Width="2" Height="52" Fill="{StaticResource InkBrush}" Canvas.Top="-4" /> <Rectangle x:Name="Playhead" Width="2" Height="52" Fill="{DynamicResource InkBrush}" Canvas.Top="-4" />
<Border x:Name="StartHandle" Style="{StaticResource Handle}" CornerRadius="5,0,0,5" Height="44" <Border x:Name="StartHandle" Style="{StaticResource Handle}" CornerRadius="5,0,0,5" Height="44"
ToolTip="Begin van de selectie"> ToolTip="Begin van de selectie">
<Rectangle Style="{StaticResource Grip}" /> <Rectangle Style="{StaticResource Grip}" />
@@ -64,7 +64,7 @@
<TextBlock x:Name="PlayIcon" Text="&#xE768;" Style="{StaticResource IconText}" /> <TextBlock x:Name="PlayIcon" Text="&#xE768;" Style="{StaticResource IconText}" />
</Button> </Button>
<TextBlock x:Name="PositionText" DockPanel.Dock="Left" VerticalAlignment="Center" Margin="6,0,0,0" <TextBlock x:Name="PositionText" DockPanel.Dock="Left" VerticalAlignment="Center" Margin="6,0,0,0"
Foreground="{StaticResource InkBrush}" Text="0:00.0 / 0:00.0" /> Foreground="{DynamicResource InkBrush}" Text="0:00.0 / 0:00.0" />
<Button DockPanel.Dock="Right" Style="{StaticResource FlatButton}" Click="SetEnd_Click" <Button DockPanel.Dock="Right" Style="{StaticResource FlatButton}" Click="SetEnd_Click"
ToolTip="Einde van de selectie op de huidige positie zetten"> ToolTip="Einde van de selectie op de huidige positie zetten">
@@ -81,10 +81,10 @@
</StackPanel> </StackPanel>
</Button> </Button>
<TextBlock x:Name="SelectionText" DockPanel.Dock="Right" VerticalAlignment="Center" Margin="0,0,10,0" <TextBlock x:Name="SelectionText" DockPanel.Dock="Right" VerticalAlignment="Center" Margin="0,0,10,0"
Foreground="{StaticResource SubtleTextBrush}" /> Foreground="{DynamicResource SubtleTextBrush}" />
</DockPanel> </DockPanel>
<Border Grid.Row="3" Margin="0,12,0,0" Background="White" BorderBrush="#E5E5E5" BorderThickness="1" <Border Grid.Row="3" Margin="0,12,0,0" Background="{DynamicResource CardBrush}" BorderBrush="{DynamicResource CardBorderBrush}" BorderThickness="1"
CornerRadius="6" Padding="14,10"> CornerRadius="6" Padding="14,10">
<DockPanel LastChildFill="True"> <DockPanel LastChildFill="True">
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" VerticalAlignment="Center"> <StackPanel DockPanel.Dock="Right" Orientation="Horizontal" VerticalAlignment="Center">
@@ -132,7 +132,7 @@
</StackPanel> </StackPanel>
<TextBlock x:Name="InfoText" Style="{StaticResource PropLabel}" Margin="0,6,0,0" TextWrapping="Wrap" /> <TextBlock x:Name="InfoText" Style="{StaticResource PropLabel}" Margin="0,6,0,0" TextWrapping="Wrap" />
<TextBlock x:Name="SavedText" Margin="0,4,0,0" Visibility="Collapsed" FontSize="12" TextWrapping="Wrap"> <TextBlock x:Name="SavedText" Margin="0,4,0,0" Visibility="Collapsed" FontSize="12" TextWrapping="Wrap">
<Run x:Name="SavedRun" Foreground="{StaticResource InkBrush}" /> <Run x:Name="SavedRun" Foreground="{DynamicResource InkBrush}" />
<Hyperlink Click="OpenFolder_Click"><Run x:Name="OpenRun" Text="Map openen" /></Hyperlink> <Hyperlink Click="OpenFolder_Click"><Run x:Name="OpenRun" Text="Map openen" /></Hyperlink>
</TextBlock> </TextBlock>
</StackPanel> </StackPanel>
+22 -6
View File
@@ -34,6 +34,7 @@ public partial class ScreencastWindow : Window
private readonly string _path; private readonly string _path;
private readonly AppSettings _settings; private readonly AppSettings _settings;
private readonly string? _windowName;
private readonly DispatcherTimer _playTimer = new() { Interval = TimeSpan.FromMilliseconds(40) }; private readonly DispatcherTimer _playTimer = new() { Interval = TimeSpan.FromMilliseconds(40) };
private TimeSpan _duration; private TimeSpan _duration;
private TimeSpan _start; private TimeSpan _start;
@@ -45,11 +46,14 @@ public partial class ScreencastWindow : Window
private bool _kept; private bool _kept;
private string? _lastSaved; private string? _lastSaved;
internal ScreencastWindow(string path, AppSettings settings, ScreencastFormat? format = null) /// <param name="windowName">Programmanaam van het opgenomen venster, voor %V in het naamsjabloon.</param>
internal ScreencastWindow(string path, AppSettings settings, ScreencastFormat? format = null, string? windowName = null)
{ {
InitializeComponent(); InitializeComponent();
Infrastructure.ThemeManager.Attach(this);
_path = path; _path = path;
_settings = settings; _settings = settings;
_windowName = windowName;
int[] fpsChoices = FpsChoices.Contains(settings.GifFps) ? FpsChoices : FpsChoices.Append(settings.GifFps).Order().ToArray(); int[] fpsChoices = FpsChoices.Contains(settings.GifFps) ? FpsChoices : FpsChoices.Append(settings.GifFps).Order().ToArray();
foreach (int fps in fpsChoices) foreach (int fps in fpsChoices)
@@ -382,8 +386,11 @@ public partial class ScreencastWindow : Window
} }
} }
private string DefaultFileName => private string Extension => Format == ScreencastFormat.Gif ? ".gif" : ".mp4";
$"Screencast {DateTime.Now:yyyy-MM-dd HHmmss}{(Format == ScreencastFormat.Gif ? ".gif" : ".mp4")}";
private static string DefaultName => $"Screencast {DateTime.Now:yyyy-MM-dd HHmmss}";
private string DefaultFileName => DefaultName + Extension;
private static string DefaultFolder => private static string DefaultFolder =>
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyVideos), "kniip"); Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyVideos), "kniip");
@@ -413,6 +420,7 @@ public partial class ScreencastWindow : Window
{ {
_kept = true; _kept = true;
RememberChoices(); RememberChoices();
AddToHistory(target, null, target);
bool copy = _settings.SaveButton == SaveButtonChoice.SaveAndCopy; bool copy = _settings.SaveButton == SaveButtonChoice.SaveAndCopy;
bool copied = !copy || ClipboardWriter.SetFile(target); bool copied = !copy || ClipboardWriter.SetFile(target);
App.Instance.ShowBalloon(copy && copied ? "Screencast opgeslagen en gekopieerd" : "Screencast opgeslagen", target); App.Instance.ShowBalloon(copy && copied ? "Screencast opgeslagen en gekopieerd" : "Screencast opgeslagen", target);
@@ -435,8 +443,7 @@ public partial class ScreencastWindow : Window
try try
{ {
Directory.CreateDirectory(folder); return FileNameTemplate.Resolve(folder, _settings.VideoFileNameTemplate, _windowName ?? "Screencast", DefaultName, Extension);
return SaveLocation.UniquePath(folder, DefaultFileName);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -455,7 +462,7 @@ public partial class ScreencastWindow : Window
{ {
Title = "Screencast opslaan", Title = "Screencast opslaan",
Filter = gif ? "GIF-animatie (*.gif)|*.gif" : "MP4-video (*.mp4)|*.mp4", Filter = gif ? "GIF-animatie (*.gif)|*.gif" : "MP4-video (*.mp4)|*.mp4",
FileName = DefaultFileName, FileName = FileNameTemplate.SuggestedName(_settings.VideoFileNameTemplate, _windowName ?? "Screencast", DefaultName) + Extension,
InitialDirectory = folder, InitialDirectory = folder,
AddExtension = true AddExtension = true
}; };
@@ -519,6 +526,7 @@ public partial class ScreencastWindow : Window
_kept = true; _kept = true;
RememberChoices(); RememberChoices();
AddToHistory(null, url, target);
bool copied = ClipboardWriter.SetText(url); bool copied = ClipboardWriter.SetText(url);
SavedRun.Text = copied ? $"Geüpload; de link staat op het klembord: {url} " : $"Geüpload: {url} "; SavedRun.Text = copied ? $"Geüpload; de link staat op het klembord: {url} " : $"Geüpload: {url} ";
OpenRun.Text = "Link openen"; OpenRun.Text = "Link openen";
@@ -548,6 +556,14 @@ public partial class ScreencastWindow : Window
} }
} }
/// <param name="exported">Het geëxporteerde bestand, voor de miniatuur van een GIF.</param>
private void AddToHistory(string? file, string? url, string exported)
{
bool gif = Format == ScreencastFormat.Gif;
Kniip.History.HistoryStore.Add(gif ? Kniip.History.HistoryKind.Gif : Kniip.History.HistoryKind.Mp4, file, url,
gif ? Kniip.History.HistoryStore.PreviewOf(exported) : null);
}
private void RememberChoices() private void RememberChoices()
{ {
_settings.LastScreencastFormat = Format; _settings.LastScreencastFormat = Format;
+78
View File
@@ -1,3 +1,4 @@
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Windows.Controls; using System.Windows.Controls;
using Kniip.Editor; using Kniip.Editor;
@@ -32,6 +33,27 @@ public enum SaveButtonChoice
SaveAndCopy SaveAndCopy
} }
public enum EditorLayout
{
/// <summary>Eén werkbalk met gereedschappen en acties.</summary>
Single,
/// <summary>Split-weergave: gereedschappen boven, opslaan/kopiëren en zoom onder.</summary>
Split,
/// <summary>Als Split, met Nieuw en Screencast in een zijbalk naast het canvas.</summary>
ThreeSplit
}
public enum ColorMode
{
Light,
Dark,
/// <summary>De app-modus van Windows volgen (licht of donker).</summary>
System
}
public sealed class AppSettings public sealed class AppSettings
{ {
public bool WatchClipboard { get; set; } = true; public bool WatchClipboard { get; set; } = true;
@@ -44,6 +66,9 @@ public sealed class AppSettings
/// <summary>Map waarin knipsels zonder te vragen worden opgeslagen; leeg betekent elke keer vragen.</summary> /// <summary>Map waarin knipsels zonder te vragen worden opgeslagen; leeg betekent elke keer vragen.</summary>
public string? SnipSaveFolder { get; set; } public string? SnipSaveFolder { get; set; }
/// <summary>Naamsjabloon voor knipsels (zie FileNameTemplate); leeg betekent "Knipsel" met datum en tijd.</summary>
public string? SnipFileNameTemplate { get; set; }
public Dictionary<ToolKind, StyleSet> ToolStyles { get; set; } = new(); public Dictionary<ToolKind, StyleSet> ToolStyles { get; set; } = new();
/// <summary>Recent gekozen emoji (codepunten), nieuwste eerst.</summary> /// <summary>Recent gekozen emoji (codepunten), nieuwste eerst.</summary>
@@ -60,6 +85,12 @@ public sealed class AppSettings
/// <summary>Sneltoets voor een screencast die als GIF wordt aangeboden.</summary> /// <summary>Sneltoets voor een screencast die als GIF wordt aangeboden.</summary>
public string GifHotkey { get; set; } = "Ctrl+Shift+G"; public string GifHotkey { get; set; } = "Ctrl+Shift+G";
/// <summary>Sneltoets om de afbeelding op het klembord in de editor te openen; standaard geen.</summary>
public string ClipboardHotkey { get; set; } = "";
/// <summary>Sneltoets om een rechthoek te kiezen en de tekst erin te herkennen; standaard geen.</summary>
public string OcrHotkey { get; set; } = "";
public bool RecordSystemAudio { get; set; } = true; public bool RecordSystemAudio { get; set; } = true;
public bool RecordMicrophone { get; set; } public bool RecordMicrophone { get; set; }
public bool RecordShowCursor { get; set; } = true; public bool RecordShowCursor { get; set; } = true;
@@ -73,6 +104,9 @@ public sealed class AppSettings
/// <summary>Map waarin screencasts zonder te vragen worden opgeslagen; leeg betekent elke keer vragen.</summary> /// <summary>Map waarin screencasts zonder te vragen worden opgeslagen; leeg betekent elke keer vragen.</summary>
public string? VideoSaveFolder { get; set; } public string? VideoSaveFolder { get; set; }
/// <summary>Naamsjabloon voor screencasts (zie FileNameTemplate); leeg betekent "Screencast" met datum en tijd.</summary>
public string? VideoFileNameTemplate { get; set; }
/// <summary>Gedrag van de knop Opslaan in de editor en de screencast.</summary> /// <summary>Gedrag van de knop Opslaan in de editor en de screencast.</summary>
public SaveButtonChoice SaveButton { get; set; } = SaveButtonChoice.SaveOnly; public SaveButtonChoice SaveButton { get; set; } = SaveButtonChoice.SaveOnly;
@@ -85,11 +119,55 @@ public sealed class AppSettings
/// <summary>Volgorde van de werkbalkitems (ItemId's); leeg betekent de standaardvolgorde.</summary> /// <summary>Volgorde van de werkbalkitems (ItemId's); leeg betekent de standaardvolgorde.</summary>
public List<string> ToolbarOrder { get; set; } = new(); public List<string> ToolbarOrder { get; set; } = new();
/// <summary>Kleurmodus van alle vensters van kniip.</summary>
public ColorMode ColorMode { get; set; } = ColorMode.Light;
/// <summary>Indeling van de werkbalk(en) in de editor.</summary>
public EditorLayout EditorLayout { get; set; } = EditorLayout.Split;
/// <summary>Volgorde van de werkbalkitems in de gesplitste indeling (boven, dan onder); leeg betekent de standaardvolgorde.</summary>
public List<string> SplitToolbarOrder { get; set; } = new();
/// <summary>Kant van het canvas waar de balk met Nieuw en Screencast van de drie-split-indeling staat.</summary>
public Dock CaptureBarPosition { get; set; } = Dock.Left;
/// <summary>Nieuw en Screencast zwevend boven het canvas tonen in plaats van in een balk aan CaptureBarPosition.</summary>
public bool CaptureBarFloating { get; set; }
/// <summary>Positie van de zwevende knoppen als fractie (0-1) van de vrije ruimte boven het canvas, horizontaal.</summary>
public double CaptureBarFloatX { get; set; }
/// <summary>Positie van de zwevende knoppen als fractie (0-1) van de vrije ruimte boven het canvas, verticaal.</summary>
public double CaptureBarFloatY { get; set; } = 0.5;
/// <summary>De zwevende knoppen staan vast en kunnen niet versleept worden.</summary>
public bool CaptureBarFloatLocked { get; set; }
public List<string> ToolbarHidden { get; set; } = new(); public List<string> ToolbarHidden { get; set; } = new();
/// <summary>Linialen en hulplijnen in de editor tonen; objecten klikken dan ook vast aan hulplijnen en de randen en het midden van de afbeelding.</summary>
public bool ShowRulers { get; set; } = true;
/// <summary>Zipline-server waar Uploaden naartoe gaat; leeg betekent geen uploadknop.</summary> /// <summary>Zipline-server waar Uploaden naartoe gaat; leeg betekent geen uploadknop.</summary>
public UploaderConfig? Uploader { get; set; } public UploaderConfig? Uploader { get; set; }
/// <summary>Bij het starten en daarna dagelijks op de achtergrond kijken of er een nieuwere versie is.</summary>
public bool CheckForUpdates { get; set; } = true;
/// <summary>Vergrootglas met kruisdraad en coördinaten tonen bij het kiezen van een gebied (knipsel en screencast).</summary>
public bool ShowMagnifier { get; set; } = true;
private int _captureDelaySeconds;
/// <summary>Seconden wachten voordat een rechthoek- of vensterknipsel het scherm bevriest (0-60); 0 is direct.</summary>
public int CaptureDelaySeconds
{
get => _captureDelaySeconds;
set => _captureDelaySeconds = Math.Clamp(value, 0, MaxCaptureDelaySeconds);
}
public const int MaxCaptureDelaySeconds = 60;
public StyleSet StyleFor(ToolKind tool) public StyleSet StyleFor(ToolKind tool)
{ {
if (!ToolStyles.TryGetValue(tool, out StyleSet? style)) if (!ToolStyles.TryGetValue(tool, out StyleSet? style))
+117
View File
@@ -0,0 +1,117 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text.RegularExpressions;
namespace Kniip.Settings;
/// <summary>
/// Naamsjabloon voor opslaan: %J jaar, %M maand, %D dag, %V programmanaam van het venster,
/// %Rn n willekeurige tekens, %Nn eerste vrije nummer in de map met n cijfers. / of \ maakt submappen.
/// </summary>
public static class FileNameTemplate
{
private const int MaxWidth = 32;
private const string RandomChars = "abcdefghijklmnopqrstuvwxyz0123456789";
private static readonly Regex Token = new(@"%(?:(?<k>[JMDV])|(?<k>[RN])(?<n>\d{1,2}))",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
public const string Help =
"%J jaar, %M maand, %D dag, %V programmanaam van het venster, %R6 zes willekeurige tekens, %N3 oplopend nummer met drie cijfers (001). " +
"Met / maak je submappen. De extensie kiest kniip zelf.";
/// <summary>
/// Volledig pad in <paramref name="folder"/> dat nog niet bestaat; de submappen worden aangemaakt.
/// Een leeg sjabloon geeft <paramref name="defaultName"/>.
/// </summary>
public static string Resolve(string folder, string? template, string windowName, string defaultName, string extension)
{
var randoms = new Dictionary<int, string>();
DateTime now = DateTime.Now;
bool counts = HasCounter(template);
for (int number = 1; ; number++)
{
List<string> parts = Parts(template, windowName, defaultName, now, number, randoms);
string directory = Path.Combine(new[] { folder }.Concat(parts.SkipLast(1)).ToArray());
Directory.CreateDirectory(directory);
string fileName = parts[^1] + extension;
if (!counts)
{
return SaveLocation.UniquePath(directory, fileName);
}
string path = Path.Combine(directory, fileName);
if (!File.Exists(path))
{
return path;
}
}
}
/// <summary>Voorbeeld van het pad onder de opslagmap, zonder naar bestaande bestanden te kijken.</summary>
public static string Preview(string? template, string windowName, string defaultName, string extension) =>
string.Join(Path.DirectorySeparatorChar, Parts(template, windowName, defaultName, DateTime.Now, 1, new Dictionary<int, string>())) + extension;
/// <summary>Alleen de bestandsnaam zonder extensie, als voorstel in een opslagvenster.</summary>
public static string SuggestedName(string? template, string windowName, string defaultName) =>
Parts(template, windowName, defaultName, DateTime.Now, 1, new Dictionary<int, string>())[^1];
private static bool HasCounter(string? template) =>
!string.IsNullOrWhiteSpace(template)
&& Token.Matches(template).Any(m => char.ToUpperInvariant(m.Groups["k"].Value[0]) == 'N');
/// <summary>Opgeschoonde mapnamen gevolgd door de bestandsnaam zonder extensie.</summary>
private static List<string> Parts(string? template, string windowName, string defaultName, DateTime now, int number, Dictionary<int, string> randoms)
{
var parts = new List<string>();
if (!string.IsNullOrWhiteSpace(template))
{
string expanded = Token.Replace(template, m => Value(m, windowName, now, number, randoms));
parts.AddRange(expanded.Split('/', '\\').Select(Clean).Where(p => p.Length > 0));
}
if (parts.Count == 0 || template!.TrimEnd().EndsWith('/') || template.TrimEnd().EndsWith('\\'))
{
parts.Add(Clean(defaultName));
}
return parts;
}
private static string Value(Match match, string windowName, DateTime now, int number, Dictionary<int, string> randoms)
{
int width = match.Groups["n"].Success ? Math.Min(int.Parse(match.Groups["n"].Value, CultureInfo.InvariantCulture), MaxWidth) : 0;
switch (char.ToUpperInvariant(match.Groups["k"].Value[0]))
{
case 'J':
return now.Year.ToString(CultureInfo.InvariantCulture);
case 'M':
return now.Month.ToString("00", CultureInfo.InvariantCulture);
case 'D':
return now.Day.ToString("00", CultureInfo.InvariantCulture);
case 'V':
return windowName.Replace('/', '_').Replace('\\', '_');
case 'R':
if (!randoms.TryGetValue(match.Index, out string? random))
{
random = new string(Enumerable.Range(0, width).Select(_ => RandomChars[RandomNumberGenerator.GetInt32(RandomChars.Length)]).ToArray());
randoms[match.Index] = random;
}
return random;
default:
return number.ToString(CultureInfo.InvariantCulture).PadLeft(width, '0');
}
}
private static string Clean(string segment)
{
char[] invalid = Path.GetInvalidFileNameChars();
string cleaned = new(segment.Select(c => invalid.Contains(c) ? '_' : c).ToArray());
return cleaned.Trim().TrimEnd('.').Trim();
}
}
+8 -3
View File
@@ -7,19 +7,24 @@ using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using System.Windows.Media; using System.Windows.Media;
using Kniip.Infrastructure;
namespace Kniip.Settings; namespace Kniip.Settings;
public static class SettingsStore public static class SettingsStore
{ {
public static string Folder { get; } = public static string Folder { get; } =
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Kniip"); Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), AppIdentity.Key);
private static string FilePath => Path.Combine(Folder, "settings.json"); private static string FilePath => Path.Combine(Folder, "settings.json");
/// <summary>Instellingen van voor de hernoeming van Knip+ naar kniip.</summary> /// <summary>
/// Startpunt als er nog geen instellingen zijn: voor kniip die van voor de hernoeming van Knip+,
/// voor de testbuild een kopie van die van de gewone kniip.
/// </summary>
private static string LegacyFilePath => private static string LegacyFilePath =>
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "KnipPlus", "settings.json"); Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
AppIdentity.IsTest ? "Kniip" : "KnipPlus", "settings.json");
private static readonly JsonSerializerOptions Options = new() private static readonly JsonSerializerOptions Options = new()
{ {
+125 -49
View File
@@ -1,29 +1,36 @@
<Window x:Class="Kniip.Settings.SettingsWindow" <Window x:Class="Kniip.Settings.SettingsWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="kniip instellingen" Width="720" Height="540" ResizeMode="NoResize" Title="kniip instellingen" Width="720" Height="600" ResizeMode="NoResize"
WindowStartupLocation="CenterScreen" Background="{StaticResource WindowBrush}" WindowStartupLocation="CenterScreen" Background="{DynamicResource WindowBrush}" Foreground="{DynamicResource InkBrush}"
FontFamily="Segoe UI" FontSize="13" Icon="pack://application:,,,/AppIcon.ico"> FontFamily="Segoe UI" FontSize="13" Icon="pack://application:,,,/AppIcon.ico">
<Window.Resources> <Window.Resources>
<!-- Inhoud van een tab erft anders de blauwe, vette tekst van de geselecteerde tabkop -->
<Style x:Key="TabPage" TargetType="StackPanel">
<Setter Property="TextElement.Foreground" Value="{DynamicResource InkBrush}" />
<Setter Property="TextElement.FontWeight" Value="Normal" />
</Style>
<Style x:Key="CardTitle" TargetType="TextBlock"> <Style x:Key="CardTitle" TargetType="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource AccentTextBrush}" />
<Setter Property="FontWeight" Value="SemiBold" /> <Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="FontSize" Value="14" /> <Setter Property="FontSize" Value="14" />
<Setter Property="Margin" Value="0,0,0,8" /> <Setter Property="Margin" Value="0,0,0,8" />
</Style> </Style>
<Style TargetType="CheckBox"> <Style TargetType="CheckBox" BasedOn="{StaticResource {x:Type CheckBox}}">
<Setter Property="Margin" Value="0,4" /> <Setter Property="Margin" Value="0,4" />
</Style> </Style>
<Style TargetType="RadioButton"> <Style TargetType="RadioButton" BasedOn="{StaticResource {x:Type RadioButton}}">
<Setter Property="Margin" Value="0,4" /> <Setter Property="Margin" Value="0,4" />
</Style> </Style>
<Style x:Key="Hint" TargetType="TextBlock"> <Style x:Key="Hint" TargetType="TextBlock">
<Setter Property="Foreground" Value="{StaticResource SubtleTextBrush}" /> <Setter Property="Foreground" Value="{DynamicResource SubtleTextBrush}" />
<Setter Property="FontSize" Value="12" /> <Setter Property="FontSize" Value="12" />
<Setter Property="TextWrapping" Value="Wrap" /> <Setter Property="TextWrapping" Value="Wrap" />
<Setter Property="Margin" Value="22,0,0,4" /> <Setter Property="Margin" Value="22,0,0,4" />
</Style> </Style>
<Style x:Key="SettingsTabItem" TargetType="TabItem"> <Style x:Key="SettingsTabItem" TargetType="TabItem">
<Setter Property="FocusVisualStyle" Value="{x:Null}" /> <Setter Property="FocusVisualStyle" Value="{x:Null}" />
<Setter Property="Foreground" Value="{DynamicResource InkBrush}" />
<Setter Property="HorizontalAlignment" Value="Stretch" /> <Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="HorizontalContentAlignment" Value="Left" /> <Setter Property="HorizontalContentAlignment" Value="Left" />
<Setter Property="Padding" Value="12,9" /> <Setter Property="Padding" Value="12,9" />
@@ -42,12 +49,12 @@
<Condition Property="IsMouseOver" Value="True" /> <Condition Property="IsMouseOver" Value="True" />
<Condition Property="IsSelected" Value="False" /> <Condition Property="IsSelected" Value="False" />
</MultiTrigger.Conditions> </MultiTrigger.Conditions>
<Setter TargetName="Bd" Property="Background" Value="{StaticResource HoverBrush}" /> <Setter TargetName="Bd" Property="Background" Value="{DynamicResource HoverBrush}" />
</MultiTrigger> </MultiTrigger>
<Trigger Property="IsSelected" Value="True"> <Trigger Property="IsSelected" Value="True">
<Setter TargetName="Bd" Property="Background" Value="White" /> <Setter TargetName="Bd" Property="Background" Value="{DynamicResource CardBrush}" />
<Setter TargetName="Bd" Property="BorderBrush" Value="{StaticResource AccentBrush}" /> <Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource AccentBrush}" />
<Setter Property="Foreground" Value="{StaticResource AccentBrush}" /> <Setter Property="Foreground" Value="{DynamicResource AccentTextBrush}" />
<Setter Property="FontWeight" Value="SemiBold" /> <Setter Property="FontWeight" Value="SemiBold" />
</Trigger> </Trigger>
</ControlTemplate.Triggers> </ControlTemplate.Triggers>
@@ -69,12 +76,9 @@
<ColumnDefinition Width="*" /> <ColumnDefinition Width="*" />
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<TabPanel Grid.Column="0" IsItemsHost="True" VerticalAlignment="Top" Background="Transparent" /> <TabPanel Grid.Column="0" IsItemsHost="True" VerticalAlignment="Top" Background="Transparent" />
<Border Grid.Column="1" Background="White" BorderBrush="#E5E5E5" BorderThickness="1" <Border Grid.Column="1" Background="{DynamicResource CardBrush}" BorderBrush="{DynamicResource CardBorderBrush}" BorderThickness="1"
CornerRadius="6" SnapsToDevicePixels="True"> CornerRadius="6" SnapsToDevicePixels="True" Padding="16,14">
<ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled" <ContentPresenter ContentSource="SelectedContent" />
Padding="16,14">
<ContentPresenter ContentSource="SelectedContent" />
</ScrollViewer>
</Border> </Border>
</Grid> </Grid>
</ControlTemplate> </ControlTemplate>
@@ -96,18 +100,8 @@
</DockPanel> </DockPanel>
<TabControl Style="{StaticResource SettingsTabs}" SelectionChanged="Tabs_SelectionChanged"> <TabControl Style="{StaticResource SettingsTabs}" SelectionChanged="Tabs_SelectionChanged">
<TabItem Header="Knipsels">
<StackPanel>
<TextBlock Text="Knipsels oppikken" Style="{StaticResource CardTitle}" />
<CheckBox x:Name="WatchClipboardBox" Content="Klembord bewaken" />
<CheckBox x:Name="OnlySnippingToolBox" Content="Alleen afbeeldingen van het Knipprogramma" Margin="22,2,0,4" />
<CheckBox x:Name="WatchFolderBox" Content="Map Schermafbeeldingen bewaken" />
<TextBlock x:Name="FolderText" Style="{StaticResource Hint}" />
</StackPanel>
</TabItem>
<TabItem Header="Sneltoetsen"> <TabItem Header="Sneltoetsen">
<StackPanel> <StackPanel Style="{StaticResource TabPage}">
<TextBlock Text="Sneltoetsen" Style="{StaticResource CardTitle}" /> <TextBlock Text="Sneltoetsen" Style="{StaticResource CardTitle}" />
<StackPanel Orientation="Horizontal" Margin="0,2,0,4"> <StackPanel Orientation="Horizontal" Margin="0,2,0,4">
<TextBlock Text="Rechthoek" VerticalAlignment="Center" Width="150" /> <TextBlock Text="Rechthoek" VerticalAlignment="Center" Width="150" />
@@ -130,30 +124,35 @@
<Button Tag="Record" Style="{StaticResource FlatButton}" Content="Geen" Click="ClearHotkey_Click" Margin="6,0,0,0" <Button Tag="Record" Style="{StaticResource FlatButton}" Content="Geen" Click="ClearHotkey_Click" Margin="6,0,0,0"
ToolTip="Geen sneltoets gebruiken" /> ToolTip="Geen sneltoets gebruiken" />
</StackPanel> </StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,6"> <StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="Screencast (GIF)" VerticalAlignment="Center" Width="150" /> <TextBlock Text="Screencast (GIF)" VerticalAlignment="Center" Width="150" />
<Button x:Name="GifHotkeyButton" Tag="Gif" Style="{StaticResource SecondaryButton}" Width="170" Margin="0" <Button x:Name="GifHotkeyButton" Tag="Gif" Style="{StaticResource SecondaryButton}" Width="170" Margin="0"
Click="HotkeyButton_Click" ToolTip="Klik en druk daarna de gewenste toetscombinatie in" /> Click="HotkeyButton_Click" ToolTip="Klik en druk daarna de gewenste toetscombinatie in" />
<Button Tag="Gif" Style="{StaticResource FlatButton}" Content="Geen" Click="ClearHotkey_Click" Margin="6,0,0,0" <Button Tag="Gif" Style="{StaticResource FlatButton}" Content="Geen" Click="ClearHotkey_Click" Margin="6,0,0,0"
ToolTip="Geen sneltoets gebruiken" /> ToolTip="Geen sneltoets gebruiken" />
</StackPanel> </StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="Vanaf klembord" VerticalAlignment="Center" Width="150" />
<Button x:Name="ClipboardHotkeyButton" Tag="Clipboard" Style="{StaticResource SecondaryButton}" Width="170" Margin="0"
Click="HotkeyButton_Click" ToolTip="Klik en druk daarna de gewenste toetscombinatie in; opent de afbeelding op het klembord" />
<Button Tag="Clipboard" Style="{StaticResource FlatButton}" Content="Geen" Click="ClearHotkey_Click" Margin="6,0,0,0"
ToolTip="Geen sneltoets gebruiken" />
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,6">
<TextBlock Text="OCR (tekst herkennen)" VerticalAlignment="Center" Width="150" />
<Button x:Name="OcrHotkeyButton" Tag="Ocr" Style="{StaticResource SecondaryButton}" Width="170" Margin="0"
Click="HotkeyButton_Click" ToolTip="Klik en druk daarna de gewenste toetscombinatie in; sleep daarna een rechthoek om de tekst erin te lezen" />
<Button Tag="Ocr" Style="{StaticResource FlatButton}" Content="Geen" Click="ClearHotkey_Click" Margin="6,0,0,0"
ToolTip="Geen sneltoets gebruiken" />
</StackPanel>
<TextBlock x:Name="HotkeyHint" Style="{StaticResource Hint}" Margin="0,0,0,4" /> <TextBlock x:Name="HotkeyHint" Style="{StaticResource Hint}" Margin="0,0,0,4" />
</StackPanel> <TextBlock Text="Gebied kiezen" Style="{StaticResource CardTitle}" Margin="0,12,0,8" />
</TabItem> <CheckBox x:Name="MagnifierBox" Content="Vergrootglas tonen bij het kiezen van een gebied (knipsel en screencast)" />
<TabItem Header="Nieuw knipsel">
<StackPanel>
<TextBlock Text="Bij een nieuw knipsel" Style="{StaticResource CardTitle}" />
<RadioButton x:Name="ActionNotifyRadio" GroupName="Action" Content="Melding tonen (klik om te bewerken)" />
<RadioButton x:Name="ActionOpenRadio" GroupName="Action" Content="Editor meteen openen" />
<RadioButton x:Name="ActionNothingRadio" GroupName="Action" Content="Niets doen (openen via systeemvak of 'Openen met')" />
<TextBlock Style="{StaticResource Hint}" Margin="0,8,0,0"
Text="Tip: zet in het Knipprogramma 'Wijzigingen automatisch kopiëren' uit, anders ziet kniip elke pennenstreek in het Knipprogramma als nieuw knipsel." />
</StackPanel> </StackPanel>
</TabItem> </TabItem>
<TabItem Header="Opslaan"> <TabItem Header="Opslaan">
<StackPanel> <StackPanel Style="{StaticResource TabPage}">
<TextBlock Text="Opslaan" Style="{StaticResource CardTitle}" /> <TextBlock Text="Opslaan" Style="{StaticResource CardTitle}" />
<DockPanel Margin="0,2,0,4"> <DockPanel Margin="0,2,0,4">
<TextBlock Text="Knipsels" VerticalAlignment="Center" Width="110" /> <TextBlock Text="Knipsels" VerticalAlignment="Center" Width="110" />
@@ -163,6 +162,12 @@
Margin="6,0,0,0" /> Margin="6,0,0,0" />
<TextBlock x:Name="SnipFolderText" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" /> <TextBlock x:Name="SnipFolderText" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
</DockPanel> </DockPanel>
<DockPanel Margin="0,0,0,2">
<TextBlock Text="Naam" VerticalAlignment="Center" Width="110" Padding="12,0,0,0" />
<TextBox x:Name="SnipTemplateBox" Height="26" VerticalContentAlignment="Center" TextChanged="Template_TextChanged"
ToolTip="Leeg: Knipsel met datum en tijd" />
</DockPanel>
<TextBlock x:Name="SnipTemplatePreview" Style="{StaticResource Hint}" Margin="110,0,0,8" TextTrimming="CharacterEllipsis" />
<DockPanel Margin="0,0,0,4"> <DockPanel Margin="0,0,0,4">
<TextBlock Text="Screencasts" VerticalAlignment="Center" Width="110" /> <TextBlock Text="Screencasts" VerticalAlignment="Center" Width="110" />
<Button Tag="Video" DockPanel.Dock="Right" Style="{StaticResource FlatButton}" Content="Vragen" Click="ClearSaveFolder_Click" <Button Tag="Video" DockPanel.Dock="Right" Style="{StaticResource FlatButton}" Content="Vragen" Click="ClearSaveFolder_Click"
@@ -171,8 +176,15 @@
Margin="6,0,0,0" /> Margin="6,0,0,0" />
<TextBlock x:Name="VideoFolderText" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" /> <TextBlock x:Name="VideoFolderText" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
</DockPanel> </DockPanel>
<DockPanel Margin="0,0,0,2">
<TextBlock Text="Naam" VerticalAlignment="Center" Width="110" Padding="12,0,0,0" />
<TextBox x:Name="VideoTemplateBox" Height="26" VerticalContentAlignment="Center" TextChanged="Template_TextChanged"
ToolTip="Leeg: Screencast met datum en tijd" />
</DockPanel>
<TextBlock x:Name="VideoTemplatePreview" Style="{StaticResource Hint}" Margin="110,0,0,8" TextTrimming="CharacterEllipsis" />
<TextBlock x:Name="TemplateHelp" Style="{StaticResource Hint}" Margin="0,0,0,4" />
<TextBlock Style="{StaticResource Hint}" Margin="0,4,0,8" <TextBlock Style="{StaticResource Hint}" Margin="0,4,0,8"
Text="Met een map ingesteld slaat Opslaan (Ctrl+S) direct op, met datum en tijd in de naam. Shift+klik of Ctrl+Shift+S vraagt toch waar het bestand moet komen." /> Text="Met een map ingesteld slaat Opslaan (Ctrl+S) direct op, met de naam hierboven. Shift+klik of Ctrl+Shift+S vraagt toch waar het bestand moet komen." />
<RadioButton x:Name="SaveOnlyRadio" GroupName="SaveButton" Content="Opslaan" /> <RadioButton x:Name="SaveOnlyRadio" GroupName="SaveButton" Content="Opslaan" />
<RadioButton x:Name="SaveAndCopyRadio" GroupName="SaveButton" Content="Opslaan en kopiëren" /> <RadioButton x:Name="SaveAndCopyRadio" GroupName="SaveButton" Content="Opslaan en kopiëren" />
<TextBlock Style="{StaticResource Hint}" <TextBlock Style="{StaticResource Hint}"
@@ -181,7 +193,7 @@
</TabItem> </TabItem>
<TabItem Header="Uploaden"> <TabItem Header="Uploaden">
<StackPanel> <StackPanel Style="{StaticResource TabPage}">
<TextBlock Text="Uploaden naar Zipline" Style="{StaticResource CardTitle}" /> <TextBlock Text="Uploaden naar Zipline" Style="{StaticResource CardTitle}" />
<DockPanel Margin="0,2,0,4"> <DockPanel Margin="0,2,0,4">
<TextBlock Text="Server" VerticalAlignment="Center" Width="110" /> <TextBlock Text="Server" VerticalAlignment="Center" Width="110" />
@@ -202,7 +214,7 @@
</TabItem> </TabItem>
<TabItem Header="Screencasts"> <TabItem Header="Screencasts">
<StackPanel> <StackPanel Style="{StaticResource TabPage}">
<TextBlock Text="Screencasts" Style="{StaticResource CardTitle}" /> <TextBlock Text="Screencasts" Style="{StaticResource CardTitle}" />
<TextBlock Style="{StaticResource Hint}" Margin="0,0,0,6" <TextBlock Style="{StaticResource Hint}" Margin="0,0,0,6"
Text="Sneltoets: eerste keer gebied kiezen, tweede keer opname starten, derde keer stoppen. De nabewerking staat daarna op MP4 of GIF." /> Text="Sneltoets: eerste keer gebied kiezen, tweede keer opname starten, derde keer stoppen. De nabewerking staat daarna op MP4 of GIF." />
@@ -222,9 +234,48 @@
</StackPanel> </StackPanel>
</TabItem> </TabItem>
<TabItem Header="Windows"> <TabItem Header="Weergave">
<StackPanel> <StackPanel Style="{StaticResource TabPage}">
<TextBlock Text="Windows-integratie" Style="{StaticResource CardTitle}" /> <TextBlock Text="Kleurmodus" Style="{StaticResource CardTitle}" />
<RadioButton x:Name="ColorModeLightRadio" GroupName="ColorMode" Content="Licht" />
<RadioButton x:Name="ColorModeDarkRadio" GroupName="ColorMode" Content="Donker" />
<RadioButton x:Name="ColorModeSystemRadio" GroupName="ColorMode" Content="Systeem" />
<TextBlock Style="{StaticResource Hint}"
Text="Systeem volgt de app-modus van Windows (Instellingen > Persoonlijke instellingen > Kleuren) en wisselt mee als je die verandert." />
<TextBlock Text="Editor" Style="{StaticResource CardTitle}" Margin="0,16,0,6" />
<CheckBox x:Name="StraightHighlightBox" Content="Markeerstift trekt standaard een rechte horizontale streep (Shift wisselt)" />
<TextBlock Text="Werkbalk" Style="{StaticResource CardTitle}" Margin="0,16,0,6" />
<RadioButton x:Name="LayoutSplitRadio" GroupName="EditorLayout" Content="Split-weergave: gereedschappen boven, opslaan onder" />
<RadioButton x:Name="LayoutThreeSplitRadio" GroupName="EditorLayout" Content="Drie-split: gereedschappen boven, opslaan onder, Nieuw en Screencast apart" />
<StackPanel Orientation="Horizontal" Margin="22,2,0,4" IsEnabled="{Binding IsChecked, ElementName=LayoutThreeSplitRadio}">
<RadioButton x:Name="CaptureBarLeftRadio" GroupName="CaptureBarPosition" Content="Links" Margin="0,0,16,0" />
<RadioButton x:Name="CaptureBarRightRadio" GroupName="CaptureBarPosition" Content="Rechts" Margin="0,0,16,0" />
<RadioButton x:Name="CaptureBarTopRadio" GroupName="CaptureBarPosition" Content="Boven" Margin="0,0,16,0" />
<RadioButton x:Name="CaptureBarBottomRadio" GroupName="CaptureBarPosition" Content="Onder" Margin="0,0,16,0" />
<RadioButton x:Name="CaptureBarFloatRadio" GroupName="CaptureBarPosition" Content="Zwevend" />
</StackPanel>
<RadioButton x:Name="LayoutSingleRadio" GroupName="EditorLayout" Content="Eén werkbalk met gereedschappen en acties" />
<TextBlock Style="{StaticResource Hint}"
Text="Ook te wisselen via rechtsklik op de werkbalk in de editor. Daar kun je knoppen ook verslepen of verbergen." />
</StackPanel>
</TabItem>
<TabItem Header="Systeem">
<StackPanel Style="{StaticResource TabPage}">
<TextBlock Text="Knipprogramma" Style="{StaticResource CardTitle}" />
<CheckBox x:Name="WatchClipboardBox" Content="Klembord bewaken" />
<CheckBox x:Name="OnlySnippingToolBox" Content="Alleen afbeeldingen van het Knipprogramma" Margin="22,2,0,4" />
<CheckBox x:Name="WatchFolderBox" Content="Map Schermafbeeldingen bewaken" />
<TextBlock x:Name="FolderText" Style="{StaticResource Hint}" />
<TextBlock Text="Bij een nieuw knipsel" Style="{StaticResource CardTitle}" Margin="0,16,0,6" />
<RadioButton x:Name="ActionNotifyRadio" GroupName="Action" Content="Melding tonen (klik om te bewerken)" />
<RadioButton x:Name="ActionOpenRadio" GroupName="Action" Content="Editor meteen openen" />
<RadioButton x:Name="ActionNothingRadio" GroupName="Action" Content="Niets doen (openen via systeemvak of 'Openen met')" />
<TextBlock Style="{StaticResource Hint}" Margin="0,8,0,0"
Text="Tip: zet in het Knipprogramma 'Wijzigingen automatisch kopiëren' uit, anders ziet kniip elke pennenstreek in het Knipprogramma als nieuw knipsel." />
<TextBlock Text="Windows-integratie" Style="{StaticResource CardTitle}" Margin="0,16,0,6" />
<CheckBox x:Name="AutostartBox" Content="kniip starten met Windows" /> <CheckBox x:Name="AutostartBox" Content="kniip starten met Windows" />
<CheckBox x:Name="OpenWithBox" Content="kniip tonen in 'Openen met' (ook in het Knipprogramma: ... → Openen met)" /> <CheckBox x:Name="OpenWithBox" Content="kniip tonen in 'Openen met' (ook in het Knipprogramma: ... → Openen met)" />
<TextBlock Style="{StaticResource Hint}" <TextBlock Style="{StaticResource Hint}"
@@ -232,10 +283,35 @@
</StackPanel> </StackPanel>
</TabItem> </TabItem>
<TabItem Header="Editor"> <TabItem x:Name="UpdateTab">
<StackPanel> <TabItem.Header>
<TextBlock Text="Editor" Style="{StaticResource CardTitle}" /> <StackPanel Orientation="Horizontal">
<CheckBox x:Name="StraightHighlightBox" Content="Markeerstift trekt standaard een rechte horizontale streep (Shift wisselt)" /> <TextBlock Text="Update" />
<Ellipse x:Name="UpdateTabBadge" Width="8" Height="8" Fill="{DynamicResource AccentBrush}" Stroke="{DynamicResource WindowBrush}"
StrokeThickness="1" Margin="6,0,0,0" VerticalAlignment="Center" Visibility="Collapsed"
ToolTip="Er is een nieuwe versie beschikbaar" />
</StackPanel>
</TabItem.Header>
<StackPanel Style="{StaticResource TabPage}">
<TextBlock Text="Update" Style="{StaticResource CardTitle}" />
<TextBlock x:Name="CurrentVersionText" Margin="0,0,0,2" />
<CheckBox x:Name="AutoUpdateCheckBox" Content="Automatisch controleren op nieuwe versies (bij starten en dagelijks)" />
<TextBlock x:Name="UpdateStatus" Style="{StaticResource Hint}" Margin="0,0,0,8" />
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
<Button x:Name="UpdateButton" Style="{StaticResource AccentButton}" Click="Update_Click" Margin="0,0,6,0"
Visibility="Collapsed" ToolTip="Installer downloaden; kniip sluit, wordt bijgewerkt en start opnieuw" />
<Button x:Name="CheckUpdateButton" Style="{StaticResource SecondaryButton}" Content="Opnieuw controleren"
Click="CheckUpdate_Click" Margin="0,0,6,0" />
<Button Style="{StaticResource FlatButton}" Content="Releasepagina" Click="ReleasePage_Click"
ToolTip="https://git.hibbit.net/rogier/kniip/releases" />
</StackPanel>
<ProgressBar x:Name="DownloadProgress" Height="4" Minimum="0" Maximum="1" Margin="0,0,0,8"
Foreground="{DynamicResource AccentBrush}" Visibility="Collapsed" />
<Border BorderBrush="{DynamicResource DividerBrush}" BorderThickness="1" CornerRadius="4" Height="300">
<FlowDocumentScrollViewer x:Name="ChangelogViewer" VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled" IsToolBarVisible="False"
Zoom="100" MinZoom="100" MaxZoom="100" Focusable="False" />
</Border>
</StackPanel> </StackPanel>
</TabItem> </TabItem>
</TabControl> </TabControl>
+272 -3
View File
@@ -10,6 +10,7 @@ using System.Windows.Media.Imaging;
using Kniip.Capture; using Kniip.Capture;
using Kniip.Editor; using Kniip.Editor;
using Kniip.Infrastructure; using Kniip.Infrastructure;
using Kniip.Update;
using Kniip.Upload; using Kniip.Upload;
namespace Kniip.Settings; namespace Kniip.Settings;
@@ -25,6 +26,8 @@ public partial class SettingsWindow : Window
["Window"] = "venster knippen", ["Window"] = "venster knippen",
["Record"] = "screencast (MP4)", ["Record"] = "screencast (MP4)",
["Gif"] = "screencast (GIF)", ["Gif"] = "screencast (GIF)",
["Clipboard"] = "bewerken vanaf klembord",
["Ocr"] = "OCR",
}; };
private readonly AppSettings _settings; private readonly AppSettings _settings;
@@ -35,11 +38,16 @@ public partial class SettingsWindow : Window
private string? _videoFolder; private string? _videoFolder;
private UploaderConfig? _uploader; private UploaderConfig? _uploader;
private bool _resetEditor; private bool _resetEditor;
private bool _updateChecked;
private ReleaseInfo? _update;
private System.Threading.CancellationTokenSource? _updateCts;
public SettingsWindow(AppSettings settings) public SettingsWindow(AppSettings settings)
{ {
InitializeComponent(); InitializeComponent();
ThemeManager.Attach(this);
_settings = settings; _settings = settings;
Title = $"{AppIdentity.DisplayName} instellingen";
FolderText.Text = CaptureCoordinator.ScreenshotsFolder ?? "Map Schermafbeeldingen niet gevonden."; FolderText.Text = CaptureCoordinator.ScreenshotsFolder ?? "Map Schermafbeeldingen niet gevonden.";
AutostartBox.IsChecked = ShellIntegration.IsAutostartEnabled(); AutostartBox.IsChecked = ShellIntegration.IsAutostartEnabled();
@@ -58,14 +66,24 @@ public partial class SettingsWindow : Window
["Window"] = WindowHotkeyButton, ["Window"] = WindowHotkeyButton,
["Record"] = RecordHotkeyButton, ["Record"] = RecordHotkeyButton,
["Gif"] = GifHotkeyButton, ["Gif"] = GifHotkeyButton,
["Clipboard"] = ClipboardHotkeyButton,
["Ocr"] = OcrHotkeyButton,
}; };
HotkeyHint.Text = App.Instance.HotkeyHook?.IsActive == true HotkeyHint.Text = App.Instance.HotkeyHook?.IsActive == true
? HotkeyHintText ? HotkeyHintText
: "De toetsenbordhaak kon niet worden gestart; de sneltoetsen werken nu niet."; : "De toetsenbordhaak kon niet worden gestart; de sneltoetsen werken nu niet.";
Deactivated += (_, _) => StopListening(); Deactivated += (_, _) => StopListening();
Closed += (_, _) => StopListening(); App.Instance.Updates.AvailabilityChanged += OnUpdateAvailabilityChanged;
Closed += (_, _) =>
{
StopListening();
_updateCts?.Cancel();
App.Instance.Updates.AvailabilityChanged -= OnUpdateAvailabilityChanged;
};
ShowUpdateBadge();
CurrentVersionText.Text = $"Geïnstalleerde versie: {UpdateChecker.CurrentVersion}";
LoadFrom(settings); LoadFrom(settings);
MaxHeight = SystemParameters.WorkArea.Height; MaxHeight = SystemParameters.WorkArea.Height;
@@ -82,10 +100,27 @@ public partial class SettingsWindow : Window
ActionOpenRadio.IsChecked = settings.Action == CaptureAction.OpenEditor; ActionOpenRadio.IsChecked = settings.Action == CaptureAction.OpenEditor;
ActionNothingRadio.IsChecked = settings.Action == CaptureAction.Nothing; ActionNothingRadio.IsChecked = settings.Action == CaptureAction.Nothing;
StraightHighlightBox.IsChecked = settings.StraightHighlight; StraightHighlightBox.IsChecked = settings.StraightHighlight;
ColorModeLightRadio.IsChecked = settings.ColorMode == ColorMode.Light;
ColorModeDarkRadio.IsChecked = settings.ColorMode == ColorMode.Dark;
ColorModeSystemRadio.IsChecked = settings.ColorMode == ColorMode.System;
LayoutSplitRadio.IsChecked = settings.EditorLayout == EditorLayout.Split;
LayoutThreeSplitRadio.IsChecked = settings.EditorLayout == EditorLayout.ThreeSplit;
LayoutSingleRadio.IsChecked = settings.EditorLayout == EditorLayout.Single;
bool docked = !settings.CaptureBarFloating;
CaptureBarLeftRadio.IsChecked = docked && settings.CaptureBarPosition == Dock.Left;
CaptureBarRightRadio.IsChecked = docked && settings.CaptureBarPosition == Dock.Right;
CaptureBarTopRadio.IsChecked = docked && settings.CaptureBarPosition == Dock.Top;
CaptureBarBottomRadio.IsChecked = docked && settings.CaptureBarPosition == Dock.Bottom;
CaptureBarFloatRadio.IsChecked = settings.CaptureBarFloating;
AutoUpdateCheckBox.IsChecked = settings.CheckForUpdates;
MagnifierBox.IsChecked = settings.ShowMagnifier;
_snipFolder = settings.SnipSaveFolder; _snipFolder = settings.SnipSaveFolder;
_videoFolder = settings.VideoSaveFolder; _videoFolder = settings.VideoSaveFolder;
ShowSaveFolders(); ShowSaveFolders();
SnipTemplateBox.Text = settings.SnipFileNameTemplate ?? string.Empty;
VideoTemplateBox.Text = settings.VideoFileNameTemplate ?? string.Empty;
ShowTemplatePreviews();
SaveAndCopyRadio.IsChecked = settings.SaveButton == SaveButtonChoice.SaveAndCopy; SaveAndCopyRadio.IsChecked = settings.SaveButton == SaveButtonChoice.SaveAndCopy;
SaveOnlyRadio.IsChecked = settings.SaveButton != SaveButtonChoice.SaveAndCopy; SaveOnlyRadio.IsChecked = settings.SaveButton != SaveButtonChoice.SaveAndCopy;
@@ -93,6 +128,8 @@ public partial class SettingsWindow : Window
_hotkeys["Window"] = settings.WindowHotkey; _hotkeys["Window"] = settings.WindowHotkey;
_hotkeys["Record"] = settings.RecordHotkey; _hotkeys["Record"] = settings.RecordHotkey;
_hotkeys["Gif"] = settings.GifHotkey; _hotkeys["Gif"] = settings.GifHotkey;
_hotkeys["Clipboard"] = settings.ClipboardHotkey;
_hotkeys["Ocr"] = settings.OcrHotkey;
foreach (string id in _hotkeys.Keys) foreach (string id in _hotkeys.Keys)
{ {
ShowHotkey(id); ShowHotkey(id);
@@ -230,9 +267,22 @@ public partial class SettingsWindow : Window
{ {
text.Text = string.IsNullOrEmpty(folder) ? "Elke keer vragen" : folder; text.Text = string.IsNullOrEmpty(folder) ? "Elke keer vragen" : folder;
text.ToolTip = folder; text.ToolTip = folder;
text.Foreground = string.IsNullOrEmpty(folder) ? (Brush)Application.Current.Resources["SubtleTextBrush"] : SystemColors.ControlTextBrush; text.SetResourceReference(TextBlock.ForegroundProperty, string.IsNullOrEmpty(folder) ? "SubtleTextBrush" : "InkBrush");
} }
private void Template_TextChanged(object sender, TextChangedEventArgs e) => ShowTemplatePreviews();
private void ShowTemplatePreviews()
{
TemplateHelp.Text = FileNameTemplate.Help;
SnipTemplatePreview.Text = "Voorbeeld: " + FileNameTemplate.Preview(NullIfBlank(SnipTemplateBox.Text), "Cursor",
$"Knipsel {DateTime.Now:yyyy-MM-dd HHmmss}", ".png");
VideoTemplatePreview.Text = "Voorbeeld: " + FileNameTemplate.Preview(NullIfBlank(VideoTemplateBox.Text), "Cursor",
$"Screencast {DateTime.Now:yyyy-MM-dd HHmmss}", ".mp4");
}
private static string? NullIfBlank(string text) => string.IsNullOrWhiteSpace(text) ? null : text.Trim();
private void BrowseSaveFolder_Click(object sender, RoutedEventArgs e) private void BrowseSaveFolder_Click(object sender, RoutedEventArgs e)
{ {
bool snip = sender is FrameworkElement { Tag: "Snip" }; bool snip = sender is FrameworkElement { Tag: "Snip" };
@@ -280,7 +330,7 @@ public partial class SettingsWindow : Window
bool configured = _uploader is { IsConfigured: true }; bool configured = _uploader is { IsConfigured: true };
UploaderText.Text = configured ? _uploader!.DisplayName : "Niet ingesteld"; UploaderText.Text = configured ? _uploader!.DisplayName : "Niet ingesteld";
UploaderText.ToolTip = configured ? _uploader!.RequestUrl : null; UploaderText.ToolTip = configured ? _uploader!.RequestUrl : null;
UploaderText.Foreground = configured ? SystemColors.ControlTextBrush : (Brush)Application.Current.Resources["SubtleTextBrush"]; UploaderText.SetResourceReference(TextBlock.ForegroundProperty, configured ? "InkBrush" : "SubtleTextBrush");
TestUploaderButton.IsEnabled = configured; TestUploaderButton.IsEnabled = configured;
RemoveUploaderButton.IsEnabled = configured; RemoveUploaderButton.IsEnabled = configured;
} }
@@ -396,6 +446,8 @@ public partial class SettingsWindow : Window
_settings.Uploader = _uploader; _settings.Uploader = _uploader;
_settings.SnipSaveFolder = _snipFolder; _settings.SnipSaveFolder = _snipFolder;
_settings.VideoSaveFolder = _videoFolder; _settings.VideoSaveFolder = _videoFolder;
_settings.SnipFileNameTemplate = NullIfBlank(SnipTemplateBox.Text);
_settings.VideoFileNameTemplate = NullIfBlank(VideoTemplateBox.Text);
_settings.SaveButton = SaveAndCopyRadio.IsChecked == true _settings.SaveButton = SaveAndCopyRadio.IsChecked == true
? SaveButtonChoice.SaveAndCopy ? SaveButtonChoice.SaveAndCopy
: SaveButtonChoice.SaveOnly; : SaveButtonChoice.SaveOnly;
@@ -406,11 +458,29 @@ public partial class SettingsWindow : Window
: ActionNothingRadio.IsChecked == true ? CaptureAction.Nothing : ActionNothingRadio.IsChecked == true ? CaptureAction.Nothing
: CaptureAction.Notify; : CaptureAction.Notify;
_settings.StraightHighlight = StraightHighlightBox.IsChecked == true; _settings.StraightHighlight = StraightHighlightBox.IsChecked == true;
_settings.ColorMode = ColorModeDarkRadio.IsChecked == true ? ColorMode.Dark
: ColorModeSystemRadio.IsChecked == true ? ColorMode.System
: ColorMode.Light;
_settings.EditorLayout = LayoutSplitRadio.IsChecked == true ? EditorLayout.Split
: LayoutThreeSplitRadio.IsChecked == true ? EditorLayout.ThreeSplit
: EditorLayout.Single;
_settings.CaptureBarFloating = CaptureBarFloatRadio.IsChecked == true;
if (!_settings.CaptureBarFloating)
{
_settings.CaptureBarPosition = CaptureBarRightRadio.IsChecked == true ? Dock.Right
: CaptureBarTopRadio.IsChecked == true ? Dock.Top
: CaptureBarBottomRadio.IsChecked == true ? Dock.Bottom
: Dock.Left;
}
_settings.CheckForUpdates = AutoUpdateCheckBox.IsChecked == true;
_settings.ShowMagnifier = MagnifierBox.IsChecked == true;
StopListening(); StopListening();
_settings.SnipHotkey = _hotkeys["Snip"]; _settings.SnipHotkey = _hotkeys["Snip"];
_settings.WindowHotkey = _hotkeys["Window"]; _settings.WindowHotkey = _hotkeys["Window"];
_settings.RecordHotkey = _hotkeys["Record"]; _settings.RecordHotkey = _hotkeys["Record"];
_settings.GifHotkey = _hotkeys["Gif"]; _settings.GifHotkey = _hotkeys["Gif"];
_settings.ClipboardHotkey = _hotkeys["Clipboard"];
_settings.OcrHotkey = _hotkeys["Ocr"];
_settings.RecordSystemAudio = SystemAudioBox.IsChecked == true; _settings.RecordSystemAudio = SystemAudioBox.IsChecked == true;
_settings.RecordMicrophone = MicrophoneBox.IsChecked == true; _settings.RecordMicrophone = MicrophoneBox.IsChecked == true;
_settings.RecordShowCursor = ShowCursorBox.IsChecked == true; _settings.RecordShowCursor = ShowCursorBox.IsChecked == true;
@@ -429,6 +499,7 @@ public partial class SettingsWindow : Window
_settings.ToolbarPosition = defaults.ToolbarPosition; _settings.ToolbarPosition = defaults.ToolbarPosition;
_settings.ToolbarLocked = defaults.ToolbarLocked; _settings.ToolbarLocked = defaults.ToolbarLocked;
_settings.ToolbarOrder = defaults.ToolbarOrder; _settings.ToolbarOrder = defaults.ToolbarOrder;
_settings.SplitToolbarOrder = defaults.SplitToolbarOrder;
_settings.ToolbarHidden = defaults.ToolbarHidden; _settings.ToolbarHidden = defaults.ToolbarHidden;
} }
@@ -464,6 +535,204 @@ public partial class SettingsWindow : Window
if (e.OriginalSource is TabControl) if (e.OriginalSource is TabControl)
{ {
StopListening(); StopListening();
if (UpdateTab.IsSelected && !_updateChecked)
{
_ = CheckForUpdatesAsync();
}
} }
} }
private void CheckUpdate_Click(object sender, RoutedEventArgs e) => _ = CheckForUpdatesAsync();
private void OnUpdateAvailabilityChanged(object? sender, EventArgs e) => ShowUpdateBadge();
private void ShowUpdateBadge() =>
UpdateTabBadge.Visibility = App.Instance.Updates.IsUpdateAvailable ? Visibility.Visible : Visibility.Collapsed;
private void ReleasePage_Click(object sender, RoutedEventArgs e) => OpenUrl(UpdateChecker.ReleasesPage);
private async System.Threading.Tasks.Task CheckForUpdatesAsync()
{
_updateChecked = true;
_updateCts?.Cancel();
var cts = new System.Threading.CancellationTokenSource();
_updateCts = cts;
_update = null;
UpdateButton.Visibility = Visibility.Collapsed;
CheckUpdateButton.IsEnabled = false;
UpdateStatus.Text = "Controleren op updates...";
try
{
IReadOnlyList<ReleaseInfo> releases = await UpdateChecker.GetReleasesAsync(cts.Token);
ShowReleases(releases);
}
catch (UpdateException ex)
{
UpdateStatus.Text = ex.Message;
ShowChangelog(Array.Empty<ReleaseInfo>(), "Geen changelog beschikbaar.");
}
catch (OperationCanceledException)
{
return;
}
finally
{
if (_updateCts == cts)
{
_updateCts = null;
CheckUpdateButton.IsEnabled = true;
}
cts.Dispose();
}
}
private void ShowReleases(IReadOnlyList<ReleaseInfo> releases)
{
Version current = UpdateChecker.CurrentVersion;
List<ReleaseInfo> newer = releases.Where(r => r.Version > current).ToList();
App.Instance.Updates.Report(newer.FirstOrDefault());
if (newer.Count == 0)
{
UpdateStatus.Text = releases.Count == 0
? "Er zijn nog geen releases gepubliceerd."
: $"Je gebruikt de nieuwste versie ({current}).";
ReleaseInfo? notes = releases.FirstOrDefault(r => r.Version == current) ?? releases.FirstOrDefault();
ShowChangelog(notes == null ? Array.Empty<ReleaseInfo>() : new[] { notes }, "Geen changelog beschikbaar.");
return;
}
if (AppIdentity.IsTest)
{
UpdateStatus.Text = $"Versie {newer[0].Version} is beschikbaar. Deze testversie werkt zichzelf niet bij; " +
"de installer zou de gewone kniip overschrijven.";
ShowChangelog(newer, null);
return;
}
_update = newer.FirstOrDefault(r => r.InstallerUrl != null);
if (_update != null)
{
UpdateStatus.Text = $"Versie {_update.Version} is beschikbaar.";
UpdateButton.Content = $"Updaten naar {_update.Version}";
UpdateButton.Visibility = Visibility.Visible;
}
else
{
UpdateStatus.Text = $"Versie {newer[0].Version} is beschikbaar, maar zonder installer. Download die via de releasepagina.";
}
ShowChangelog(newer, null);
}
private void ShowChangelog(IReadOnlyList<ReleaseInfo> releases, string? emptyText)
{
FlowDocument document = MarkdownRenderer.CreateDocument();
var culture = System.Globalization.CultureInfo.GetCultureInfo("nl-NL");
var baseUri = new Uri(UpdateChecker.RepositoryUrl);
foreach (ReleaseInfo release in releases)
{
Paragraph heading = MarkdownRenderer.Heading($"Versie {release.Version}", 17,
new Thickness(0, document.Blocks.Count == 0 ? 0 : 14, 0, 0));
document.Blocks.Add(heading);
if (release.Date is DateTimeOffset date)
{
document.Blocks.Add(new Paragraph(new Run(date.ToLocalTime().ToString("d MMMM yyyy", culture)))
{
Foreground = (Brush)Application.Current.Resources["SubtleTextBrush"],
FontSize = 12,
Margin = new Thickness(0, 0, 0, 8),
});
}
if (string.IsNullOrWhiteSpace(release.Body))
{
document.Blocks.Add(new Paragraph(new Run("Geen beschrijving."))
{
Foreground = (Brush)Application.Current.Resources["SubtleTextBrush"],
});
}
else
{
MarkdownRenderer.Append(document, release.Body, baseUri);
}
}
if (releases.Count == 0 && emptyText != null)
{
document.Blocks.Add(new Paragraph(new Run(emptyText))
{
Foreground = (Brush)Application.Current.Resources["SubtleTextBrush"],
});
}
ChangelogViewer.Document = document;
}
private async void Update_Click(object sender, RoutedEventArgs e)
{
if (_update is not { } release)
{
return;
}
string installFolder = System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Programs", "Kniip");
bool installed = Environment.ProcessPath is { } exe
&& string.Equals(System.IO.Path.GetDirectoryName(exe), installFolder, StringComparison.OrdinalIgnoreCase);
string message = $"kniip wordt afgesloten, bijgewerkt naar versie {release.Version} en daarna opnieuw gestart. " +
"Open editors en screencasts worden eerst gesloten.";
if (!installed)
{
message += $"\n\nDeze kniip is niet via de installer gestart; de nieuwe versie wordt geïnstalleerd in {installFolder}.";
}
if (MessageBox.Show(this, message, "kniip bijwerken", MessageBoxButton.OKCancel, MessageBoxImage.Information) != MessageBoxResult.OK)
{
return;
}
var cts = new System.Threading.CancellationTokenSource();
_updateCts = cts;
UpdateButton.IsEnabled = false;
CheckUpdateButton.IsEnabled = false;
DownloadProgress.Value = 0;
DownloadProgress.Visibility = Visibility.Visible;
UpdateStatus.Text = $"Versie {release.Version} downloaden...";
string path;
try
{
path = await UpdateInstaller.DownloadAsync(release, new Progress<double>(p => DownloadProgress.Value = p), cts.Token);
}
catch (OperationCanceledException)
{
return;
}
catch (Exception ex) when (ex is UpdateException or System.IO.IOException or UnauthorizedAccessException)
{
UpdateStatus.Text = $"Versie {release.Version} is beschikbaar.";
MessageBox.Show(this, $"De update kon niet worden gedownload.\n\n{ex.Message}", "kniip",
MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
finally
{
if (_updateCts == cts)
{
_updateCts = null;
}
cts.Dispose();
if (IsLoaded)
{
DownloadProgress.Visibility = Visibility.Collapsed;
UpdateButton.IsEnabled = true;
CheckUpdateButton.IsEnabled = true;
}
}
UpdateStatus.Text = "Installer starten...";
App.Instance.InstallUpdate(path);
}
} }
+165
View File
@@ -0,0 +1,165 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<SolidColorBrush x:Key="AccentBrush" Color="#0067C0" />
<SolidColorBrush x:Key="AccentHoverBrush" Color="#1975C5" />
<SolidColorBrush x:Key="AccentPressedBrush" Color="#3183CA" />
<SolidColorBrush x:Key="AccentTextBrush" Color="#4CC2FF" />
<SolidColorBrush x:Key="SelectedBrush" Color="#334CC2FF" />
<SolidColorBrush x:Key="WindowBrush" Color="#202020" />
<SolidColorBrush x:Key="ToolbarBrush" Color="#2B2B2B" />
<SolidColorBrush x:Key="UnlockedToolbarBrush" Color="#1F3347" />
<SolidColorBrush x:Key="CardBrush" Color="#2B2B2B" />
<SolidColorBrush x:Key="CardBorderBrush" Color="#3A3A3A" />
<SolidColorBrush x:Key="ToastBrush" Color="#2B2B2B" />
<SolidColorBrush x:Key="ToastBorderBrush" Color="#454545" />
<SolidColorBrush x:Key="HoverBrush" Color="#14FFFFFF" />
<SolidColorBrush x:Key="PressedBrush" Color="#1FFFFFFF" />
<SolidColorBrush x:Key="CheckedBrush" Color="#3A3A3A" />
<SolidColorBrush x:Key="DividerBrush" Color="#3A3A3A" />
<SolidColorBrush x:Key="InkBrush" Color="#F2F2F2" />
<SolidColorBrush x:Key="SubtleTextBrush" Color="#A8A8A8" />
<SolidColorBrush x:Key="ControlBrush" Color="#2D2D2D" />
<SolidColorBrush x:Key="ControlHoverBrush" Color="#353535" />
<SolidColorBrush x:Key="ControlPressedBrush" Color="#3D3D3D" />
<SolidColorBrush x:Key="ControlBorderBrush" Color="#3F3F3F" />
<SolidColorBrush x:Key="ControlStrongBorderBrush" Color="#4A4A4A" />
<SolidColorBrush x:Key="CheckBoxBorderBrush" Color="#9A9A9A" />
<SolidColorBrush x:Key="ProminentBrush" Color="#383838" />
<SolidColorBrush x:Key="ProminentHoverBrush" Color="#424242" />
<SolidColorBrush x:Key="ProminentPressedBrush" Color="#303030" />
<SolidColorBrush x:Key="CanvasBrush" Color="#1C1C1C" />
<SolidColorBrush x:Key="CanvasBorderBrush" Color="#3A3A3A" />
<SolidColorBrush x:Key="TrackBrush" Color="#3A3A3A" />
<SolidColorBrush x:Key="CodeBrush" Color="#333333" />
<SolidColorBrush x:Key="QuoteBorderBrush" Color="#505050" />
<!-- Standaardbesturingselementen van WPF lezen deze systeemkleuren -->
<SolidColorBrush x:Key="{x:Static SystemColors.ControlTextBrushKey}" Color="#F2F2F2" />
<SolidColorBrush x:Key="{x:Static SystemColors.WindowTextBrushKey}" Color="#F2F2F2" />
<SolidColorBrush x:Key="{x:Static SystemColors.WindowBrushKey}" Color="#2D2D2D" />
<SolidColorBrush x:Key="{x:Static SystemColors.ControlBrushKey}" Color="#202020" />
<SolidColorBrush x:Key="{x:Static SystemColors.GrayTextBrushKey}" Color="#8A8A8A" />
<SolidColorBrush x:Key="{x:Static SystemColors.HotTrackBrushKey}" Color="#4CC2FF" />
<Style TargetType="ProgressBar">
<Setter Property="Background" Value="#3A3A3A" />
<Setter Property="BorderBrush" Value="#3A3A3A" />
</Style>
<Style x:Key="ScrollPageButton" TargetType="RepeatButton">
<Setter Property="Focusable" Value="False" />
<Setter Property="IsTabStop" Value="False" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="RepeatButton">
<Border Background="Transparent" />
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="ScrollThumb" TargetType="Thumb">
<Setter Property="Focusable" Value="False" />
<Setter Property="IsTabStop" Value="False" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Thumb">
<Border x:Name="Bd" Background="#5C5C5C" CornerRadius="3" Margin="3" />
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#7A7A7A" />
</Trigger>
<Trigger Property="IsDragging" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#8E8E8E" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="ScrollBar">
<Setter Property="Background" Value="Transparent" />
<Setter Property="Width" Value="12" />
<Setter Property="MinWidth" Value="12" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ScrollBar">
<Grid Background="{TemplateBinding Background}">
<Track x:Name="PART_Track" IsDirectionReversed="True">
<Track.DecreaseRepeatButton>
<RepeatButton Style="{StaticResource ScrollPageButton}" Command="ScrollBar.PageUpCommand" />
</Track.DecreaseRepeatButton>
<Track.IncreaseRepeatButton>
<RepeatButton Style="{StaticResource ScrollPageButton}" Command="ScrollBar.PageDownCommand" />
</Track.IncreaseRepeatButton>
<Track.Thumb>
<Thumb Style="{StaticResource ScrollThumb}" />
</Track.Thumb>
</Track>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="Orientation" Value="Horizontal">
<Setter Property="Width" Value="Auto" />
<Setter Property="MinWidth" Value="0" />
<Setter Property="Height" Value="12" />
<Setter Property="MinHeight" Value="12" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ScrollBar">
<Grid Background="{TemplateBinding Background}">
<Track x:Name="PART_Track">
<Track.DecreaseRepeatButton>
<RepeatButton Style="{StaticResource ScrollPageButton}" Command="ScrollBar.PageLeftCommand" />
</Track.DecreaseRepeatButton>
<Track.IncreaseRepeatButton>
<RepeatButton Style="{StaticResource ScrollPageButton}" Command="ScrollBar.PageRightCommand" />
</Track.IncreaseRepeatButton>
<Track.Thumb>
<Thumb Style="{StaticResource ScrollThumb}" />
</Track.Thumb>
</Track>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Trigger>
</Style.Triggers>
</Style>
<Style TargetType="GridViewColumnHeader">
<Setter Property="Foreground" Value="#F2F2F2" />
<Setter Property="HorizontalContentAlignment" Value="Left" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="GridViewColumnHeader">
<Grid>
<Border x:Name="Bd" Background="#2B2B2B" BorderBrush="#3A3A3A" BorderThickness="0,0,1,1" Padding="6,4">
<ContentPresenter HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalAlignment="Center" RecognizesAccessKey="True" />
</Border>
<Thumb x:Name="PART_HeaderGripper" HorizontalAlignment="Right" Width="8" Margin="0,0,-4,0" Cursor="SizeWE">
<Thumb.Template>
<ControlTemplate TargetType="Thumb">
<Border Background="Transparent" />
</ControlTemplate>
</Thumb.Template>
</Thumb>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#333333" />
</Trigger>
<Trigger Property="Role" Value="Padding">
<Setter TargetName="PART_HeaderGripper" Property="Visibility" Value="Collapsed" />
<Setter TargetName="Bd" Property="BorderThickness" Value="0,0,0,1" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>
+35
View File
@@ -0,0 +1,35 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<SolidColorBrush x:Key="AccentBrush" Color="#0067C0" />
<SolidColorBrush x:Key="AccentHoverBrush" Color="#1975C5" />
<SolidColorBrush x:Key="AccentPressedBrush" Color="#3183CA" />
<SolidColorBrush x:Key="AccentTextBrush" Color="#0067C0" />
<SolidColorBrush x:Key="SelectedBrush" Color="#1A0067C0" />
<SolidColorBrush x:Key="WindowBrush" Color="#F3F3F3" />
<SolidColorBrush x:Key="ToolbarBrush" Color="#F9F9F9" />
<SolidColorBrush x:Key="UnlockedToolbarBrush" Color="#EAF2FB" />
<SolidColorBrush x:Key="CardBrush" Color="#FFFFFF" />
<SolidColorBrush x:Key="CardBorderBrush" Color="#E5E5E5" />
<SolidColorBrush x:Key="ToastBrush" Color="#FCFCFC" />
<SolidColorBrush x:Key="ToastBorderBrush" Color="#DADADA" />
<SolidColorBrush x:Key="HoverBrush" Color="#0F000000" />
<SolidColorBrush x:Key="PressedBrush" Color="#18000000" />
<SolidColorBrush x:Key="CheckedBrush" Color="#FFFFFF" />
<SolidColorBrush x:Key="DividerBrush" Color="#E5E5E5" />
<SolidColorBrush x:Key="InkBrush" Color="#1A1A1A" />
<SolidColorBrush x:Key="SubtleTextBrush" Color="#5C5C5C" />
<SolidColorBrush x:Key="ControlBrush" Color="#FFFFFF" />
<SolidColorBrush x:Key="ControlHoverBrush" Color="#F5F5F5" />
<SolidColorBrush x:Key="ControlPressedBrush" Color="#EBEBEB" />
<SolidColorBrush x:Key="ControlBorderBrush" Color="#E0E0E0" />
<SolidColorBrush x:Key="ControlStrongBorderBrush" Color="#D6D6D6" />
<SolidColorBrush x:Key="CheckBoxBorderBrush" Color="#8A8A8A" />
<SolidColorBrush x:Key="ProminentBrush" Color="#ECECEC" />
<SolidColorBrush x:Key="ProminentHoverBrush" Color="#E0E0E0" />
<SolidColorBrush x:Key="ProminentPressedBrush" Color="#D4D4D4" />
<SolidColorBrush x:Key="CanvasBrush" Color="#E9E9E9" />
<SolidColorBrush x:Key="CanvasBorderBrush" Color="#C8C8C8" />
<SolidColorBrush x:Key="TrackBrush" Color="#DADADA" />
<SolidColorBrush x:Key="CodeBrush" Color="#F3F3F3" />
<SolidColorBrush x:Key="QuoteBorderBrush" Color="#D0D0D0" />
</ResourceDictionary>
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

+345
View File
@@ -0,0 +1,345 @@
using System;
using System.Linq;
using System.Windows;
using System.Windows.Documents;
using System.Windows.Media;
using Markdig;
using Markdig.Extensions.TaskLists;
using Markdig.Syntax;
using Markdig.Syntax.Inlines;
using MdBlock = Markdig.Syntax.Block;
using MdInline = Markdig.Syntax.Inlines.Inline;
using WpfBlock = System.Windows.Documents.Block;
using WpfInline = System.Windows.Documents.Inline;
using WpfList = System.Windows.Documents.List;
namespace Kniip.Update;
/// <summary>Zet markdown (releasenotes) om naar een WPF <see cref="FlowDocument"/>.</summary>
public static class MarkdownRenderer
{
private static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder()
.UseEmphasisExtras()
.UseAutoLinks()
.UseTaskLists()
.Build();
private static readonly FontFamily CodeFont = new("Cascadia Mono, Consolas");
private static Brush CodeBackground => Resource("CodeBrush", Brushes.WhiteSmoke);
private static Brush QuoteBorder => Resource("QuoteBorderBrush", Brushes.LightGray);
private static Brush Accent => Resource("AccentBrush", Brushes.SteelBlue);
private static Brush Subtle => Resource("SubtleTextBrush", Brushes.Gray);
public static FlowDocument CreateDocument() => new()
{
FontFamily = new FontFamily("Segoe UI"),
FontSize = 13,
PagePadding = new Thickness(14, 10, 14, 10),
TextAlignment = TextAlignment.Left,
Background = Resource("CardBrush", Brushes.White),
};
/// <summary>Voegt de blokken van <paramref name="markdown"/> toe; relatieve links wijzen naar <paramref name="baseUri"/>.</summary>
public static void Append(FlowDocument document, string markdown, Uri? baseUri = null)
{
MarkdownDocument parsed = Markdown.Parse(markdown ?? string.Empty, Pipeline);
foreach (MdBlock block in parsed)
{
if (RenderBlock(block, baseUri) is { } rendered)
{
document.Blocks.Add(rendered);
}
}
}
public static Paragraph Heading(string text, double fontSize, Thickness margin) => new(new Run(text))
{
FontSize = fontSize,
FontWeight = FontWeights.SemiBold,
Margin = margin,
};
private static WpfBlock? RenderBlock(MdBlock block, Uri? baseUri)
{
switch (block)
{
case HeadingBlock heading:
{
var paragraph = new Paragraph
{
FontSize = heading.Level switch { 1 => 19, 2 => 16.5, 3 => 14.5, _ => 13 },
FontWeight = FontWeights.SemiBold,
Margin = new Thickness(0, heading.Level <= 2 ? 12 : 8, 0, 4),
};
AddInlines(paragraph.Inlines, heading.Inline, baseUri);
return paragraph;
}
case ParagraphBlock paragraphBlock:
{
var paragraph = new Paragraph { Margin = new Thickness(0, 0, 0, 8) };
AddInlines(paragraph.Inlines, paragraphBlock.Inline, baseUri);
return paragraph;
}
case ListBlock listBlock:
{
var list = new WpfList
{
MarkerStyle = listBlock.IsOrdered ? TextMarkerStyle.Decimal : TextMarkerStyle.Disc,
Margin = new Thickness(0, 0, 0, 8),
Padding = new Thickness(20, 0, 0, 0),
};
if (listBlock.IsOrdered && int.TryParse(listBlock.OrderedStart, out int start) && start > 0)
{
list.StartIndex = start;
}
foreach (ListItemBlock itemBlock in listBlock.OfType<ListItemBlock>())
{
var item = new ListItem();
foreach (MdBlock child in itemBlock)
{
if (RenderBlock(child, baseUri) is { } rendered)
{
rendered.Margin = new Thickness(0, 0, 0, 2);
item.Blocks.Add(rendered);
}
}
list.ListItems.Add(item);
}
return list;
}
case QuoteBlock quote:
{
var section = new Section
{
BorderBrush = QuoteBorder,
BorderThickness = new Thickness(3, 0, 0, 0),
Padding = new Thickness(10, 0, 0, 0),
Margin = new Thickness(0, 0, 0, 8),
Foreground = Subtle,
};
foreach (MdBlock child in quote)
{
if (RenderBlock(child, baseUri) is { } rendered)
{
section.Blocks.Add(rendered);
}
}
return section;
}
case CodeBlock code:
{
var paragraph = new Paragraph
{
FontFamily = CodeFont,
FontSize = 12,
Background = CodeBackground,
Padding = new Thickness(8, 6, 8, 6),
Margin = new Thickness(0, 0, 0, 8),
};
string[] lines = code.Lines.ToString().Replace("\r\n", "\n").TrimEnd('\n').Split('\n');
for (int i = 0; i < lines.Length; i++)
{
if (i > 0)
{
paragraph.Inlines.Add(new LineBreak());
}
paragraph.Inlines.Add(new Run(lines[i]));
}
return paragraph;
}
case ThematicBreakBlock:
return new Paragraph
{
BorderBrush = QuoteBorder,
BorderThickness = new Thickness(0, 0, 0, 1),
FontSize = 1,
Margin = new Thickness(0, 4, 0, 10),
};
case HtmlBlock:
return null;
case ContainerBlock container:
{
var section = new Section();
foreach (MdBlock child in container)
{
if (RenderBlock(child, baseUri) is { } rendered)
{
section.Blocks.Add(rendered);
}
}
return section.Blocks.Count > 0 ? section : null;
}
case LeafBlock leaf when leaf.Inline != null:
{
var paragraph = new Paragraph { Margin = new Thickness(0, 0, 0, 8) };
AddInlines(paragraph.Inlines, leaf.Inline, baseUri);
return paragraph;
}
default:
return null;
}
}
private static void AddInlines(InlineCollection target, ContainerInline? container, Uri? baseUri)
{
if (container == null)
{
return;
}
foreach (MdInline inline in container)
{
if (RenderInline(inline, baseUri) is { } rendered)
{
target.Add(rendered);
}
}
}
private static WpfInline? RenderInline(MdInline inline, Uri? baseUri)
{
switch (inline)
{
case LiteralInline literal:
return new Run(literal.Content.ToString());
case CodeInline code:
return new Run(code.Content)
{
FontFamily = CodeFont,
FontSize = 12,
Background = CodeBackground,
};
case LineBreakInline lineBreak:
return lineBreak.IsHard ? new LineBreak() : new Run(" ");
case TaskList task:
return new Run(task.Checked ? "\u2611 " : "\u2610 ");
case AutolinkInline autolink:
return CreateLink(new Span(new Run(autolink.Url)), autolink.IsEmail ? "mailto:" + autolink.Url : autolink.Url, baseUri);
case LinkInline link:
{
var span = new Span();
AddInlines(span.Inlines, link, baseUri);
if (span.Inlines.Count == 0)
{
span.Inlines.Add(new Run(link.Url ?? string.Empty));
}
return CreateLink(span, link.Url, baseUri);
}
case EmphasisInline emphasis:
{
var span = new Span();
AddInlines(span.Inlines, emphasis, baseUri);
if (emphasis.DelimiterChar == '~')
{
span.TextDecorations = TextDecorations.Strikethrough;
}
else if (emphasis.DelimiterCount >= 2)
{
span.FontWeight = FontWeights.SemiBold;
}
else
{
span.FontStyle = FontStyles.Italic;
}
return span;
}
case HtmlInline html when html.Tag.StartsWith("<br", StringComparison.OrdinalIgnoreCase):
return new LineBreak();
case HtmlEntityInline entity:
return new Run(entity.Transcoded.ToString());
case HtmlInline:
return null;
case ContainerInline container:
{
var span = new Span();
AddInlines(span.Inlines, container, baseUri);
return span;
}
default:
return null;
}
}
private static WpfInline CreateLink(Span content, string? url, Uri? baseUri)
{
Uri? target = ResolveUrl(url, baseUri);
if (target == null)
{
return content;
}
var link = new Hyperlink(content)
{
Foreground = Accent,
ToolTip = target.ToString(),
NavigateUri = target,
};
link.RequestNavigate += (_, e) =>
{
OpenUrl(e.Uri);
e.Handled = true;
};
return link;
}
private static Uri? ResolveUrl(string? url, Uri? baseUri)
{
if (string.IsNullOrWhiteSpace(url))
{
return null;
}
Uri? uri = Uri.TryCreate(url, UriKind.Absolute, out Uri? absolute) ? absolute
: baseUri != null && Uri.TryCreate(baseUri, url, out Uri? relative) ? relative
: null;
return uri != null && (uri.Scheme == Uri.UriSchemeHttps || uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeMailto)
? uri
: null;
}
public static void OpenUrl(Uri uri)
{
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(uri.AbsoluteUri) { UseShellExecute = true });
}
catch (Exception)
{
// Zonder standaardbrowser gebeurt er niets; de link staat in de tooltip.
}
}
private static Brush Resource(string key, Brush fallback) =>
Application.Current?.TryFindResource(key) as Brush ?? fallback;
}
+158
View File
@@ -0,0 +1,158 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Reflection;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace Kniip.Update;
public sealed class UpdateException : Exception
{
public UpdateException(string message, Exception? inner = null) : base(message, inner)
{
}
}
public sealed record ReleaseInfo(
Version Version,
string Tag,
string Title,
DateTimeOffset? Date,
string Body,
string HtmlUrl,
string? InstallerUrl,
long InstallerSize);
/// <summary>Leest de releases van kniip uit de Gitea/Forgejo-API van git.hibbit.net.</summary>
public static class UpdateChecker
{
public const string RepositoryUrl = "https://git.hibbit.net/rogier/kniip/";
public const string ReleasesPage = "https://git.hibbit.net/rogier/kniip/releases";
private const string ReleasesApi = "https://git.hibbit.net/api/v1/repos/rogier/kniip/releases?limit=20";
internal static readonly HttpClient Client = new() { Timeout = TimeSpan.FromMinutes(10) };
static UpdateChecker()
{
Client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("kniip", CurrentVersion.ToString()));
}
/// <summary>Versie van deze exe, altijd in drie delen (1.2.0).</summary>
public static Version CurrentVersion { get; } = Normalize(Assembly.GetEntryAssembly()?.GetName().Version ?? new Version(1, 0, 0));
/// <summary>Gepubliceerde releases, nieuwste eerst; concepten en voorversies vallen af.</summary>
public static async Task<IReadOnlyList<ReleaseInfo>> GetReleasesAsync(CancellationToken cancellationToken = default)
{
string body;
try
{
using var request = new HttpRequestMessage(HttpMethod.Get, ReleasesApi);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(TimeSpan.FromSeconds(20));
using HttpResponseMessage response = await Client.SendAsync(request, timeout.Token).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.NotFound)
{
throw new UpdateException("De releases van kniip zijn niet gevonden op git.hibbit.net.");
}
if (!response.IsSuccessStatusCode)
{
throw new UpdateException($"git.hibbit.net gaf een fout ({(int)response.StatusCode}).");
}
body = await response.Content.ReadAsStringAsync(timeout.Token).ConfigureAwait(false);
}
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
throw new UpdateException("git.hibbit.net reageert niet (time-out).", ex);
}
catch (HttpRequestException ex)
{
throw new UpdateException("Kan git.hibbit.net niet bereiken. Controleer de internetverbinding.", ex);
}
try
{
return Parse(body);
}
catch (Exception ex) when (ex is JsonException or InvalidOperationException or KeyNotFoundException)
{
throw new UpdateException("Het antwoord van git.hibbit.net is niet te lezen.", ex);
}
}
private static List<ReleaseInfo> Parse(string json)
{
using JsonDocument document = JsonDocument.Parse(json);
var releases = new List<ReleaseInfo>();
foreach (JsonElement item in document.RootElement.EnumerateArray())
{
if (Bool(item, "draft") || Bool(item, "prerelease") || !TryParseVersion(String(item, "tag_name"), out Version version))
{
continue;
}
string? installerUrl = null;
long installerSize = 0;
if (item.TryGetProperty("assets", out JsonElement assets) && assets.ValueKind == JsonValueKind.Array)
{
foreach (JsonElement asset in assets.EnumerateArray())
{
string name = String(asset, "name");
if (name.StartsWith("Kniip-Setup-", StringComparison.OrdinalIgnoreCase)
&& name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
{
installerUrl = String(asset, "browser_download_url");
installerSize = asset.TryGetProperty("size", out JsonElement size) && size.TryGetInt64(out long bytes) ? bytes : 0;
break;
}
}
}
DateTimeOffset? date = DateTimeOffset.TryParse(String(item, "published_at"), CultureInfo.InvariantCulture,
DateTimeStyles.None, out DateTimeOffset published) ? published : null;
string tag = String(item, "tag_name");
string title = String(item, "name");
releases.Add(new ReleaseInfo(version, tag, title.Length > 0 ? title : tag, date, String(item, "body"),
String(item, "html_url") is { Length: > 0 } url ? url : ReleasesPage,
string.IsNullOrEmpty(installerUrl) ? null : installerUrl, installerSize));
}
return releases.OrderByDescending(r => r.Version).ToList();
}
internal static bool TryParseVersion(string? tag, out Version version)
{
version = new Version(0, 0, 0);
string text = (tag ?? string.Empty).Trim().TrimStart('v', 'V');
int suffix = text.IndexOfAny(new[] { '-', '+' });
if (suffix >= 0)
{
text = text[..suffix];
}
if (!Version.TryParse(text.Contains('.') ? text : text + ".0", out Version? parsed))
{
return false;
}
version = Normalize(parsed);
return true;
}
private static Version Normalize(Version version) =>
new(version.Major, Math.Max(version.Minor, 0), Math.Max(version.Build, 0));
private static string String(JsonElement element, string name) =>
element.TryGetProperty(name, out JsonElement value) && value.ValueKind == JsonValueKind.String ? value.GetString() ?? string.Empty : string.Empty;
private static bool Bool(JsonElement element, string name) =>
element.TryGetProperty(name, out JsonElement value) && value.ValueKind == JsonValueKind.True;
}
+100
View File
@@ -0,0 +1,100 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
namespace Kniip.Update;
/// <summary>Downloadt de installer van een release en start die stil.</summary>
public static class UpdateInstaller
{
public static string DownloadFolder => Path.Combine(Path.GetTempPath(), "kniip-update");
public static async Task<string> DownloadAsync(ReleaseInfo release, IProgress<double>? progress = null,
CancellationToken cancellationToken = default)
{
if (release.InstallerUrl is not { } url)
{
throw new UpdateException($"Release {release.Tag} heeft geen installer.");
}
Directory.CreateDirectory(DownloadFolder);
string path = Path.Combine(DownloadFolder, $"Kniip-Setup-{release.Version}.exe");
string partial = path + ".download";
try
{
using (HttpResponseMessage response = await UpdateChecker.Client
.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false))
{
if (!response.IsSuccessStatusCode)
{
throw new UpdateException($"Downloaden is mislukt ({(int)response.StatusCode}).");
}
long total = response.Content.Headers.ContentLength ?? release.InstallerSize;
await using Stream source = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
await using var target = new FileStream(partial, FileMode.Create, FileAccess.Write, FileShare.None, 81920, useAsync: true);
var buffer = new byte[81920];
long received = 0;
int read;
while ((read = await source.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) > 0)
{
await target.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false);
received += read;
if (total > 0)
{
progress?.Report(Math.Min(1, (double)received / total));
}
}
if (release.InstallerSize > 0 && received != release.InstallerSize)
{
throw new UpdateException("De download is onvolledig; probeer het opnieuw.");
}
}
File.Move(partial, path, overwrite: true);
return path;
}
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
throw new UpdateException("git.hibbit.net reageert niet (time-out).", ex);
}
catch (HttpRequestException ex)
{
throw new UpdateException("Downloaden is mislukt. Controleer de internetverbinding.", ex);
}
finally
{
TryDelete(partial);
}
}
/// <summary>Start de installer zonder vensters; die sluit kniip en start het daarna opnieuw.</summary>
public static void Start(string installerPath) =>
Process.Start(new ProcessStartInfo(installerPath)
{
Arguments = "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /CLOSEAPPLICATIONS",
UseShellExecute = true,
WorkingDirectory = Path.GetDirectoryName(installerPath) ?? string.Empty,
});
private static void TryDelete(string path)
{
try
{
if (File.Exists(path))
{
File.Delete(path);
}
}
catch (IOException)
{
}
catch (UnauthorizedAccessException)
{
}
}
}
+97
View File
@@ -0,0 +1,97 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Threading;
namespace Kniip.Update;
/// <summary>Kijkt op de achtergrond of er een nieuwere versie is en meldt dat aan de vensters.</summary>
public sealed class UpdateNotifier
{
private static readonly TimeSpan FirstCheckDelay = TimeSpan.FromSeconds(10);
private static readonly TimeSpan CheckInterval = TimeSpan.FromHours(24);
private readonly DispatcherTimer _timer = new();
private bool _enabled;
private bool _checking;
public UpdateNotifier()
{
_timer.Tick += async (_, _) =>
{
_timer.Interval = CheckInterval;
await CheckAsync();
};
}
/// <summary>Nieuwste versie die nieuwer is dan deze exe; null als er niets nieuws is.</summary>
public ReleaseInfo? Available { get; private set; }
public bool IsUpdateAvailable => Available != null;
/// <summary>Wordt op de UI-thread afgevuurd als <see cref="Available"/> verandert.</summary>
public event EventHandler? AvailabilityChanged;
public void Apply(bool enabled)
{
if (enabled == _enabled)
{
return;
}
_enabled = enabled;
_timer.Stop();
if (enabled)
{
_timer.Interval = FirstCheckDelay;
_timer.Start();
}
else
{
Report(null);
}
}
/// <summary>Resultaat van een controle elders (de tab Update), zodat de melding gelijkloopt.</summary>
public void Report(ReleaseInfo? newest)
{
if (newest != null && newest.Version <= UpdateChecker.CurrentVersion)
{
newest = null;
}
bool changed = Available?.Version != newest?.Version;
Available = newest;
if (changed)
{
AvailabilityChanged?.Invoke(this, EventArgs.Empty);
}
}
private async Task CheckAsync()
{
if (_checking)
{
return;
}
_checking = true;
try
{
IReadOnlyList<ReleaseInfo> releases = await UpdateChecker.GetReleasesAsync();
if (_enabled)
{
Report(releases.FirstOrDefault(r => r.Version > UpdateChecker.CurrentVersion));
}
}
catch (UpdateException)
{
// Een mislukte achtergrondcontrole is geen melding waard; de volgende poging komt vanzelf.
}
finally
{
_checking = false;
}
}
}
+72
View File
@@ -0,0 +1,72 @@
# Maakt src/Kniip/TrayIcon.ico uit de 256px-afbeelding in AppIcon.ico. Het systeemvak tekent op 16px;
# daar zijn de snijlijnen en de zwarte rand op een donkere taakbalk onzichtbaar en lijkt het icoon klein.
# Dit snijdt alleen het fotokader uit, zodat dat het hele vak vult. Opnieuw draaien als AppIcon.ico wijzigt.
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.Drawing
$root = Split-Path -Parent $PSScriptRoot
$source = Join-Path $root 'src\Kniip\AppIcon.ico'
$target = Join-Path $root 'src\Kniip\TrayIcon.ico'
# Kader in de 256px-afbeelding, inclusief de zwarte rand.
$crop = New-Object System.Drawing.Rectangle 42, 42, 172, 172
$sizes = 16, 20, 24, 32, 40, 48, 64
function Get-IcoPng([byte[]]$ico, [int]$size) {
$count = [BitConverter]::ToUInt16($ico, 4)
for ($i = 0; $i -lt $count; $i++) {
$entry = 6 + $i * 16
$width = if ($ico[$entry] -eq 0) { 256 } else { $ico[$entry] }
if ($width -eq $size) {
$length = [BitConverter]::ToUInt32($ico, $entry + 8)
$offset = [BitConverter]::ToUInt32($ico, $entry + 12)
$data = New-Object byte[] $length
[Array]::Copy($ico, $offset, $data, 0, $length)
return $data
}
}
throw "Geen ${size}px-afbeelding in $source"
}
$png = Get-IcoPng ([IO.File]::ReadAllBytes($source)) 256
$master = [System.Drawing.Bitmap]::FromStream((New-Object IO.MemoryStream (, $png)))
$images = foreach ($size in $sizes) {
$bitmap = New-Object System.Drawing.Bitmap $size, $size, ([System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
$g = [System.Drawing.Graphics]::FromImage($bitmap)
$g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
$g.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality
$g.CompositingQuality = [System.Drawing.Drawing2D.CompositingQuality]::HighQuality
$g.DrawImage($master, (New-Object System.Drawing.Rectangle 0, 0, $size, $size), $crop, [System.Drawing.GraphicsUnit]::Pixel)
$g.Dispose()
$stream = New-Object IO.MemoryStream
$bitmap.Save($stream, [System.Drawing.Imaging.ImageFormat]::Png)
$bitmap.Dispose()
, $stream.ToArray()
}
$master.Dispose()
$out = New-Object IO.MemoryStream
$writer = New-Object IO.BinaryWriter $out
$writer.Write([UInt16]0)
$writer.Write([UInt16]1)
$writer.Write([UInt16]$sizes.Count)
$offset = 6 + 16 * $sizes.Count
for ($i = 0; $i -lt $sizes.Count; $i++) {
$writer.Write([byte]$sizes[$i])
$writer.Write([byte]$sizes[$i])
$writer.Write([byte]0)
$writer.Write([byte]0)
$writer.Write([UInt16]1)
$writer.Write([UInt16]32)
$writer.Write([UInt32]$images[$i].Length)
$writer.Write([UInt32]$offset)
$offset += $images[$i].Length
}
foreach ($image in $images) {
$writer.Write($image)
}
$writer.Flush()
[IO.File]::WriteAllBytes($target, $out.ToArray())
Write-Host "Geschreven: $target"
+4
View File
@@ -0,0 +1,4 @@
@echo off
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0remove-autostart.ps1"
echo.
pause
+75
View File
@@ -0,0 +1,75 @@
# Haalt kniip uit Windows Opstart-apps voor de aangemelde gebruiker.
# Bedoeld voor wie alleen Kniip.exe heeft gedraaid (geen installer): het pad van de exe
# maakt niet uit, en het bestand hoeft er niet meer te staan.
#
# Elke Windows-account heeft een eigen opstartlijst. Draai dit script in de account
# waarin kniip een keer is gestart. Beheerdersrechten zijn niet nodig.
#
# powershell -NoProfile -ExecutionPolicy Bypass -File .\tools\remove-autostart.ps1
# of dubbelklik op remove-autostart.cmd
$ErrorActionPreference = "Stop"
$knownNames = @("Kniip", "KniipTest", "KnipPlus")
$exePattern = '(?i)(^|[\\/"])(Kniip(Test)?(-[\d.]+)?|KnipPlus)\.exe(\b|")'
function Test-KniipCommand([string]$text) {
return [bool]($text -match $exePattern)
}
function Remove-NamedValue([string]$keyPath, [string]$name) {
if (-not (Test-Path -LiteralPath $keyPath)) { return $false }
$key = Get-Item -LiteralPath $keyPath
if ($key.GetValueNames() -notcontains $name) { return $false }
Remove-ItemProperty -LiteralPath $keyPath -Name $name
return $true
}
$removed = New-Object System.Collections.Generic.List[string]
$runKey = "HKCU:\Software\Microsoft\Windows\CurrentVersion\Run"
$approvedRunKey = "HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run"
$names = New-Object System.Collections.Generic.HashSet[string]
foreach ($name in $knownNames) { [void]$names.Add($name) }
if (Test-Path -LiteralPath $runKey) {
$run = Get-Item -LiteralPath $runKey
foreach ($name in @($run.GetValueNames())) {
$data = [string]$run.GetValue($name)
if ($names.Contains($name) -or (Test-KniipCommand $data)) {
Remove-ItemProperty -LiteralPath $runKey -Name $name
$removed.Add("Run\$name ($data)")
[void]$names.Add($name)
}
}
}
foreach ($name in $names) {
if (Remove-NamedValue $approvedRunKey $name) {
$removed.Add("StartupApproved\Run\$name")
}
}
$startup = [Environment]::GetFolderPath("Startup")
$approvedStartupKey = "HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\StartupFolder"
$wsh = New-Object -ComObject WScript.Shell
Get-ChildItem -LiteralPath $startup -Filter "*.lnk" -ErrorAction SilentlyContinue | ForEach-Object {
$shortcut = $wsh.CreateShortcut($_.FullName)
$hit = (Test-KniipCommand $shortcut.TargetPath) -or (Test-KniipCommand $shortcut.Arguments) -or ($knownNames -contains $_.BaseName)
if (-not $hit) { return }
Remove-Item -LiteralPath $_.FullName -Force
$removed.Add("Opstartmap\$($_.Name)")
if (Remove-NamedValue $approvedStartupKey $_.Name) {
$removed.Add("StartupApproved\StartupFolder\$($_.Name)")
}
}
if ($removed.Count -eq 0) {
Write-Host "Geen kniip-opstartregistratie gevonden voor $env:USERNAME."
exit 0
}
Write-Host "Verwijderd voor ${env:USERNAME}:"
foreach ($line in $removed) { Write-Host " $line" }
Write-Host ""
Write-Host "kniip staat niet meer in Opstart-apps. Staat de regel er nog, log dan een keer uit en weer in."