stable
This commit is contained in:
commit
d55d8bc32d
93 files changed
+13507
No files matched your search
@@ -0,0 +1,109 @@
|
||||
---
|
||||
name: Emoji stempel-tool
|
||||
overview: "Voeg een Emoji-gereedschap toe aan de werkbalk van de editor. Je plaatst er een Twemoji-stempel mee op het knipsel. De kiezer werkt zoals emoji-mart: alle emoji, categorie-tabs, zoeken in het Nederlands en Engels, huidskleur en \"recent gebruikt\"."
|
||||
todos:
|
||||
- id: assets
|
||||
content: Script tools/fetch-emoji.ps1 en Assets/Emoji (emoji.json, twemoji-64.png, cldr-nl.json) toevoegen als Resource in csproj
|
||||
status: completed
|
||||
- id: catalog
|
||||
content: "EmojiCatalog: json/CLDR parsen, sheet croppen met cache, categorieën, zoeken, huidskleurvarianten"
|
||||
status: completed
|
||||
- id: model
|
||||
content: ToolKind.Emoji, SkinTone, StyleSet.Emoji/SkinTone, StyleProps.Emoji, DefaultFor/PropsFor
|
||||
status: completed
|
||||
- id: annotation
|
||||
content: EmojiAnnotation (Center, Bounds, Render met DrawImage en schaduw, Move)
|
||||
status: completed
|
||||
- id: picker
|
||||
content: "EmojiPickerButton UserControl: zoeken, categorie-tabs, gevirtualiseerd raster, preview, huidskleur, recent"
|
||||
status: completed
|
||||
- id: editor
|
||||
content: "EditorWindow: toolbarknop, EmojiGroup in PropsPanel, CreateAnnotation, stempel-case, sneltoets J, hint, stijl toepassen op selectie"
|
||||
status: completed
|
||||
- id: settings
|
||||
content: AppSettings.RecentEmoji en controle van ToolbarPanel-volgorde voor het nieuwe item
|
||||
status: completed
|
||||
- id: verify
|
||||
content: "Builden en testen: plaatsen, wisselen, huidskleur, zoeken NL/EN, export/kopiëren, undo"
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# Emoji-gereedschap met Twemoji-kiezer
|
||||
|
||||
## Aanpak
|
||||
|
||||
WPF tekent Segoe UI Emoji alleen zwart-wit. Daarom leveren we de **Twemoji-set van emoji-datasource** mee. Dat is dezelfde bron die emoji-mart gebruikt voor zijn "twitter"-set:
|
||||
|
||||
- `emoji.json`: namen, categorieën, sorteervolgorde, huidskleurvarianten en de positie van elke emoji in het sheet.
|
||||
- `sheet_twitter_64.png`: één spritesheet waarin elke emoji 64 px groot is, met 1 px opvulling.
|
||||
- CLDR `annotations/nl.json`: Nederlandse namen en trefwoorden, zodat je ook in het Nederlands kunt zoeken.
|
||||
|
||||
Een emoji wordt een stempel-annotatie, net als `StepAnnotation` en `CursorAnnotation`. Je klikt om hem te plaatsen, en daarna kun je hem verplaatsen, ongedaan maken en exporteren via de bestaande renderpijplijn.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Assets["Assets/Emoji: json + sheet + nl"] --> Catalog[EmojiCatalog]
|
||||
Catalog --> Picker[EmojiPickerButton]
|
||||
Catalog --> Anno[EmojiAnnotation]
|
||||
Picker -->|"gekozen emoji + huidskleur"| Style[StyleSet.Emoji]
|
||||
Style --> Anno
|
||||
Anno --> Surface[AnnotationSurface en ImageExporter]
|
||||
```
|
||||
|
||||
## Bestanden
|
||||
|
||||
**Assets (nieuw):** `src/Kniip/Assets/Emoji/` met `emoji.json`, `twemoji-64.png` en `cldr-nl.json`. Deze komen in [src/Kniip/Kniip.csproj](src/Kniip/Kniip.csproj) als `<Resource Include="Assets\Emoji\*" />`. De bestanden worden eenmalig gedownload met een klein script, `tools/fetch-emoji.ps1`, dat vaste versies van emoji-datasource-twitter (15.x) en cldr-json binnenhaalt. De bestanden zelf worden ingecheckt, zodat de build offline blijft werken.
|
||||
|
||||
**`src/Kniip/Editor/Emoji/EmojiCatalog.cs` (nieuw)**
|
||||
- Leest bij het eerste gebruik de json en het sheet (lazy, statisch). Emoji die niet in de Twitter-set zitten (`has_img_twitter`) en verouderde varianten (`obsoleted_by`) worden overgeslagen.
|
||||
- Het model is `EmojiInfo` met deze velden: `Unified`, `Char`, `Name`, `ShortNames`, `Category`, `SortOrder`, `Keywords` (Engels plus Nederlands) en `SkinVariations` (een dictionary van toon naar sheetpositie).
|
||||
- `ImageSource Get(string unified, SkinTone tone)`: een `CroppedBitmap` uit het sheet, in een cache en frozen.
|
||||
- `IEnumerable<EmojiInfo> Search(string query)` zoekt in namen, shortcodes en trefwoorden, zonder op hoofdletters te letten.
|
||||
- Categorieën in de volgorde van emoji-mart, met Nederlandse labels: Recent, Smileys en emotie, Mensen, Dieren en natuur, Eten en drinken, Activiteiten, Reizen en plaatsen, Objecten, Symbolen, Vlaggen.
|
||||
|
||||
**`src/Kniip/Editor/Annotations/EmojiAnnotation.cs` (nieuw)**
|
||||
- Een stempel zoals `CursorAnnotation`: `Center` als `Point`, de grootte komt uit `Style.Size`, en `Bounds` is een vierkant rond `Center`.
|
||||
- `Render` gebruikt `dc.DrawImage(EmojiCatalog.Get(Style.Emoji, Style.SkinTone), Bounds)`. Bij `Style.Shadow` komt er een schaduw onder, op dezelfde manier als in `RenderHelpers.DrawWithShadow`, maar dan voor een afbeelding (met een `DrawingGroup` en een `DropShadowEffect`-equivalent, of een offset-kopie met lage opaciteit).
|
||||
- Er gebruikt `RenderOptions.SetBitmapScalingMode(HighQuality)` voor nette schaling.
|
||||
|
||||
**[src/Kniip/Editor/StyleSet.cs](src/Kniip/Editor/StyleSet.cs)**
|
||||
- `ToolKind.Emoji` komt achter `Cursor`.
|
||||
- Nieuwe enum `SkinTone { Default, Light, MediumLight, Medium, MediumDark, Dark }`.
|
||||
- `StyleSet` krijgt `string Emoji = "1f44d"` (duim omhoog) en `SkinTone SkinTone`.
|
||||
- Nieuwe `StyleProps.Emoji = 8192`.
|
||||
- `DefaultFor`: `ToolKind.Emoji => new StyleSet { Size = 48, Shadow = false }`.
|
||||
- `PropsFor`: `ToolKind.Emoji => StyleProps.Emoji | StyleProps.Size | StyleProps.Shadow`.
|
||||
|
||||
**`src/Kniip/Editor/EmojiPickerButton.xaml(.cs)` (nieuw)**
|
||||
|
||||
Dit volgt hetzelfde patroon als [ColorPickerButton](src/Kniip/Editor/ColorPickerButton.xaml.cs): een `ToggleButton` met een `Popup`, en het event `EmojiChanged`. De popup is ongeveer 350x420 en bevat:
|
||||
- Een zoekveld bovenaan, dat focus krijgt zodra de popup opent.
|
||||
- Een rij met categorie-tabs. Een klik scrollt naar de bijbehorende sectie.
|
||||
- Een gevirtualiseerd raster: `ItemsControl` met `VirtualizingStackPanel` en per categorie een rij van negen emoji van 32 px.
|
||||
- Onderaan een preview met de naam en `:shortcode:` van de emoji waar je met de muis boven hangt, plus een huidskleurkiezer met zes bolletjes.
|
||||
- Na een keuze sluit de popup.
|
||||
|
||||
**[src/Kniip/Settings/AppSettings.cs](src/Kniip/Settings/AppSettings.cs)**
|
||||
- Nieuwe instelling `List<string> RecentEmoji`, met maximaal 18 items en de laatst gekozen emoji vooraan. Deze lijst vult de tab "Recent".
|
||||
|
||||
**[src/Kniip/Editor/EditorWindow.xaml](src/Kniip/Editor/EditorWindow.xaml)**
|
||||
- Een nieuwe `RadioButton Tag="Emoji"` achter `Cursor`, met `ItemId="Emoji"` en `Title="Emoji"`, tooltip "Emoji plaatsen (J)" en als icoon het Segoe Fluent-glyph `` (Emoji2).
|
||||
- In `PropsPanel` komt een `EmojiGroup` met `EmojiPickerButton`. `SizeGroup` en `ShadowGroup` worden hergebruikt.
|
||||
|
||||
**[src/Kniip/Editor/EditorWindow.xaml.cs](src/Kniip/Editor/EditorWindow.xaml.cs)**
|
||||
- `CreateAnnotation`: `ToolKind.Emoji => new EmojiAnnotation { Center = p, Style = style }`.
|
||||
- De stempel-case in `Surface_MouseLeftButtonDown` (rond regel 490) krijgt er `case ToolKind.Emoji:` bij.
|
||||
- `CursorForTool`: `Emoji` gebruikt `Cursors.Hand`.
|
||||
- Sneltoets `Key.J`: `SelectTool(ToolKind.Emoji)`.
|
||||
- Hinttekst: "Klik om de gekozen emoji te plaatsen; kies een andere emoji in de balk eronder."
|
||||
- `UpdatePropsPanel` en het toepassen van stijl op een geselecteerd object krijgen `StyleProps.Emoji` erbij. Zo kun je de emoji van een geplaatste stempel wisselen.
|
||||
- De grootte-opties voor Emoji: 24, 32, 48, 64, 96 en 128. Boven 64 wordt het bitmap opgeschaald; dat is acceptabel.
|
||||
|
||||
**[src/Kniip/Editor/ToolbarPanel.cs](src/Kniip/Editor/ToolbarPanel.cs)**
|
||||
- Controleren of een opgeslagen `ToolbarOrder` zonder "Emoji" het nieuwe item gewoon op de standaardplek toont. Is dat niet zo, dan wordt dat ingebouwd.
|
||||
|
||||
## Aandachtspunten
|
||||
- De app wordt ongeveer 3 tot 8 MB groter door het sheet en de json. Het sheet wordt pas geladen als je het Emoji-gereedschap voor het eerst gebruikt.
|
||||
- Twemoji valt onder CC-BY 4.0. De naamsvermelding komt in de README of het Over-scherm.
|
||||
- Bestaande stijlen in de instellingen blijven compatibel, omdat de nieuwe velden standaardwaarden hebben.
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
name: Knip companion editor
|
||||
overview: Een C# .NET 8 WPF-app die naast het Windows Knipprogramma draait, nieuwe knipsels oppikt (klembord, Screenshots-map, 'Openen met') en ze opent in een eigen editor met blur/pixelate, tekstkaders, stappen, cursor, highlight en vormen.
|
||||
todos:
|
||||
- id: skeleton
|
||||
content: Solution + WPF .NET 8 project, tray-icoon, single-instance, editor die een afbeelding laadt
|
||||
status: completed
|
||||
- id: capture
|
||||
content: ClipboardWatcher en FolderWatcher (Screenshots) met dedupe en instellingen
|
||||
status: completed
|
||||
- id: tools-shapes
|
||||
content: "Annotatiemodel + tools: rechthoek/ellips met randen/kleuren, tekstkader, stappen, cursor, highlight"
|
||||
status: completed
|
||||
- id: blur
|
||||
content: Blur- en pixelate-tool, undo/redo, selecteren/verplaatsen/resizen
|
||||
status: completed
|
||||
- id: export
|
||||
content: Export naar klembord/bestand, 'Openen met'-registratie, autostart, installer
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# Knip Companion - WPF annotatie-editor naast het Knipprogramma
|
||||
|
||||
## Architectuur
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
SnipTool["Knipprogramma (onaangetast)"] -->|kopieert| Clipboard
|
||||
SnipTool -->|slaat op| ScreenshotsFolder["Afbeeldingen/Schermafbeeldingen"]
|
||||
SnipTool -->|"Openen met"| CmdArgs["App gestart met bestandspad"]
|
||||
Clipboard --> Watcher[CaptureWatcher]
|
||||
ScreenshotsFolder --> Watcher
|
||||
CmdArgs --> Editor
|
||||
Watcher -->|toast/tray-melding of direct| Editor[EditorWindow]
|
||||
Editor -->|Ctrl+C / Opslaan| Output["Klembord / PNG-bestand"]
|
||||
```
|
||||
|
||||
- De app draait in de achtergrond met een tray-icoon (`NotifyIcon` via `H.NotifyIcon.Wpf`) en is single-instance (Mutex + named pipe om bestandspaden door te geven).
|
||||
- Bij een nieuw knipsel opent de editor direct of na een melding (instelbaar).
|
||||
|
||||
## Projectstructuur
|
||||
- [KnipPlus.sln](KnipPlus.sln)
|
||||
- [src/KnipPlus/App.xaml.cs](src/KnipPlus/App.xaml.cs): opstarten, single-instance, tray, verwerking van commandline-argumenten
|
||||
- [src/KnipPlus/Capture/ClipboardWatcher.cs](src/KnipPlus/Capture/ClipboardWatcher.cs): `AddClipboardFormatListener` + `WM_CLIPBOARDUPDATE`, controleert of het klembord een afbeelding bevat
|
||||
- [src/KnipPlus/Capture/FolderWatcher.cs](src/KnipPlus/Capture/FolderWatcher.cs): `FileSystemWatcher` op `KnownFolder Screenshots`, met dedupe tegen het klembord (hash)
|
||||
- [src/KnipPlus/Editor/EditorWindow.xaml](src/KnipPlus/Editor/EditorWindow.xaml): werkbalk (stijl zoals het Knipprogramma), canvas, eigenschappenpaneel (kleur, randdikte, stijl)
|
||||
- [src/KnipPlus/Editor/Annotations/](src/KnipPlus/Editor/Annotations/): één klasse per tool, met een gemeenschappelijke basis `Annotation` (Render, HitTest, Move/Resize-handles)
|
||||
- [src/KnipPlus/Editor/UndoStack.cs](src/KnipPlus/Editor/UndoStack.cs): undo/redo met commando's
|
||||
- [src/KnipPlus/Settings/](src/KnipPlus/Settings/): JSON-instellingen in `%AppData%\KnipPlus`
|
||||
|
||||
## Tools (annotaties als bewerkbare objecten, pas plat bij export)
|
||||
- **Rechthoek/ellips**: randkleur, vulkleur (optioneel/transparant), dikte, stijl (doorgetrokken/gestreept/gestippeld), afgeronde hoeken, schaduw
|
||||
- **Tekst in kader**: tekst met achtergrondkader, padding, lettertype/-grootte, kleur
|
||||
- **Stappen**: genummerde badges die automatisch ophogen; reeks kiezen: 1,2,3 / a,b,c / A,B,C / I,II,III
|
||||
- **Cursor**: stempel van een pijl- of handcursor (meegeleverde vectoriconen)
|
||||
- **Highlight**: semi-transparante markeerstift; met Shift een recht horizontale streep (standaard rechtgetrokken, instelbaar)
|
||||
- **Blur / pixelate**: rechthoekig gebied; effect berekend op de onderliggende bitmap (Gaussian blur of pixelblokken, sterkte instelbaar)
|
||||
- Extra: pijl, lijn, bijsnijden, selecteren/verplaatsen/verwijderen
|
||||
|
||||
## Export
|
||||
- Ctrl+C: gerenderde PNG naar het klembord (gemarkeerd zodat de eigen watcher het niet opnieuw oppikt)
|
||||
- Ctrl+S / Opslaan als: PNG/JPG
|
||||
- Rendering via `RenderTargetBitmap` op de originele resolutie (rekening houden met DPI)
|
||||
|
||||
## Integratie met "Openen met"
|
||||
- Registratie per gebruiker in `HKCU\Software\Classes\Applications\KnipPlus.exe` + `OpenWithProgids` voor `.png/.jpg`, zodat de app in het menu "Openen met" van het Knipprogramma staat (geen beheerdersrechten nodig)
|
||||
- Optioneel: automatisch starten met Windows (`HKCU\...\Run`)
|
||||
|
||||
## Fasering
|
||||
1. Skelet: solution, tray-app, single-instance, editor die een afbeelding toont
|
||||
2. Capture: klembord- en mapwatcher, instellingen
|
||||
3. Tools: vormen, tekstkader, stappen, highlight, cursor
|
||||
4. Blur/pixelate, undo/redo, selecteren/aanpassen
|
||||
5. Export, "Openen met"-registratie, autostart, installer (MSIX of Inno Setup)
|
||||
@@ -0,0 +1,85 @@
|
||||
---
|
||||
name: Print Screen sneltoets
|
||||
overview: Knip+ krijgt een instelbare sneltoets waarbij Print Screen gewoon gekozen kan worden. Die toets opent een eigen rechthoekselectie over het scherm; na loslaten gaat het knipsel naar de bestaande editor.
|
||||
todos:
|
||||
- id: hotkey-model
|
||||
content: HotkeyGesture op AppSettings, standaard Print Screen, JSON blijft werken
|
||||
status: completed
|
||||
- id: global-hook
|
||||
content: WH_KEYBOARD_LL-haak die Print Screen ziet, inslikt en de actie op de UI-thread start
|
||||
status: completed
|
||||
- id: settings-ui
|
||||
content: Instellingenkaart om de toets te kiezen, inclusief Print Screen, Esc en wissen
|
||||
status: completed
|
||||
- id: region-overlay
|
||||
content: Per-monitor rechthoekselectie en CopyFromScreen naar de editor
|
||||
status: completed
|
||||
- id: wire-up
|
||||
content: StartNewSnip, systeemvak en opslaan van instellingen op de haak aansluiten
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# Print Screen als sneltoets voor eigen knipsel
|
||||
|
||||
Knip+ start nu het Windows Knipprogramma via `ms-screenclip:` ([App.xaml.cs](src/KnipPlus/App.xaml.cs) `StartNewSnip`). Er is geen sneltoets, en een gewone WPF-`KeyDown` ziet Print Screen niet: Windows eet die toets op voordat het venster hem krijgt. Daarom komt er een globale low-level toetsaanslaghaak, plus een selectievenster.
|
||||
|
||||
De schermafbeelding (opname, actief venster, GIF) is alleen het voorbeeld van de toets. Alleen rechthoekselectie komt erbij; `ScreenRecorderLib` blijft ongebruikt.
|
||||
|
||||
## Sneltoets vastleggen
|
||||
|
||||
Nieuw type `HotkeyGesture` (Ctrl, Alt, Shift, Win + virtuele toets) op [AppSettings](src/KnipPlus/Settings/AppSettings.cs), opgeslagen via de bestaande JSON-store. Standaard: **Print Screen** zonder modifiers (`VK_SNAPSHOT` = `0x2C`). Ontbreekt het veld in een bestaand `settings.json`, dan geldt diezelfde standaard.
|
||||
|
||||
In [SettingsWindow.xaml](src/KnipPlus/Settings/SettingsWindow.xaml) een kaart **Sneltoets**:
|
||||
|
||||
- Knop met de huidige combinatie, bijvoorbeeld `Print Screen` of `Ctrl + Shift + P`.
|
||||
- Klikken zet de knop in luisterstand: de volgende toets (geen losse Ctrl/Alt/Shift/Win) wordt de sneltoets.
|
||||
- Print Screen komt binnen via dezelfde haak, niet via WPF, zodat de knop hem echt kan tonen.
|
||||
- Esc annuleert het kiezen. Delete of Backspace maakt de sneltoets leeg.
|
||||
- Korte toelichting: als Print Screen is gekozen, vangt Knip+ die toets af. Het Windows-knipprogramma opent dan niet óók.
|
||||
|
||||
## Globale haak
|
||||
|
||||
Nieuwe `GlobalHotkeyHook` in `src/KnipPlus/Infrastructure/`, P/Invoke in [NativeMethods.cs](src/KnipPlus/Interop/NativeMethods.cs):
|
||||
|
||||
- `SetWindowsHookEx(WH_KEYBOARD_LL)` op de UI-thread, delegate vastgehouden tegen GC, `UnhookWindowsHookEx` bij afsluiten.
|
||||
- Alleen key-down (`WM_KEYDOWN` / `WM_SYSKEYDOWN`), geen key-up.
|
||||
- Modifiers via `GetAsyncKeyState` (inclusief linker en rechter Windows-toets).
|
||||
- Bij een match: toets inslikken (niet doorgeven) en de actie met `BeginInvoke` starten, zodat de haak niet blokkeert.
|
||||
- Tijdens het kiezen van een sneltoets slikt de haak die toets ook in, maar start geen knipsel.
|
||||
|
||||
`RegisterHotKey` is hier niet genoeg: Windows heeft Print Screen vaak al geregistreerd, en dan faalt de registratie stil.
|
||||
|
||||
## Rechthoekselectie
|
||||
|
||||
Nieuw overlayvenster per monitor (`src/KnipPlus/Capture/RegionCaptureWindow.xaml`). De app is al PerMonitorV2 ([app.manifest](src/KnipPlus/app.manifest)).
|
||||
|
||||
- Randloos, altijd bovenop, niet in de taakbalk, donkere laag over `Screen.Bounds`.
|
||||
- Positie en grootte in fysieke pixels via `SetWindowPos`, daarna de WPF-grootte uit de DPI van dát scherm, zodat een scherm op 150% niet verschuift.
|
||||
- Slepen tekent de rechthoek (uitsparing in de dimlaag plus maatlabel). Loslaten met een minimale grootte (ongeveer 3×3 px) maakt het knipsel.
|
||||
- Esc of rechtermuisknop annuleert.
|
||||
- Pixels via `Graphics.CopyFromScreen` op de fysieke rechthoek, omzetten naar een bevroren `BitmapSource` (96 DPI, Bgra32) met het bestaande patroon in [PixelUtil](src/KnipPlus/Infrastructure/PixelUtil.cs). Het klembord blijft onaangeroerd, zodat de klembordbewaking het knipsel niet nog eens aanbiedt.
|
||||
|
||||
Daarna direct `OpenEditor`. Geen toast: de gebruiker heeft zelf geknipt.
|
||||
|
||||
## Aansluiting
|
||||
|
||||
In [App.xaml.cs](src/KnipPlus/App.xaml.cs):
|
||||
|
||||
- Haak starten na het laden van de instellingen, en opnieuw zetten bij Opslaan.
|
||||
- `StartNewSnip` opent de rechthoekselectie in plaats van `ms-screenclip:`. Daarmee gebruiken de knop **Nieuw** en Ctrl+N in de editor dezelfde selectie.
|
||||
- Zolang de selectie open is, negeert de haak een tweede druk.
|
||||
|
||||
In [TrayIcon.cs](src/KnipPlus/Infrastructure/TrayIcon.cs) een menu-item **Rechthoek knippen**, zodat het ook zonder toets kan.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Hook[GlobalHotkeyHook]
|
||||
Settings[Instellingen]
|
||||
Overlay[RegionCaptureWindow]
|
||||
Editor[EditorWindow]
|
||||
Settings -->|opslaan| Hook
|
||||
Hook -->|"Print Screen of gekozen toets"| Overlay
|
||||
Tray[Systeemvak en Nieuw] --> Overlay
|
||||
Overlay -->|BitmapSource| Editor
|
||||
```
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
name: Screencasts MP4 GIF
|
||||
overview: "Knip+ krijgt een eigen schermopname: gebied kiezen via tray of sneltoets, opnemen met geluid, muisaanwijzer en klikmarkering, daarna begin en einde inkorten en opslaan als MP4 of GIF."
|
||||
todos:
|
||||
- id: spike
|
||||
content: ScreenRecorderLib toevoegen, TFM naar windows10.0.19041.0, en controleren dat single-file publish met opname werkt
|
||||
status: completed
|
||||
- id: hotkey-tray
|
||||
content: GlobalHotkey + tray-item + StartRecording/StopRecording in App
|
||||
status: completed
|
||||
- id: selector
|
||||
content: RegionSelectorWindow (slepen, venster, heel scherm) en RegionFrameWindow
|
||||
status: completed
|
||||
- id: toolbar-session
|
||||
content: RecordingToolbar en RecordingSession (audio, cursor, klikken, pauze, aftellen)
|
||||
status: completed
|
||||
- id: screencast-window
|
||||
content: ScreencastWindow met preview en bereikschuif voor inkorten
|
||||
status: completed
|
||||
- id: export
|
||||
content: VideoExporter (MP4 trim) en GifEncoder (frames via GetThumbnailsAsync, quantisatie, delta-frames)
|
||||
status: completed
|
||||
- id: settings
|
||||
content: AppSettings-velden en sectie Screencasts in SettingsWindow
|
||||
status: completed
|
||||
- id: build
|
||||
content: build.ps1/installer bijwerken en eind-tot-eind testen
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# Screencasts (MP4 en GIF) in Knip+
|
||||
|
||||
## Aanpak
|
||||
|
||||
Er komt één opnamepad: Knip+ neemt altijd eerst op als MP4 in een tijdelijk bestand (met geluid als dat aan staat). Pas na het stoppen kies je in een nabewerkingsvenster het bereik en het formaat. Zo hoef je vooraf niets te beslissen, en GIF en MP4 delen dezelfde opname en dezelfde inkortfunctie.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
trigger["Tray of sneltoets"] --> selector[RegionSelectorWindow]
|
||||
selector --> toolbar["RecordingToolbar: start, pauze, stop"]
|
||||
toolbar --> session["RecordingSession: tijdelijke MP4"]
|
||||
session --> editor["ScreencastWindow: voorbeeld en inkorten"]
|
||||
editor --> mp4["VideoExporter: MP4"]
|
||||
editor --> gif["GifEncoder: GIF"]
|
||||
```
|
||||
|
||||
## Techniek
|
||||
|
||||
- **Opnemen**: NuGet-pakket `ScreenRecorderLib` (MIT, gebruikt Windows Media Foundation, H.264/AAC). Dat regelt in één keer het gekozen gebied, de muisaanwijzer, de klikmarkering en het mixen van systeemgeluid en microfoon. Er is dan geen ffmpeg nodig.
|
||||
- **Inkorten naar MP4**: `Windows.Media.Editing.MediaComposition` met `TrimTimeFromStart` en `TrimTimeFromEnd`, en daarna `RenderToFileAsync`. Daarvoor gaat het target framework in [src/KnipPlus/KnipPlus.csproj](src/KnipPlus/KnipPlus.csproj) van `net8.0-windows` naar `net8.0-windows10.0.19041.0`.
|
||||
- **GIF**: frames uit de MP4 halen met `MediaComposition.GetThumbnailsAsync`, met een instelbare fps (standaard 10) en maximale breedte. Daarna codeert een eigen `GifEncoder` ze: per frame een palet van 256 kleuren (octree-quantisatie), framevertraging, oneindig herhalen (NETSCAPE-extensie) en ongewijzigde pixels transparant maken, zodat het bestand klein blijft. De ingebouwde `GifBitmapEncoder` van WPF kent geen framevertraging of herhalen en is daarom niet bruikbaar.
|
||||
- **Risico bij single-file publish**: `ScreenRecorderLib` is een C++/CLI-assembly en die kan niet vanuit de bundel geladen worden. Daarom komt `-p:IncludeAllContentForSelfExtract=true` in [build.ps1](build.ps1) (of als fallback publiceren als map via de installer). Dit test ik direct als eerste stap.
|
||||
|
||||
## Nieuwe bestanden (`src/KnipPlus/Recording/`)
|
||||
|
||||
- `RegionSelectorWindow.xaml(.cs)`: een transparante overlay over alle schermen. Je sleept een rechthoek, klikt op een venster om dat te kiezen, of drukt op Enter voor het hele scherm. De selectie houdt rekening met DPI per monitor.
|
||||
- `RegionFrameWindow.cs`: een rand rond het opnamegebied die klikken doorlaat en buiten het gebied valt, zodat hij niet in de opname komt.
|
||||
- `RecordingToolbar.xaml(.cs)`: een kleine knoppenbalk die altijd bovenop blijft, naast het gebied. Daarop staan schakelaars voor systeemgeluid en microfoon, en knoppen voor start (met 3 seconden aftellen), pauze en stop, plus de verstreken tijd.
|
||||
- `RecordingSession.cs`: een wrapper om `ScreenRecorderLib.Recorder` (opties, status, fouten) die een tijdelijk MP4-pad oplevert in `%TEMP%\KnipPlus`.
|
||||
- `ScreencastWindow.xaml(.cs)`: voorbeeld van de opname via `MediaElement`, een bereikschuif met begin- en eindgreep en afspelen binnen het bereik. Verder knoppen "Opslaan als MP4", "Opslaan als GIF" (met fps en breedte) en "Kopiëren" (het bestand als FileDropList op het klembord). Bij het sluiten wordt het tijdelijke bestand opgeruimd.
|
||||
- `VideoExporter.cs`: MP4 inkorten en renderen.
|
||||
- `GifEncoder.cs`: GIF-encoder met quantisatie en het overslaan van ongewijzigde pixels per frame.
|
||||
|
||||
## Wijzigingen in bestaande bestanden
|
||||
|
||||
- [src/KnipPlus/Infrastructure/TrayIcon.cs](src/KnipPlus/Infrastructure/TrayIcon.cs): menu-item "Screencast opnemen..." en een event `RecordRequested`. Tijdens een opname wordt dit "Opname stoppen".
|
||||
- `src/KnipPlus/Infrastructure/GlobalHotkey.cs` (nieuw): `RegisterHotKey` via een verborgen `HwndSource`. Dezelfde sneltoets start of stopt de opname (standaard Ctrl + Shift + R). Als de sneltoets al bezet is, verschijnt er een ballonmelding.
|
||||
- [src/KnipPlus/Interop/NativeMethods.cs](src/KnipPlus/Interop/NativeMethods.cs): `RegisterHotKey`, `UnregisterHotKey` en `WindowFromPoint` (voor het kiezen van een venster).
|
||||
- [src/KnipPlus/App.xaml.cs](src/KnipPlus/App.xaml.cs): `StartRecording()` en `StopRecording()`. Er draait maximaal één opname tegelijk. Tijdens de opname staat `Capture.Paused` aan, zodat er geen knipsels opgepikt worden.
|
||||
- [src/KnipPlus/Settings/AppSettings.cs](src/KnipPlus/Settings/AppSettings.cs): `RecordHotkey`, `RecordSystemAudio`, `RecordMicrophone`, `RecordShowCursor`, `RecordHighlightClicks`, `RecordFps` (30), `GifFps` (10), `GifMaxWidth` (800) en `LastVideoFolder` (standaard `Video's\Knip+`).
|
||||
- [src/KnipPlus/Settings/SettingsWindow.xaml](src/KnipPlus/Settings/SettingsWindow.xaml) en de bijbehorende `.cs`: een sectie "Screencasts" met de sneltoets, standaardinstellingen voor geluid, muisaanwijzer en klikken, en GIF-instellingen.
|
||||
- [installer/KnipPlus.iss](installer/KnipPlus.iss): alleen aanpassen als de fallback (publiceren als map) nodig blijkt.
|
||||
@@ -0,0 +1,97 @@
|
||||
---
|
||||
name: Zipline upload
|
||||
overview: Een Zipline 4-uploadbestemming toevoegen die je configureert door het ShareX-bestand (.sxcu) of het Shell Script-bestand (.sh) uit Zipline te importeren. Kniip uploadt daarna zelf via HttpClient, met een aparte knop Uploaden in de editor en in het screencastvenster. De link komt op het klembord.
|
||||
todos:
|
||||
- id: config
|
||||
content: UploaderConfig + UploaderImporter (sxcu- en curl/sh-parser) in src/Kniip/Upload
|
||||
status: completed
|
||||
- id: client
|
||||
content: ZiplineUploader met HttpClient multipart, foutafhandeling en JSON-padlezer
|
||||
status: completed
|
||||
- id: settings
|
||||
content: AppSettings.Uploader, DPAPI-versleuteling token in SettingsStore, tabblad Uploaden (importeren/testen/verwijderen)
|
||||
status: completed
|
||||
- id: clipboard
|
||||
content: ClipboardWriter.SetText toevoegen
|
||||
status: completed
|
||||
- id: editor
|
||||
content: Uploadknop + Ctrl+U in EditorWindow, zichtbaar alleen met config; ToolbarPanel-volgorde controleren
|
||||
status: completed
|
||||
- id: screencast
|
||||
content: "Uploadknop in ScreencastWindow: export naar temp, upload met voortgang/annuleren, link naar klembord"
|
||||
status: completed
|
||||
- id: build
|
||||
content: Bouwen en testen met een echte .sxcu en .sh uit Zipline 4
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# Upload naar Zipline 4
|
||||
|
||||
## Hoe het werkt
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
sxcu["ShareX .sxcu"] --> importer[UploaderImporter]
|
||||
sh["Shell script .sh"] --> importer
|
||||
importer --> config["UploaderConfig in AppSettings"]
|
||||
editorBtn["Editor: Uploaden"] --> client[ZiplineUploader]
|
||||
castBtn["Screencast: Uploaden"] --> client
|
||||
config --> client
|
||||
client -->|"POST multipart"| server["Zipline /api/upload"]
|
||||
server -->|"JSON files[0].url"| client
|
||||
client --> clip["URL naar klembord + melding"]
|
||||
```
|
||||
|
||||
Zipline 4 gebruikt een vast patroon: `POST https://host/api/upload` met de header `authorization: <token>`, extra headers zoals `x-zipline-format`, een multipart-veld `file`, en als antwoord `{"files":[{"url": ...}]}`. Beide exportbestanden bevatten precies die gegevens. Kniip leest ze daarom alleen in en voert niets uit.
|
||||
|
||||
## Nieuwe bestanden in `src/Kniip/Upload/`
|
||||
|
||||
- `UploaderConfig.cs` bevat de velden `Name`, `RequestUrl`, `Headers` (een dictionary), `FileFormName` (standaard `"file"`) en `UrlPath` (standaard `"files[0].url"`). Daarnaast staat er een eigenschap `IsConfigured`.
|
||||
- `UploaderImporter.cs`:
|
||||
- `FromSxcu(string json)`: leest met `System.Text.Json` de velden `RequestURL`, `Headers`, `FileFormName` en `URL` uit. Van `URL` wordt `{json:files[0].url}` omgezet naar het pad `files[0].url`. Een `.sxcu` met `Body` anders dan `MultipartFormData` wordt geweigerd.
|
||||
- `FromShellScript(string text)`: zoekt de `curl`-regel. Daaruit haalt hij de `http(s)://`-URL, elke `-H "naam: waarde"` als header (behalve `content-type`), en de veldnaam uit `-F naam=@...`. Het antwoordpad komt uit `jq -r <pad>` als dat er staat, anders de standaardwaarde. Regelvervolgingen (`\` aan het einde van een regel) en aanhalingstekens worden gewoon afgehandeld.
|
||||
- Kan het bestand niet worden gelezen, dan geeft de importer een duidelijke Nederlandse foutmelding.
|
||||
- `ZiplineUploader.cs`: één statische `HttpClient` en de methode `Task<string> UploadAsync(UploaderConfig, Stream, string fileName, string contentType, IProgress<double>?, CancellationToken)`. Die bouwt de `MultipartFormDataContent`, voegt de headers toe en geeft bij een HTTP-fout de statuscode en de `error`-tekst van Zipline terug. Daarna leest hij de URL via een eenvoudige padlezer (`a.b[0].c`) uit het JSON-antwoord.
|
||||
|
||||
## Instellingen
|
||||
|
||||
- In [`src/Kniip/Settings/AppSettings.cs`](src/Kniip/Settings/AppSettings.cs) komt `public UploaderConfig? Uploader { get; set; }` bij.
|
||||
- De `authorization`-header is een geheime token. Die wordt in de settings-JSON met DPAPI versleuteld voor de huidige gebruiker, via `ProtectedData.Protect` en het NuGet-pakket `System.Security.Cryptography.ProtectedData`. Dat gebeurt in [`src/Kniip/Settings/SettingsStore.cs`](src/Kniip/Settings/SettingsStore.cs) bij het opslaan en laden.
|
||||
- In [`src/Kniip/Settings/SettingsWindow.xaml`](src/Kniip/Settings/SettingsWindow.xaml) komt een nieuw tabblad **Uploaden** met:
|
||||
- Een knop "Importeren..." met een `OpenFileDialog` en het filter `ShareX-uploader (*.sxcu)|*.sxcu|Shell script (*.sh)|*.sh`.
|
||||
- Een regel die de huidige bestemming toont: de servernaam uit de URL, zonder token.
|
||||
- De knoppen "Testen" (uploadt een klein PNG'tje en toont de link) en "Verwijderen".
|
||||
- Een hinttekst: "Maak het bestand in Zipline via Instellingen > Generate Uploaders (ShareX of Shell Script)."
|
||||
- De afhandeling komt in [`src/Kniip/Settings/SettingsWindow.xaml.cs`](src/Kniip/Settings/SettingsWindow.xaml.cs). De nieuwe configuratie wordt pas bij Opslaan vastgelegd, net als bij de andere instellingen.
|
||||
|
||||
## Editor
|
||||
|
||||
- In [`src/Kniip/Editor/EditorWindow.xaml`](src/Kniip/Editor/EditorWindow.xaml) komt een nieuwe toolbarknop `ItemId="Upload"` (icoon ``, titel "Uploaden"), tussen Opslaan en Kopiëren.
|
||||
- In [`src/Kniip/Editor/EditorWindow.xaml.cs`](src/Kniip/Editor/EditorWindow.xaml.cs) komt `Upload_Click` plus de sneltoets Ctrl+U:
|
||||
- `CommitTextEdit()`, daarna `DocumentRenderer.Export(_doc)` en vervolgens `ImageExporter.EncodePng`.
|
||||
- `UploadAsync` met de bestandsnaam `Knipsel yyyy-MM-dd HHmmss.png`.
|
||||
- Het resultaat gaat naar `ClipboardWriter.SetText(url)` en `ShowTemporaryHint("Geüpload, link gekopieerd: ...")`.
|
||||
- Tijdens het uploaden is de knop uitgeschakeld.
|
||||
- De knop is ingeklapt zolang `Uploader` niet is ingesteld. Dat wordt opnieuw gecontroleerd in `OnActivated`, net zoals `ApplySaveButton` dat doet.
|
||||
- Ik controleer in `ToolbarPanel` dat een nieuw `ItemId` ook verschijnt als er al een `ToolbarOrder` is opgeslagen. Zo niet, dan komen onbekende items achteraan.
|
||||
|
||||
## Screencast
|
||||
|
||||
- In [`src/Kniip/Recording/ScreencastWindow.xaml`](src/Kniip/Recording/ScreencastWindow.xaml) komt een knop `UploadButton` naast Kopiëren.
|
||||
- In [`src/Kniip/Recording/ScreencastWindow.xaml.cs`](src/Kniip/Recording/ScreencastWindow.xaml.cs) komt `Upload_Click`. Dat volgt hetzelfde patroon als `Copy_Click`:
|
||||
- Eerst `ExportAsync` naar een tijdelijke map.
|
||||
- Daarna `UploadAsync` met `video/mp4` of `image/gif`. De voortgang loopt via dezelfde `Progress`-balk en annuleren gaat via `_export`.
|
||||
- Daarna gaat de URL naar het klembord en toont `SavedText` de link.
|
||||
- `SetBusy` schakelt ook `UploadButton` uit.
|
||||
|
||||
## Hulpfunctie
|
||||
|
||||
- In [`src/Kniip/Editor/ImageExporter.cs`](src/Kniip/Editor/ImageExporter.cs) krijgt `ClipboardWriter` de methode `SetText(string)`, met dezelfde aanpak voor een bezet klembord als `SetFile`.
|
||||
|
||||
## Buiten de scope
|
||||
|
||||
- Automatisch uploaden bij een nieuw knipsel.
|
||||
- Een "opslaan en uploaden"-keuze voor de knop Opslaan.
|
||||
- Het echt uitvoeren van het shellscript.
|
||||
- Andere uploaders dan Zipline. De importer werkt in principe ook voor andere ShareX-configuraties met multipart, maar dat wordt niet getest.
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "../.."
|
||||
}
|
||||
],
|
||||
"settings": {}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 576 576" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><g><path d="M112,112l288,0c35.109,0 64,28.891 64,64l0,288l-288,0c-35.109,0 -64,-28.891 -64,-64l0,-288Z" style="fill:#eee;fill-rule:nonzero;"/><path d="M112,0c-8.777,0 -16,7.223 -16,16l0,80l-80,0c-8.777,0 -16,7.223 -16,16c0,8.777 7.223,16 16,16l80,0l0,272c0,44 36,80 80,80l272,0l0,80c0,8.777 7.223,16 16,16c8.777,0 16,-7.223 16,-16l0,-80l80,0c8.777,0 16,-7.223 16,-16c0,-8.777 -7.223,-16 -16,-16l-80,0l0,-272c0,-44 -36,-80 -80,-80l-272,0l0,-80c0,-8.777 -7.223,-16 -16,-16m16,128l272,0c26.688,0 48,21.312 48,48l0,272l-272,0c-26.688,0 -48,-21.312 -48,-48l0,-272Z" style="fill:#212121;fill-rule:nonzero;"/><path d="M224,240c-10.253,-0.025 -20.1,4.057 -27.328,11.328l-84.672,84.672l0,64c0,35.109 28.891,64 64,64l288,0l-212.672,-212.672c-7.228,-7.271 -17.075,-11.353 -27.328,-11.328" style="fill:#42a5f5;fill-rule:nonzero;"/><g><path d="M224,224c-14.496,0 -28.384,5.76 -38.624,16l-84.704,84.672c-2.998,3.005 -4.68,7.083 -4.672,11.328l0,64c0,44 36,80 80,80l288,0c0.01,0 0.019,0 0.029,0c8.777,0 16,-7.223 16,-16c-0,-4.249 -1.693,-8.327 -4.701,-11.328l-212.704,-212.672c-10.24,-10.24 -24.128,-16 -38.624,-16m0,32c6.016,0 11.744,2.368 16,6.624l185.376,185.376l-249.376,0c-26.688,0 -48,-21.312 -48,-48l0,-57.376l80,-80c4.228,-4.262 9.997,-6.65 16,-6.624m160,-96c-17.472,0 -32,14.528 -32,32c0,17.472 14.528,32 32,32c17.472,0 32,-14.528 32,-32c0,-17.472 -14.528,-32 -32,-32" style="fill-rule:nonzero;"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,5 @@
|
||||
bin/
|
||||
obj/
|
||||
artifacts/
|
||||
.vs/
|
||||
*.user
|
||||
@@ -0,0 +1,22 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kniip", "src\Kniip\Kniip.csproj", "{6B1F3C2A-8E4D-4C1B-9A57-2F3D9E7C4A10}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|x64 = Debug|x64
|
||||
Release|x64 = Release|x64
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{6B1F3C2A-8E4D-4C1B-9A57-2F3D9E7C4A10}.Debug|x64.ActiveCfg = Debug|x64
|
||||
{6B1F3C2A-8E4D-4C1B-9A57-2F3D9E7C4A10}.Debug|x64.Build.0 = Debug|x64
|
||||
{6B1F3C2A-8E4D-4C1B-9A57-2F3D9E7C4A10}.Release|x64.ActiveCfg = Release|x64
|
||||
{6B1F3C2A-8E4D-4C1B-9A57-2F3D9E7C4A10}.Release|x64.Build.0 = Release|x64
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
@@ -0,0 +1,52 @@
|
||||
# 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.
|
||||
# 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.
|
||||
#
|
||||
# .\build.ps1 -> artifacts\publish\Kniip.exe (+ installer indien mogelijk)
|
||||
# .\build.ps1 -Version 1.2.0
|
||||
|
||||
param(
|
||||
[string]$Version = "1.0.0",
|
||||
[string]$Runtime = "win-x64"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$root = $PSScriptRoot
|
||||
$publishDir = Join-Path $root "artifacts\publish"
|
||||
|
||||
# Gebruik een SDK in het gebruikersprofiel als er geen systeembrede SDK is.
|
||||
$localDotnet = Join-Path $env:LOCALAPPDATA "Microsoft\dotnet"
|
||||
if (-not (dotnet --list-sdks 2>$null) -and (Test-Path (Join-Path $localDotnet "dotnet.exe"))) {
|
||||
$env:DOTNET_ROOT = $localDotnet
|
||||
$env:PATH = "$localDotnet;$env:PATH"
|
||||
}
|
||||
|
||||
if (Test-Path $publishDir) { Remove-Item $publishDir -Recurse -Force }
|
||||
|
||||
dotnet publish (Join-Path $root "src\Kniip\Kniip.csproj") `
|
||||
-c Release -r $Runtime --self-contained true `
|
||||
-p:PublishSingleFile=true `
|
||||
-p:IncludeNativeLibrariesForSelfExtract=true `
|
||||
-p:IncludeAllContentForSelfExtract=true `
|
||||
-p:EnableCompressionInSingleFile=true `
|
||||
-p:DebugType=none `
|
||||
-p:Version=$Version `
|
||||
-o $publishDir
|
||||
if ($LASTEXITCODE -ne 0) { throw "dotnet publish is mislukt." }
|
||||
|
||||
Write-Host "Gepubliceerd: $publishDir\Kniip.exe" -ForegroundColor Green
|
||||
|
||||
$iscc = @(
|
||||
"${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe",
|
||||
"$env:ProgramFiles\Inno Setup 6\ISCC.exe",
|
||||
"$env:LOCALAPPDATA\Programs\Inno Setup 6\ISCC.exe"
|
||||
) | Where-Object { Test-Path $_ } | Select-Object -First 1
|
||||
|
||||
if ($iscc) {
|
||||
& $iscc "/DAppVersion=$Version" "/DPublishDir=$publishDir" (Join-Path $root "installer\Kniip.iss")
|
||||
if ($LASTEXITCODE -ne 0) { throw "Installer bouwen is mislukt." }
|
||||
Write-Host "Installer: $root\artifacts\Kniip-Setup-$Version.exe" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "Inno Setup 6 niet gevonden; installer overgeslagen (winget install JRSoftware.InnoSetup)." -ForegroundColor Yellow
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
; Inno Setup-script voor kniip (per-gebruiker installatie, geen beheerdersrechten nodig).
|
||||
; Bouwen: eerst .\build.ps1 (publiceert naar artifacts\publish), daarna compileert build.ps1 dit script
|
||||
; automatisch als Inno Setup 6 (https://jrsoftware.org/isinfo.php) is geïnstalleerd.
|
||||
|
||||
#define AppName "kniip"
|
||||
#define AppExe "Kniip.exe"
|
||||
#ifndef AppVersion
|
||||
#define AppVersion "1.0.0"
|
||||
#endif
|
||||
#ifndef PublishDir
|
||||
#define PublishDir "..\artifacts\publish"
|
||||
#endif
|
||||
|
||||
[Setup]
|
||||
AppId={{6B1F3C2A-8E4D-4C1B-9A57-2F3D9E7C4A10}
|
||||
AppName={#AppName}
|
||||
AppVersion={#AppVersion}
|
||||
AppPublisher=kniip
|
||||
DefaultDirName={localappdata}\Programs\Kniip
|
||||
DefaultGroupName={#AppName}
|
||||
DisableProgramGroupPage=yes
|
||||
PrivilegesRequired=lowest
|
||||
OutputDir=..\artifacts
|
||||
OutputBaseFilename=Kniip-Setup-{#AppVersion}
|
||||
SetupIconFile=..\src\Kniip\AppIcon.ico
|
||||
UninstallDisplayIcon={app}\{#AppExe}
|
||||
Compression=lzma2
|
||||
SolidCompression=yes
|
||||
WizardStyle=modern
|
||||
ArchitecturesAllowed=x64compatible
|
||||
ArchitecturesInstallIn64BitMode=x64compatible
|
||||
CloseApplications=yes
|
||||
|
||||
[Languages]
|
||||
Name: "dutch"; MessagesFile: "compiler:Languages\Dutch.isl"
|
||||
|
||||
[Tasks]
|
||||
Name: "autostart"; Description: "kniip starten met Windows (aanbevolen)"; GroupDescription: "Integratie:"
|
||||
Name: "openwith"; Description: "kniip tonen in 'Openen met' voor afbeeldingen (ook in het Knipprogramma)"; GroupDescription: "Integratie:"
|
||||
Name: "desktopicon"; Description: "Snelkoppeling op het bureaublad"; GroupDescription: "Snelkoppelingen:"; Flags: unchecked
|
||||
|
||||
[InstallDelete]
|
||||
; Overblijfselen van voor de hernoeming van Knip+ naar kniip; de registersleutels ruimt de app zelf op.
|
||||
Type: files; Name: "{app}\KnipPlus.exe"
|
||||
Type: files; Name: "{autoprograms}\Knip+.lnk"
|
||||
Type: files; Name: "{autodesktop}\Knip+.lnk"
|
||||
|
||||
[Files]
|
||||
Source: "{#PublishDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
|
||||
|
||||
[Icons]
|
||||
Name: "{autoprograms}\{#AppName}"; Filename: "{app}\{#AppExe}"
|
||||
Name: "{autodesktop}\{#AppName}"; Filename: "{app}\{#AppExe}"; Tasks: desktopicon
|
||||
|
||||
[Registry]
|
||||
; Autostart
|
||||
Root: HKCU; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; ValueType: string; ValueName: "Kniip"; ValueData: """{app}\{#AppExe}"" --tray"; Tasks: autostart; Flags: uninsdeletevalue
|
||||
|
||||
; "Openen met"
|
||||
Root: HKCU; Subkey: "Software\Classes\Kniip.Image"; ValueType: string; ValueName: ""; ValueData: "kniip afbeelding"; Tasks: openwith; Flags: uninsdeletekey
|
||||
Root: HKCU; Subkey: "Software\Classes\Kniip.Image\DefaultIcon"; ValueType: string; ValueName: ""; ValueData: """{app}\{#AppExe}"",0"; Tasks: openwith
|
||||
Root: HKCU; Subkey: "Software\Classes\Kniip.Image\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\{#AppExe}"" ""%1"""; Tasks: openwith
|
||||
Root: HKCU; Subkey: "Software\Classes\Applications\{#AppExe}"; ValueType: string; ValueName: "FriendlyAppName"; ValueData: "kniip editor"; Tasks: openwith; Flags: uninsdeletekey
|
||||
Root: HKCU; Subkey: "Software\Classes\Applications\{#AppExe}\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\{#AppExe}"" ""%1"""; Tasks: openwith
|
||||
Root: HKCU; Subkey: "Software\Classes\Applications\{#AppExe}\SupportedTypes"; ValueType: string; ValueName: ".png"; ValueData: ""; Tasks: openwith
|
||||
Root: HKCU; Subkey: "Software\Classes\Applications\{#AppExe}\SupportedTypes"; ValueType: string; ValueName: ".jpg"; ValueData: ""; Tasks: openwith
|
||||
Root: HKCU; Subkey: "Software\Classes\Applications\{#AppExe}\SupportedTypes"; ValueType: string; ValueName: ".jpeg"; ValueData: ""; Tasks: openwith
|
||||
Root: HKCU; Subkey: "Software\Classes\Applications\{#AppExe}\SupportedTypes"; ValueType: string; ValueName: ".bmp"; ValueData: ""; Tasks: openwith
|
||||
Root: HKCU; Subkey: "Software\Classes\Applications\{#AppExe}\SupportedTypes"; ValueType: string; ValueName: ".gif"; ValueData: ""; Tasks: openwith
|
||||
Root: HKCU; Subkey: "Software\Classes\.png\OpenWithProgids"; ValueType: none; ValueName: "Kniip.Image"; Tasks: openwith; Flags: uninsdeletevalue
|
||||
Root: HKCU; Subkey: "Software\Classes\.jpg\OpenWithProgids"; ValueType: none; ValueName: "Kniip.Image"; Tasks: openwith; Flags: uninsdeletevalue
|
||||
Root: HKCU; Subkey: "Software\Classes\.jpeg\OpenWithProgids"; ValueType: none; ValueName: "Kniip.Image"; Tasks: openwith; Flags: uninsdeletevalue
|
||||
Root: HKCU; Subkey: "Software\Classes\.bmp\OpenWithProgids"; ValueType: none; ValueName: "Kniip.Image"; Tasks: openwith; Flags: uninsdeletevalue
|
||||
Root: HKCU; Subkey: "Software\Classes\.gif\OpenWithProgids"; ValueType: none; ValueName: "Kniip.Image"; Tasks: openwith; Flags: uninsdeletevalue
|
||||
|
||||
[Run]
|
||||
Filename: "{app}\{#AppExe}"; Description: "kniip nu starten"; Flags: nowait postinstall skipifsilent
|
||||
|
||||
[UninstallRun]
|
||||
Filename: "{cmd}"; Parameters: "/C taskkill /IM {#AppExe} /F"; Flags: runhidden; RunOnceId: "StopKniip"
|
||||
; Autostart en "Openen met" kunnen ook via de instellingen van kniip zelf aangezet zijn.
|
||||
Filename: "{app}\{#AppExe}"; Parameters: "--unregister"; Flags: runhidden waituntilterminated; RunOnceId: "UnregisterKniip"
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<configuration>
|
||||
<packageSources>
|
||||
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
|
||||
</packageSources>
|
||||
</configuration>
|
||||
@@ -0,0 +1,303 @@
|
||||
<Application x:Class="Kniip.App"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
ShutdownMode="OnExplicitShutdown">
|
||||
<Application.Resources>
|
||||
<SolidColorBrush x:Key="AccentBrush" Color="#0067C0" />
|
||||
<SolidColorBrush x:Key="AccentHoverBrush" Color="#1975C5" />
|
||||
<SolidColorBrush x:Key="AccentPressedBrush" Color="#3183CA" />
|
||||
<SolidColorBrush x:Key="WindowBrush" Color="#F3F3F3" />
|
||||
<SolidColorBrush x:Key="ToolbarBrush" Color="#F9F9F9" />
|
||||
<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" />
|
||||
<FontFamily x:Key="IconFont">Segoe Fluent Icons, Segoe MDL2 Assets</FontFamily>
|
||||
|
||||
<Style TargetType="ToolTip">
|
||||
<Setter Property="FontFamily" Value="Segoe UI" />
|
||||
</Style>
|
||||
|
||||
<Style x:Key="FlatButton" TargetType="ButtonBase">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="Foreground" Value="{StaticResource InkBrush}" />
|
||||
<Setter Property="Padding" Value="8,5" />
|
||||
<Setter Property="MinWidth" Value="36" />
|
||||
<Setter Property="MinHeight" Value="34" />
|
||||
<Setter Property="Margin" Value="1,0" />
|
||||
<Setter Property="FontFamily" Value="Segoe UI" />
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<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}"
|
||||
BorderThickness="1" CornerRadius="4" Padding="{TemplateBinding Padding}" SnapsToDevicePixels="True">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<Border x:Name="Indicator" Height="3" Width="16" CornerRadius="1.5" Background="{StaticResource AccentBrush}"
|
||||
VerticalAlignment="Bottom" Margin="0,0,0,2" Visibility="Collapsed" />
|
||||
</Grid>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="{StaticResource HoverBrush}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsChecked" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="{StaticResource CheckedBrush}" />
|
||||
<Setter TargetName="Bd" Property="BorderBrush" Value="#E0E0E0" />
|
||||
<Setter TargetName="Indicator" Property="Visibility" Value="Visible" />
|
||||
</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="AccentButton" TargetType="Button">
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
<Setter Property="Background" Value="{StaticResource AccentBrush}" />
|
||||
<Setter Property="Padding" Value="14,6" />
|
||||
<Setter Property="MinHeight" Value="32" />
|
||||
<Setter Property="FontFamily" Value="Segoe UI" />
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
<Setter Property="Margin" Value="4,0" />
|
||||
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border x:Name="Bd" Background="{TemplateBinding Background}" CornerRadius="4" Padding="{TemplateBinding Padding}">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="{StaticResource AccentHoverBrush}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsPressed" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="{StaticResource AccentPressedBrush}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter Property="Opacity" Value="0.45" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="SecondaryButton" TargetType="Button" BasedOn="{StaticResource FlatButton}">
|
||||
<Setter Property="Background" Value="White" />
|
||||
<Setter Property="BorderBrush" Value="#D6D6D6" />
|
||||
<Setter Property="Padding" Value="12,5" />
|
||||
<Setter Property="MinHeight" Value="32" />
|
||||
<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="{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="" 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>
|
||||
</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="{StaticResource 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="White" BorderBrush="#E0E0E0" 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="#F5F5F5" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsChecked" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="#1A0067C0" />
|
||||
<Setter TargetName="Bd" Property="BorderBrush" Value="{StaticResource AccentBrush}" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="PropCombo" TargetType="ComboBox">
|
||||
<Setter Property="MinWidth" Value="80" />
|
||||
<Setter Property="Height" Value="28" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
</Style>
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
@@ -0,0 +1,494 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Windows;
|
||||
using System.Windows.Media.Imaging;
|
||||
using System.Windows.Threading;
|
||||
using Kniip.Capture;
|
||||
using Kniip.Editor;
|
||||
using Kniip.Infrastructure;
|
||||
using Kniip.Interop;
|
||||
using Kniip.Notifications;
|
||||
using Kniip.Recording;
|
||||
using Kniip.Settings;
|
||||
using Microsoft.Win32;
|
||||
using Screen = System.Windows.Forms.Screen;
|
||||
|
||||
namespace Kniip;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
private SingleInstance? _singleInstance;
|
||||
private TrayIcon? _tray;
|
||||
private SettingsWindow? _settingsWindow;
|
||||
private readonly List<RegionCaptureWindow> _snipWindows = new();
|
||||
private uint? _ignoredClipboardSequence;
|
||||
|
||||
public static App Instance => (App)Current;
|
||||
|
||||
public AppSettings Settings { get; private set; } = new();
|
||||
|
||||
internal CaptureCoordinator Capture { get; private set; } = null!;
|
||||
|
||||
internal RecordingController Recorder { get; private set; } = null!;
|
||||
|
||||
/// <summary>Toetsenbordhaak voor alle sneltoetsen (knipsels en screencasts).</summary>
|
||||
internal GlobalHotkeyHook? HotkeyHook { get; private set; }
|
||||
|
||||
protected override void OnStartup(StartupEventArgs e)
|
||||
{
|
||||
base.OnStartup(e);
|
||||
DispatcherUnhandledException += OnUnhandledException;
|
||||
|
||||
string[] args = e.Args;
|
||||
if (HandleMaintenanceArguments(args))
|
||||
{
|
||||
Shutdown();
|
||||
return;
|
||||
}
|
||||
|
||||
_singleInstance = new SingleInstance();
|
||||
if (!_singleInstance.TryAcquire())
|
||||
{
|
||||
SingleInstance.SendToPrimary(args);
|
||||
_singleInstance.Dispose();
|
||||
_singleInstance = null;
|
||||
Shutdown();
|
||||
return;
|
||||
}
|
||||
|
||||
System.Windows.Forms.Application.EnableVisualStyles();
|
||||
try
|
||||
{
|
||||
ShellIntegration.MigrateLegacyRegistration();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Een mislukte opruimactie in het register mag het starten niet blokkeren.
|
||||
}
|
||||
|
||||
Settings = SettingsStore.Load();
|
||||
|
||||
Capture = new CaptureCoordinator(Dispatcher);
|
||||
Capture.ApplySettings(Settings);
|
||||
Capture.CaptureReceived += OnCaptureReceived;
|
||||
|
||||
RecordingSession.CleanupTempFolder();
|
||||
Recorder = new RecordingController(Settings, Dispatcher);
|
||||
Recorder.StageChanged += OnRecordingStageChanged;
|
||||
Recorder.RecordingFinished += OpenScreencast;
|
||||
Recorder.Notify += (title, text) => _tray?.ShowBalloon(title, text);
|
||||
|
||||
_tray = new TrayIcon();
|
||||
_tray.OpenFromClipboardRequested += OpenFromClipboard;
|
||||
_tray.OpenFileRequested += OpenFileWithDialog;
|
||||
_tray.SnipRequested += BeginRegionSnip;
|
||||
_tray.WindowSnipRequested += BeginWindowSnip;
|
||||
_tray.RecordRequested += () =>
|
||||
{
|
||||
if (Recorder.Stage == RecordingStage.Idle)
|
||||
{
|
||||
Recorder.Toggle(ScreencastFormat.Mp4);
|
||||
}
|
||||
else
|
||||
{
|
||||
Recorder.TrayAction();
|
||||
}
|
||||
};
|
||||
_tray.GifRequested += () => Recorder.Toggle(ScreencastFormat.Gif);
|
||||
_tray.SettingsRequested += ShowSettings;
|
||||
_tray.ExitRequested += ExitApplication;
|
||||
_tray.PauseChanged += _ => UpdateCapturePaused();
|
||||
|
||||
HotkeyHook = new GlobalHotkeyHook(Dispatcher);
|
||||
ApplyHotkeys();
|
||||
|
||||
_singleInstance.StartServer(received => Dispatcher.BeginInvoke(() => HandleArguments(received, false)));
|
||||
HandleArguments(args, true);
|
||||
}
|
||||
|
||||
private static bool HandleMaintenanceArguments(string[] args)
|
||||
{
|
||||
if (args.Contains("--register-openwith", StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
ShellIntegration.RegisterOpenWith();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (args.Contains("--unregister", StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
ShellIntegration.UnregisterOpenWith();
|
||||
ShellIntegration.SetAutostart(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void HandleArguments(string[] args, bool isFirstStart)
|
||||
{
|
||||
string[] files = args.Where(a => !a.StartsWith("--", StringComparison.Ordinal)).ToArray();
|
||||
foreach (string file in files)
|
||||
{
|
||||
OpenFile(file);
|
||||
}
|
||||
|
||||
if (files.Length > 0 || args.Contains("--tray", StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (isFirstStart)
|
||||
{
|
||||
string? hotkey = HotkeyDisplay(Settings.SnipHotkey);
|
||||
_tray?.ShowBalloon("kniip is actief", hotkey == null
|
||||
? "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.");
|
||||
}
|
||||
|
||||
OpenBlankEditor();
|
||||
}
|
||||
|
||||
private void OnCaptureReceived(BitmapSource image)
|
||||
{
|
||||
switch (Settings.Action)
|
||||
{
|
||||
case CaptureAction.OpenEditor:
|
||||
OpenEditor(image);
|
||||
break;
|
||||
case CaptureAction.Notify:
|
||||
SnipToast.ShowFor(image, () => OpenEditor(image));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public void OpenEditor(BitmapSource image)
|
||||
{
|
||||
var editor = new EditorWindow(image, Settings);
|
||||
editor.Show();
|
||||
BringToFront(editor);
|
||||
}
|
||||
|
||||
/// <summary>Nieuw rechthoekknipsel na het sluiten van de editor.</summary>
|
||||
public void StartNewSnip()
|
||||
{
|
||||
// Even wachten zodat het gesloten editorvenster niet op het nieuwe knipsel staat.
|
||||
var timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) };
|
||||
timer.Tick += (_, _) =>
|
||||
{
|
||||
timer.Stop();
|
||||
BeginRegionSnip();
|
||||
};
|
||||
timer.Start();
|
||||
}
|
||||
|
||||
/// <summary>Bevriest alle schermen en laat een rechthoek slepen; het resultaat opent in de editor.</summary>
|
||||
public void BeginRegionSnip() => BeginSnip(pickWindow: false);
|
||||
|
||||
/// <summary>Bevriest alle schermen en laat een venster aanklikken; het resultaat opent in de editor.</summary>
|
||||
public void BeginWindowSnip() => BeginSnip(pickWindow: true);
|
||||
|
||||
private void BeginSnip(bool pickWindow)
|
||||
{
|
||||
if (_snipWindows.Count > 0 || Recorder.Stage == RecordingStage.Selecting)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
NativeMethods.GetCursorPos(out NativeMethods.POINT cursor);
|
||||
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.
|
||||
List<System.Drawing.Rectangle>? windows = pickWindow ? PhysicalWindow.SnapshotWindows(includeOwn: true) : null;
|
||||
var frozen = Screen.AllScreens.Select(screen => (screen, ScreenGrabber.Capture(screen.Bounds))).ToList();
|
||||
foreach ((Screen screen, BitmapSource image) in frozen)
|
||||
{
|
||||
var window = new RegionCaptureWindow(screen, image, windows);
|
||||
window.Finished += OnRegionSnipFinished;
|
||||
_snipWindows.Add(window);
|
||||
window.Show();
|
||||
}
|
||||
|
||||
RegionCaptureWindow? active = _snipWindows.FirstOrDefault(w => w.ContainsCursor(cursorPoint)) ?? _snipWindows.FirstOrDefault();
|
||||
active?.Activate();
|
||||
active?.Focus();
|
||||
|
||||
// Na een sneltoets in een ander programma krijgt de overlay vaak geen toetsenbordfocus;
|
||||
// Esc en Enter lopen daarom via de haak.
|
||||
if (HotkeyHook is { IsActive: true } hook)
|
||||
{
|
||||
hook.Capture = OnRegionSnipKey;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnRegionSnipKey(HotkeyGesture pressed)
|
||||
{
|
||||
NativeMethods.GetCursorPos(out NativeMethods.POINT cursor);
|
||||
var cursorPoint = new System.Drawing.Point(cursor.X, cursor.Y);
|
||||
RegionCaptureWindow? target = _snipWindows.FirstOrDefault(w => w.ContainsCursor(cursorPoint)) ?? _snipWindows.FirstOrDefault();
|
||||
if (target == null || pressed.Modifiers != System.Windows.Input.ModifierKeys.None)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (pressed.Key == System.Windows.Input.Key.Escape)
|
||||
{
|
||||
target.Cancel();
|
||||
}
|
||||
else if (pressed.Key == System.Windows.Input.Key.Enter)
|
||||
{
|
||||
target.SelectWholeScreen();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnRegionSnipFinished(BitmapSource? image)
|
||||
{
|
||||
if (HotkeyHook != null)
|
||||
{
|
||||
HotkeyHook.Capture = null;
|
||||
}
|
||||
|
||||
foreach (RegionCaptureWindow window in _snipWindows)
|
||||
{
|
||||
window.Finished -= OnRegionSnipFinished;
|
||||
window.Close();
|
||||
}
|
||||
|
||||
_snipWindows.Clear();
|
||||
if (image != null)
|
||||
{
|
||||
OpenEditor(image);
|
||||
}
|
||||
}
|
||||
|
||||
private string? HotkeyDisplay(string? hotkey) =>
|
||||
HotkeyHook is { IsActive: true } && HotkeyGesture.TryParse(hotkey, out HotkeyGesture gesture)
|
||||
? gesture.DisplayText
|
||||
: null;
|
||||
|
||||
private void ApplyHotkeys()
|
||||
{
|
||||
if (HotkeyHook != null)
|
||||
{
|
||||
var bindings = new Dictionary<HotkeyGesture, Action>();
|
||||
void Bind(string? hotkey, Action action)
|
||||
{
|
||||
if (HotkeyGesture.TryParse(hotkey, out HotkeyGesture gesture))
|
||||
{
|
||||
bindings.TryAdd(gesture, action);
|
||||
}
|
||||
}
|
||||
|
||||
Bind(Settings.SnipHotkey, BeginRegionSnip);
|
||||
Bind(Settings.WindowHotkey, BeginWindowSnip);
|
||||
Bind(Settings.RecordHotkey, () => Recorder.Toggle(ScreencastFormat.Mp4));
|
||||
Bind(Settings.GifHotkey, () => Recorder.Toggle(ScreencastFormat.Gif));
|
||||
HotkeyHook.Bindings = bindings;
|
||||
}
|
||||
|
||||
_tray?.SetHotkeys(HotkeyDisplay(Settings.SnipHotkey), HotkeyDisplay(Settings.WindowHotkey),
|
||||
HotkeyDisplay(Settings.RecordHotkey), HotkeyDisplay(Settings.GifHotkey));
|
||||
}
|
||||
|
||||
/// <summary>Start een screencast: eerst het gebied kiezen, daarna de knoppenbalk.</summary>
|
||||
public void StartRecording()
|
||||
{
|
||||
if (Recorder.Stage == RecordingStage.Idle)
|
||||
{
|
||||
Recorder.Toggle();
|
||||
}
|
||||
}
|
||||
|
||||
public void StopRecording()
|
||||
{
|
||||
if (Recorder.Stage is RecordingStage.Recording or RecordingStage.Paused)
|
||||
{
|
||||
Recorder.Toggle();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnRecordingStageChanged(RecordingStage stage)
|
||||
{
|
||||
string? text = stage switch
|
||||
{
|
||||
RecordingStage.Idle => null,
|
||||
RecordingStage.Recording or RecordingStage.Paused => "Opname stoppen",
|
||||
RecordingStage.Finishing => "Opname afronden...",
|
||||
_ => "Opname annuleren"
|
||||
};
|
||||
_tray?.SetRecordItem(text, stage is RecordingStage.Recording or RecordingStage.Paused);
|
||||
UpdateCapturePaused();
|
||||
}
|
||||
|
||||
/// <summary>Tijdens een screencast geen knipsels oppikken.</summary>
|
||||
private void UpdateCapturePaused() =>
|
||||
Capture.Paused = (_tray?.IsPaused ?? false) || Recorder.Stage != RecordingStage.Idle;
|
||||
|
||||
private void OpenScreencast(string path, ScreencastFormat? format)
|
||||
{
|
||||
var window = new ScreencastWindow(path, Settings, format);
|
||||
window.Show();
|
||||
BringToFront(window);
|
||||
}
|
||||
|
||||
/// <summary>Het knipsel op het klembord is weggegooid; pas na een nieuwe kopie weer openen.</summary>
|
||||
public void IgnoreCurrentClipboard() => _ignoredClipboardSequence = NativeMethods.GetClipboardSequenceNumber();
|
||||
|
||||
public void OpenFromClipboard()
|
||||
{
|
||||
BitmapSource? image = _ignoredClipboardSequence == NativeMethods.GetClipboardSequenceNumber()
|
||||
? null
|
||||
: ClipboardImageReader.TryRead(includeFiles: true);
|
||||
if (image == null)
|
||||
{
|
||||
OpenBlankEditor();
|
||||
return;
|
||||
}
|
||||
|
||||
OpenEditor(image);
|
||||
}
|
||||
|
||||
/// <summary>Editor met een leeg wit canvas, voor als er (nog) geen knipsel is.</summary>
|
||||
public void OpenBlankEditor(Window? placeLike = null)
|
||||
{
|
||||
const int width = 1280, height = 720;
|
||||
byte[] pixels = Enumerable.Repeat((byte)255, width * height * 4).ToArray();
|
||||
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);
|
||||
if (placeLike != null)
|
||||
{
|
||||
Rect bounds = placeLike.WindowState == WindowState.Normal
|
||||
? new Rect(placeLike.Left, placeLike.Top, placeLike.Width, placeLike.Height)
|
||||
: placeLike.RestoreBounds;
|
||||
editor.WindowStartupLocation = WindowStartupLocation.Manual;
|
||||
editor.Left = bounds.Left;
|
||||
editor.Top = bounds.Top;
|
||||
editor.Width = bounds.Width;
|
||||
editor.Height = bounds.Height;
|
||||
editor.WindowState = placeLike.WindowState == WindowState.Maximized ? WindowState.Maximized : WindowState.Normal;
|
||||
}
|
||||
|
||||
editor.Show();
|
||||
BringToFront(editor);
|
||||
}
|
||||
|
||||
public void OpenFile(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
OpenEditor(PixelUtil.LoadFile(path));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show($"Kan '{Path.GetFileName(path)}' niet openen.\n\n{ex.Message}", "kniip",
|
||||
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
private void OpenFileWithDialog()
|
||||
{
|
||||
if (PickImageFile(null) is { } path)
|
||||
{
|
||||
OpenFile(path);
|
||||
}
|
||||
}
|
||||
|
||||
public string? PickImageFile(Window? owner)
|
||||
{
|
||||
var dialog = new OpenFileDialog
|
||||
{
|
||||
Title = "Afbeelding openen in kniip",
|
||||
Filter = "Afbeeldingen|*.png;*.jpg;*.jpeg;*.bmp;*.gif|Alle bestanden|*.*",
|
||||
InitialDirectory = CaptureCoordinator.ScreenshotsFolder ?? Environment.GetFolderPath(Environment.SpecialFolder.MyPictures)
|
||||
};
|
||||
bool? ok = owner == null ? dialog.ShowDialog() : dialog.ShowDialog(owner);
|
||||
return ok == true ? dialog.FileName : null;
|
||||
}
|
||||
|
||||
public void ShowSettings()
|
||||
{
|
||||
if (_settingsWindow == null)
|
||||
{
|
||||
_settingsWindow = new SettingsWindow(Settings);
|
||||
_settingsWindow.Closed += (_, _) => _settingsWindow = null;
|
||||
_settingsWindow.Show();
|
||||
}
|
||||
|
||||
BringToFront(_settingsWindow);
|
||||
}
|
||||
|
||||
public void ShowBalloon(string title, string text) => _tray?.ShowBalloon(title, text);
|
||||
|
||||
public void SaveSettings()
|
||||
{
|
||||
SettingsStore.Save(Settings);
|
||||
Capture.ApplySettings(Settings);
|
||||
ApplyHotkeys();
|
||||
}
|
||||
|
||||
private static void BringToFront(Window window)
|
||||
{
|
||||
if (window.WindowState == WindowState.Minimized)
|
||||
{
|
||||
window.WindowState = WindowState.Normal;
|
||||
}
|
||||
|
||||
window.Topmost = true;
|
||||
window.Activate();
|
||||
window.Topmost = false;
|
||||
window.Focus();
|
||||
}
|
||||
|
||||
private void ExitApplication()
|
||||
{
|
||||
if (Recorder.Stage != RecordingStage.Idle)
|
||||
{
|
||||
MessageBox.Show("Er loopt nog een screencast. Stop of annuleer die eerst.", "kniip",
|
||||
MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (Window window in Windows.Cast<Window>().ToList())
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
|
||||
if (Windows.OfType<EditorWindow>().Any())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Shutdown();
|
||||
}
|
||||
|
||||
protected override void OnExit(ExitEventArgs e)
|
||||
{
|
||||
if (_singleInstance != null)
|
||||
{
|
||||
SettingsStore.Save(Settings);
|
||||
}
|
||||
|
||||
Capture?.Dispose();
|
||||
HotkeyHook?.Dispose();
|
||||
_tray?.Dispose();
|
||||
_singleInstance?.Dispose();
|
||||
base.OnExit(e);
|
||||
}
|
||||
|
||||
private void OnUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(SettingsStore.Folder);
|
||||
File.AppendAllText(Path.Combine(SettingsStore.Folder, "error.log"), $"{DateTime.Now:O}\n{e.Exception}\n\n");
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Loggen is best effort.
|
||||
}
|
||||
|
||||
MessageBox.Show($"Er ging iets mis:\n\n{e.Exception.Message}", "kniip", MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
@@ -0,0 +1,12 @@
|
||||
Emoji in kniip
|
||||
|
||||
twemoji-64.png
|
||||
Twemoji, Copyright 2019 Twitter, Inc and other contributors (https://github.com/jdecked/twemoji)
|
||||
Graphics licensed under CC-BY 4.0: https://creativecommons.org/licenses/by/4.0/
|
||||
Spritesheet uit emoji-datasource-twitter 15.1.2 (https://github.com/iamcal/emoji-data, MIT).
|
||||
|
||||
emoji.json
|
||||
Afgeleid van emoji-datasource 15.1.2 (MIT, Copyright (c) 2013 Cal Henderson)
|
||||
en de Unicode CLDR 46 annotaties voor nl en en (Unicode License v3, https://www.unicode.org/license.txt).
|
||||
|
||||
Opnieuw genereren: tools/fetch-emoji.ps1
|
||||
@@ -0,0 +1 @@
|
||||
[{"u":"1f600","c":"Smileys \u0026 Emotion","o":1,"x":32,"y":46,"s":"grinning","n":"grijnzend gezicht","k":"grinning|grinning face|grijnzend gezicht|gezicht|grappig|grijns|lachen|lachend gezicht met open ogen|leuk|smiley|cheerful|cheery|face|grin|happy|laugh|nice|smile|smiling|teeth"},{"u":"1f603","c":"Smileys \u0026 Emotion","o":2,"x":32,"y":49,"s":"smiley","n":"grijnzend gezicht met grote ogen","k":"smiley|smiling face with open mouth|grijnzend gezicht met grote ogen|gaaf|gezicht|grappig|haha|lach|lachen|lachend gezicht met open mond|leuk|mond|open|grinning face with big eyes|awesome|big|eyes|face|grin|grinning|happy|mouth|smile|smiling|teeth|yay"},{"u":"1f604","c":"Smileys \u0026 Emotion","o":3,"x":32,"y":50,"s":"smile","n":"grijnzend gezicht met lachende ogen","k":"smile|smiling face with open mouth and smiling eyes|grijnzend gezicht met lachende ogen|gelukkig|gezicht|grijns|grijnzen|lach|lachen|lol|mond|ogen|oog|open|grinning face with smiling eyes|eye|eyes|face|grin|grinning|happy|laugh|mouth|smiling"},{"u":"1f601","c":"Smileys \u0026 Emotion","o":4,"x":32,"y":47,"s":"grin","n":"stralend gezicht met lachende ogen","k":"grin|grinning face with smiling eyes|stralend gezicht met lachende ogen|gezicht|glimlach|grappig|grijns|grijnzend|grijnzend gezicht met lachende ogen|lach|lachen|lachende ogen|leuk|oog|smiley|beaming face with smiling eyes|beaming|eye|eyes|face|grinning|happy|nice|smile|smiling|teeth"},{"u":"1f606","c":"Smileys \u0026 Emotion","o":5,"x":32,"y":52,"s":"laughing","n":"grijnzend gezicht met stijf dichtgeknepen ogen","k":"laughing|satisfied|smiling face with open mouth and tightly-closed eyes|grijnzend gezicht met stijf dichtgeknepen ogen|dichtgeknepen ogen|gelukkig|gesloten|gezicht|glimlach|glimlachend|grijnzen|haha|hahaha|lachen|lol|mond|ogen|open|rofl|grinning squinting face|closed|eyes|face|grinning|happy|laugh|mouth|smile|smiling|squinting"},{"u":"1f605","c":"Smileys \u0026 Emotion","o":6,"x":32,"y":51,"s":"sweat_smile","n":"grijnzend gezicht met zweetdruppels","k":"sweat smile|smiling face with open mouth and cold sweat|grijnzend gezicht met zweetdruppels|gestrest|gezicht|grijnzen|koud|lach|lachen|mond|neerslachtig|nerveus|open|opgewonden|stress|zweet|grinning face with sweat|cold|dejected|excited|face|grinning|mouth|nervous|smile|smiling|stressed|sweat"},{"u":"1f923","c":"Smileys \u0026 Emotion","o":7,"x":40,"y":54,"s":"rolling_on_the_floor_laughing","n":"gezicht dat op de grond rolt van het lachen","k":"rolling on the floor laughing|gezicht dat op de grond rolt van het lachen|gelukkig|gezicht|grappig|haha|hehe|hilarisch|huilen|lachen|lmao|lol|rofl|roflmao|rollen|traan|vloer|vreugde|crying|face|floor|funny|happy|hilarious|joy|laugh|rolling|tear"},{"u":"1f602","c":"Smileys \u0026 Emotion","o":8,"x":32,"y":48,"s":"joy","n":"gezicht met tranen van vreugde","k":"joy|face with tears of joy|gezicht met tranen van vreugde|gelukkig|gevoelens|gezicht|grappig|haha|hehe|hilarisch|huilen|lachen|lmao|lol|rofl|roflmao|traan|vreugde|crying|face|feels|funny|happy|hilarious|laugh|tear"},{"u":"1f642","c":"Smileys \u0026 Emotion","o":9,"x":33,"y":55,"s":"slightly_smiling_face","n":"enigszins lachend gezicht","k":"slightly smiling face|enigszins lachend gezicht|gezicht|glimlach|lach|smiley|tevreden|face|happy|slightly|smile|smiling"},{"u":"1f643","c":"Smileys \u0026 Emotion","o":10,"x":33,"y":56,"s":"upside_down_face","n":"omgekeerd gezicht","k":"upside down face|upside-down face|omgekeerd gezicht|gezicht|glimlach|hehe|omgekeerd|face|smile|upside-down"},{"u":"1fae0","c":"Smileys \u0026 Emotion","o":11,"x":56,"y":30,"s":"melting_face","n":"smeltend gezicht","k":"melting face|smeltend gezicht|heet|oplossen|smelten|smiley|verdwijnen|vloeibaar|warm|disappear|dissolve|embarrassed|face|haha|heat|hot|liquid|lol|melt|melting|sarcasm|sarcastic"},{"u":"1f609","c":"Smileys \u0026 Emotion","o":12,"x":32,"y":55,"s":"wink","n":"knipogend gezicht","k":"wink|winking face|knipogend gezicht|flirten|gezicht|knipoog|sexy|smiley|zwoel|face|flirt|heartbreaker|slide|tease|winking|winks"},{"u":"1f60a","c":"Smileys \u0026 Emotion","o":13,"x":32,"y":56,"s":"blush","n":"lachend gezicht met lachende ogen","k":"blush|smiling face with smiling eyes|lachend gezicht met lachende ogen|blosjes|blozen|gezicht|glimlach|lach|lachen|oog|rode wangen|smiley|eye|eyes|face|glad|satisfied|smile|smiling"},{"u":"1f607","c":"Smileys \u0026 Emotion","o":14,"x":32,"y":53,"s":"innocent","n":"lachend gezicht met halo","k":"innocent|smiling face with halo|lachend gezicht met halo|aureool|engel|fantasie|gezegend|gezicht|glimlach|halo|heilig|lach|lach met halo|onschuldig|smiley|stralenkrans|angel|angelic|angels|blessed|face|fairy|fairytale|fantasy|happy|peaceful|smile|smiling|spirit|tale"},{"u":"1f970","c":"Smileys \u0026 Emotion","o":15,"x":44,"y":32,"s":"smiling_face_with_3_hearts","n":"lachend gezicht met hartjes","k":"smiling face with 3 hearts|smiling face with smiling eyes and three hearts|lachend gezicht met hartjes|crush|dol|gezicht|hart|hartjes|jLine truncated
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 11 MiB |
@@ -0,0 +1,112 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Windows.Media.Imaging;
|
||||
using System.Windows.Threading;
|
||||
using Kniip.Infrastructure;
|
||||
using Kniip.Interop;
|
||||
using Kniip.Settings;
|
||||
|
||||
namespace Kniip.Capture;
|
||||
|
||||
/// <summary>
|
||||
/// Combineert klembord- en mapbewaking. Het Knipprogramma kopieert én bewaart elk knipsel,
|
||||
/// dus dubbele meldingen van hetzelfde knipsel worden hier weggefilterd.
|
||||
/// </summary>
|
||||
internal sealed class CaptureCoordinator : IDisposable
|
||||
{
|
||||
private static readonly TimeSpan HashWindow = TimeSpan.FromSeconds(30);
|
||||
private static readonly TimeSpan SameSizeWindow = TimeSpan.FromSeconds(4);
|
||||
|
||||
private readonly ClipboardWatcher _clipboard;
|
||||
private readonly FolderWatcher _folder;
|
||||
private readonly List<RecentCapture> _recent = new();
|
||||
private readonly ConcurrentDictionary<string, DateTime> _ignoredPaths = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public event Action<BitmapSource>? CaptureReceived;
|
||||
|
||||
public bool Paused { get; set; }
|
||||
|
||||
public static string? ScreenshotsFolder
|
||||
{
|
||||
get
|
||||
{
|
||||
string? path = NativeMethods.GetKnownFolderPath(NativeMethods.FOLDERID_Screenshots);
|
||||
if (path != null)
|
||||
{
|
||||
return path;
|
||||
}
|
||||
|
||||
string pictures = Environment.GetFolderPath(Environment.SpecialFolder.MyPictures);
|
||||
return pictures.Length == 0 ? null : Path.Combine(pictures, "Screenshots");
|
||||
}
|
||||
}
|
||||
|
||||
public CaptureCoordinator(Dispatcher dispatcher)
|
||||
{
|
||||
_clipboard = new ClipboardWatcher();
|
||||
_clipboard.ImageCaptured += OnImage;
|
||||
|
||||
_folder = new FolderWatcher(dispatcher) { ShouldIgnore = IsIgnored };
|
||||
_folder.ImageCaptured += (image, _) => OnImage(image);
|
||||
}
|
||||
|
||||
public void ApplySettings(AppSettings settings)
|
||||
{
|
||||
_clipboard.Enabled = settings.WatchClipboard;
|
||||
_clipboard.OnlyFromSnippingTool = settings.OnlyFromSnippingTool;
|
||||
_folder.Enabled = settings.WatchScreenshotsFolder;
|
||||
}
|
||||
|
||||
/// <summary>Bestanden die kniip zelf opslaat niet opnieuw oppikken.</summary>
|
||||
public void IgnorePath(string path) => _ignoredPaths[Path.GetFullPath(path)] = DateTime.UtcNow;
|
||||
|
||||
private bool IsIgnored(string path)
|
||||
{
|
||||
DateTime now = DateTime.UtcNow;
|
||||
foreach (KeyValuePair<string, DateTime> entry in _ignoredPaths)
|
||||
{
|
||||
if (now - entry.Value > TimeSpan.FromMinutes(2))
|
||||
{
|
||||
_ignoredPaths.TryRemove(entry.Key, out _);
|
||||
}
|
||||
}
|
||||
|
||||
return _ignoredPaths.ContainsKey(Path.GetFullPath(path));
|
||||
}
|
||||
|
||||
private void OnImage(BitmapSource image)
|
||||
{
|
||||
if (Paused)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
DateTime now = DateTime.UtcNow;
|
||||
_recent.RemoveAll(r => now - r.Time > HashWindow);
|
||||
|
||||
string hash = PixelUtil.ComputeHash(image);
|
||||
foreach (RecentCapture recent in _recent)
|
||||
{
|
||||
bool sameContent = recent.Hash == hash;
|
||||
bool sameSizeJustNow = recent.Width == image.PixelWidth && recent.Height == image.PixelHeight
|
||||
&& now - recent.Time < SameSizeWindow;
|
||||
if (sameContent || sameSizeJustNow)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_recent.Add(new RecentCapture(hash, image.PixelWidth, image.PixelHeight, now));
|
||||
CaptureReceived?.Invoke(image);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_clipboard.Dispose();
|
||||
_folder.Dispose();
|
||||
}
|
||||
|
||||
private readonly record struct RecentCapture(string Hash, int Width, int Height, DateTime Time);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Windows;
|
||||
using System.Windows.Media.Imaging;
|
||||
using Kniip.Infrastructure;
|
||||
|
||||
namespace Kniip.Capture;
|
||||
|
||||
/// <summary>
|
||||
/// Leest afbeeldingen robuust van het klembord. <see cref="Clipboard.GetImage"/> van WPF gaat vaak mis
|
||||
/// met 32-bit DIB's, dus eerst "PNG", dan zelf de DIB decoderen, en pas als laatste de standaard-API.
|
||||
/// </summary>
|
||||
internal static class ClipboardImageReader
|
||||
{
|
||||
public const string ExportMarkerFormat = "Kniip.Export";
|
||||
|
||||
public static bool IsOwnExport()
|
||||
{
|
||||
try
|
||||
{
|
||||
return Clipboard.ContainsData(ExportMarkerFormat);
|
||||
}
|
||||
catch (COMException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly string[] ImageExtensions = { ".png", ".jpg", ".jpeg", ".bmp", ".gif", ".tif", ".tiff", ".ico", ".webp", ".heic" };
|
||||
|
||||
/// <param name="includeFiles">Ook een in Verkenner gekopieerd afbeeldingsbestand openen
|
||||
/// (niet voor de klembordbewaking, anders opent elk gekopieerd bestand de editor).</param>
|
||||
public static BitmapSource? TryRead(bool includeFiles = false)
|
||||
{
|
||||
try
|
||||
{
|
||||
IDataObject? data = Clipboard.GetDataObject();
|
||||
if (data == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static BitmapSource? TryReadFile(IDataObject data)
|
||||
{
|
||||
if (!data.GetDataPresent(DataFormats.FileDrop) || data.GetData(DataFormats.FileDrop) is not string[] files)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (string file in files)
|
||||
{
|
||||
if (!File.Exists(file) || Array.IndexOf(ImageExtensions, Path.GetExtension(file).ToLowerInvariant()) < 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return PixelUtil.LoadFile(file);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Onleesbaar bestand: volgende proberen.
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static BitmapSource? FromDib(byte[] dib)
|
||||
{
|
||||
if (dib.Length < 40)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
int headerSize = BitConverter.ToInt32(dib, 0);
|
||||
int bitCount = BitConverter.ToInt16(dib, 14);
|
||||
int compression = BitConverter.ToInt32(dib, 16);
|
||||
int colorsUsed = BitConverter.ToInt32(dib, 32);
|
||||
|
||||
const int BI_BITFIELDS = 3;
|
||||
int masks = compression == BI_BITFIELDS && headerSize == 40 ? 12 : 0;
|
||||
int colors = colorsUsed != 0 ? colorsUsed : bitCount <= 8 ? 1 << bitCount : 0;
|
||||
int pixelOffset = 14 + headerSize + masks + colors * 4;
|
||||
|
||||
var file = new byte[14 + dib.Length];
|
||||
file[0] = (byte)'B';
|
||||
file[1] = (byte)'M';
|
||||
BitConverter.GetBytes(file.Length).CopyTo(file, 2);
|
||||
BitConverter.GetBytes(pixelOffset).CopyTo(file, 10);
|
||||
Buffer.BlockCopy(dib, 0, file, 14, dib.Length);
|
||||
|
||||
try
|
||||
{
|
||||
return PixelUtil.Decode(new MemoryStream(file));
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Interop;
|
||||
using System.Windows.Media.Imaging;
|
||||
using Kniip.Interop;
|
||||
|
||||
namespace Kniip.Capture;
|
||||
|
||||
/// <summary>
|
||||
/// Luistert met een message-only venster naar WM_CLIPBOARDUPDATE en meldt nieuwe afbeeldingen.
|
||||
/// </summary>
|
||||
internal sealed class ClipboardWatcher : IDisposable
|
||||
{
|
||||
private static readonly string[] SnippingToolProcesses =
|
||||
{
|
||||
"SnippingTool", "ScreenClippingHost", "ScreenSketch"
|
||||
};
|
||||
|
||||
private readonly HwndSource _source;
|
||||
private bool _listening;
|
||||
|
||||
public event Action<BitmapSource>? ImageCaptured;
|
||||
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
public bool OnlyFromSnippingTool { get; set; } = true;
|
||||
|
||||
public ClipboardWatcher()
|
||||
{
|
||||
var parameters = new HwndSourceParameters("KniipClipboardWatcher")
|
||||
{
|
||||
ParentWindow = NativeMethods.HWND_MESSAGE,
|
||||
WindowStyle = 0
|
||||
};
|
||||
_source = new HwndSource(parameters);
|
||||
_source.AddHook(WndProc);
|
||||
_listening = NativeMethods.AddClipboardFormatListener(_source.Handle);
|
||||
}
|
||||
|
||||
private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
|
||||
{
|
||||
if (msg == NativeMethods.WM_CLIPBOARDUPDATE && Enabled)
|
||||
{
|
||||
OnClipboardChanged();
|
||||
}
|
||||
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
|
||||
private async void OnClipboardChanged()
|
||||
{
|
||||
string? owner = GetOwnerProcessName(out bool isOwnProcess);
|
||||
if (isOwnProcess)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (OnlyFromSnippingTool && !IsSnippingTool(owner))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Het Knipprogramma vult het klembord soms in stappen; even wachten en een paar keer proberen.
|
||||
for (int attempt = 0; attempt < 6; attempt++)
|
||||
{
|
||||
await Task.Delay(attempt == 0 ? 120 : 200);
|
||||
if (ClipboardImageReader.IsOwnExport())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
BitmapSource? image = ClipboardImageReader.TryRead();
|
||||
if (image != null)
|
||||
{
|
||||
ImageCaptured?.Invoke(image);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsSnippingTool(string? processName)
|
||||
{
|
||||
if (processName == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (string name in SnippingToolProcesses)
|
||||
{
|
||||
if (string.Equals(processName, name, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string? GetOwnerProcessName(out bool isOwnProcess)
|
||||
{
|
||||
isOwnProcess = false;
|
||||
IntPtr owner = NativeMethods.GetClipboardOwner();
|
||||
if (owner == IntPtr.Zero)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
NativeMethods.GetWindowThreadProcessId(owner, out uint pid);
|
||||
if (pid == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (pid == Environment.ProcessId)
|
||||
{
|
||||
isOwnProcess = true;
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var process = Process.GetProcessById((int)pid);
|
||||
return process.ProcessName;
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_listening)
|
||||
{
|
||||
NativeMethods.RemoveClipboardFormatListener(_source.Handle);
|
||||
_listening = false;
|
||||
}
|
||||
|
||||
_source.RemoveHook(WndProc);
|
||||
_source.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Media.Imaging;
|
||||
using System.Windows.Threading;
|
||||
using Kniip.Infrastructure;
|
||||
|
||||
namespace Kniip.Capture;
|
||||
|
||||
/// <summary>
|
||||
/// Bewaakt de map Schermafbeeldingen, waar het Knipprogramma knipsels automatisch opslaat.
|
||||
/// </summary>
|
||||
internal sealed class FolderWatcher : IDisposable
|
||||
{
|
||||
private readonly Dispatcher _dispatcher;
|
||||
private FileSystemWatcher? _watcher;
|
||||
|
||||
public event Action<BitmapSource, string>? ImageCaptured;
|
||||
|
||||
public Func<string, bool>? ShouldIgnore { get; set; }
|
||||
|
||||
public FolderWatcher(Dispatcher dispatcher)
|
||||
{
|
||||
_dispatcher = dispatcher;
|
||||
}
|
||||
|
||||
public bool Enabled
|
||||
{
|
||||
get => _watcher?.EnableRaisingEvents == true;
|
||||
set
|
||||
{
|
||||
if (value)
|
||||
{
|
||||
Start();
|
||||
}
|
||||
else if (_watcher != null)
|
||||
{
|
||||
_watcher.EnableRaisingEvents = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
if (_watcher != null)
|
||||
{
|
||||
_watcher.EnableRaisingEvents = true;
|
||||
return;
|
||||
}
|
||||
|
||||
string? folder = CaptureCoordinator.ScreenshotsFolder;
|
||||
if (folder == null || !Directory.Exists(folder))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_watcher = new FileSystemWatcher(folder)
|
||||
{
|
||||
IncludeSubdirectories = false,
|
||||
NotifyFilter = NotifyFilters.FileName
|
||||
};
|
||||
_watcher.Created += (_, e) => OnFileAppeared(e.FullPath);
|
||||
_watcher.Renamed += (_, e) => OnFileAppeared(e.FullPath);
|
||||
_watcher.EnableRaisingEvents = true;
|
||||
}
|
||||
|
||||
private void OnFileAppeared(string path)
|
||||
{
|
||||
string ext = Path.GetExtension(path);
|
||||
if (!ext.Equals(".png", StringComparison.OrdinalIgnoreCase)
|
||||
&& !ext.Equals(".jpg", StringComparison.OrdinalIgnoreCase)
|
||||
&& !ext.Equals(".jpeg", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (ShouldIgnore?.Invoke(path) == true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Task.Run(async () =>
|
||||
{
|
||||
for (int attempt = 0; attempt < 25; attempt++)
|
||||
{
|
||||
await Task.Delay(200);
|
||||
try
|
||||
{
|
||||
BitmapSource image = PixelUtil.LoadFile(path);
|
||||
await _dispatcher.InvokeAsync(() => ImageCaptured?.Invoke(image, path));
|
||||
return;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Bestand wordt nog geschreven.
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Nog onvolledig of geen geldige afbeelding; opnieuw proberen.
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_watcher?.Dispose();
|
||||
_watcher = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<Window x:Class="Kniip.Capture.RegionCaptureWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="kniip rechthoek knippen" WindowStyle="None" ResizeMode="NoResize"
|
||||
ShowInTaskbar="False" Topmost="True" Cursor="Cross" FontFamily="Segoe UI" FontSize="13"
|
||||
Background="Black">
|
||||
<Grid>
|
||||
<Image x:Name="Frozen" Stretch="Fill" RenderOptions.BitmapScalingMode="NearestNeighbor" />
|
||||
<Path x:Name="Dim" Fill="#73000000" />
|
||||
<Rectangle x:Name="SelectionRect" Stroke="{StaticResource AccentBrush}" StrokeThickness="2"
|
||||
HorizontalAlignment="Left" VerticalAlignment="Top" Visibility="Collapsed" IsHitTestVisible="False" />
|
||||
<Border x:Name="SizeBadge" HorizontalAlignment="Left" VerticalAlignment="Top" Background="#E6202020"
|
||||
CornerRadius="4" Padding="6,2" Visibility="Collapsed" IsHitTestVisible="False">
|
||||
<TextBlock x:Name="SizeText" Foreground="White" FontSize="12" />
|
||||
</Border>
|
||||
<Border x:Name="Hint" HorizontalAlignment="Center" VerticalAlignment="Top" Margin="0,28,0,0" Background="#F2FFFFFF"
|
||||
CornerRadius="6" Padding="16,9" IsHitTestVisible="False">
|
||||
<Border.Effect>
|
||||
<DropShadowEffect BlurRadius="12" ShadowDepth="2" Opacity="0.25" />
|
||||
</Border.Effect>
|
||||
<StackPanel>
|
||||
<TextBlock x:Name="HintTitle" Text="Knipsel: sleep een rechthoek" FontWeight="SemiBold" Foreground="{StaticResource InkBrush}" />
|
||||
<TextBlock Foreground="{StaticResource SubtleTextBrush}" FontSize="12" Margin="0,2,0,0"
|
||||
Text="Enter = heel scherm · Esc of rechtermuisknop = annuleren" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,231 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Windows;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using Kniip.Recording;
|
||||
using Rectangle = System.Drawing.Rectangle;
|
||||
using Screen = System.Windows.Forms.Screen;
|
||||
|
||||
namespace Kniip.Capture;
|
||||
|
||||
/// <summary>
|
||||
/// Bevroren beeld van één beeldscherm waarop je een rechthoek sleept. Er staat er één per scherm;
|
||||
/// een knipsel ligt daardoor altijd binnen één scherm.
|
||||
/// </summary>
|
||||
public partial class RegionCaptureWindow : Window
|
||||
{
|
||||
private const double DragThreshold = 3;
|
||||
private const int MinimumSize = 3;
|
||||
|
||||
private readonly Screen _screen;
|
||||
private readonly BitmapSource _frozen;
|
||||
private readonly IReadOnlyList<Rectangle>? _windows;
|
||||
private Point? _dragStart;
|
||||
private bool _dragging;
|
||||
private Rectangle? _hover;
|
||||
|
||||
/// <summary>Het knipsel, of null bij annuleren.</summary>
|
||||
internal event Action<BitmapSource?>? Finished;
|
||||
|
||||
/// <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)
|
||||
{
|
||||
InitializeComponent();
|
||||
_screen = screen;
|
||||
_frozen = frozen;
|
||||
_windows = windows;
|
||||
Frozen.Source = frozen;
|
||||
if (windows != null)
|
||||
{
|
||||
Cursor = Cursors.Hand;
|
||||
HintTitle.Text = "Knipsel: klik op een venster";
|
||||
MouseLeave += (_, _) =>
|
||||
{
|
||||
_hover = null;
|
||||
ShowSelection(null);
|
||||
};
|
||||
}
|
||||
|
||||
SourceInitialized += (_, _) => PhysicalWindow.Place(this, screen.Bounds);
|
||||
Loaded += (_, _) => ShowSelection(null);
|
||||
}
|
||||
|
||||
private bool PicksWindow => _windows != null;
|
||||
|
||||
public bool ContainsCursor(System.Drawing.Point cursor) => _screen.Bounds.Contains(cursor);
|
||||
|
||||
public void Cancel() => Finished?.Invoke(null);
|
||||
|
||||
public void SelectWholeScreen() => Finish(_screen.Bounds);
|
||||
|
||||
protected override void OnMouseMove(MouseEventArgs e)
|
||||
{
|
||||
base.OnMouseMove(e);
|
||||
if (PicksWindow)
|
||||
{
|
||||
Rectangle? hover = FindWindowAt(ToPhysical(e.GetPosition(this)));
|
||||
if (hover != _hover)
|
||||
{
|
||||
_hover = hover;
|
||||
ShowSelection(hover);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (_dragStart is not Point start || e.LeftButton != MouseButtonState.Pressed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Point position = e.GetPosition(this);
|
||||
if (!_dragging && (position - start).Length >= DragThreshold)
|
||||
{
|
||||
_dragging = true;
|
||||
Hint.Visibility = Visibility.Collapsed;
|
||||
}
|
||||
|
||||
if (_dragging)
|
||||
{
|
||||
ShowSelection(ToPhysical(new Rect(start, position)));
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnMouseLeftButtonDown(MouseButtonEventArgs e)
|
||||
{
|
||||
base.OnMouseLeftButtonDown(e);
|
||||
_dragStart = e.GetPosition(this);
|
||||
_dragging = false;
|
||||
CaptureMouse();
|
||||
}
|
||||
|
||||
protected override void OnMouseLeftButtonUp(MouseButtonEventArgs e)
|
||||
{
|
||||
base.OnMouseLeftButtonUp(e);
|
||||
ReleaseMouseCapture();
|
||||
if (_dragStart is not Point start)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (PicksWindow)
|
||||
{
|
||||
_dragStart = null;
|
||||
Finish(FindWindowAt(ToPhysical(e.GetPosition(this))) ?? _screen.Bounds);
|
||||
return;
|
||||
}
|
||||
|
||||
_dragStart = null;
|
||||
if (!_dragging)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_dragging = false;
|
||||
Rectangle area = ToPhysical(new Rect(start, e.GetPosition(this)));
|
||||
if (area.Width >= MinimumSize && area.Height >= MinimumSize)
|
||||
{
|
||||
Finish(area);
|
||||
}
|
||||
else
|
||||
{
|
||||
ShowSelection(null);
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnMouseRightButtonUp(MouseButtonEventArgs e)
|
||||
{
|
||||
base.OnMouseRightButtonUp(e);
|
||||
Cancel();
|
||||
}
|
||||
|
||||
protected override void OnKeyDown(KeyEventArgs e)
|
||||
{
|
||||
base.OnKeyDown(e);
|
||||
if (e.Key == Key.Escape)
|
||||
{
|
||||
Cancel();
|
||||
}
|
||||
else if (e.Key == Key.Enter)
|
||||
{
|
||||
SelectWholeScreen();
|
||||
}
|
||||
}
|
||||
|
||||
private void Finish(Rectangle area)
|
||||
{
|
||||
Rectangle clipped = Rectangle.Intersect(area, _screen.Bounds);
|
||||
if (clipped.Width <= 0 || clipped.Height <= 0)
|
||||
{
|
||||
Cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
var relative = new Int32Rect(clipped.X - _screen.Bounds.X, clipped.Y - _screen.Bounds.Y, clipped.Width, clipped.Height);
|
||||
Finished?.Invoke(ScreenGrabber.Crop(_frozen, relative));
|
||||
}
|
||||
|
||||
private Rectangle? FindWindowAt(System.Drawing.Point point)
|
||||
{
|
||||
foreach (Rectangle window in _windows ?? Array.Empty<Rectangle>())
|
||||
{
|
||||
if (window.Contains(point))
|
||||
{
|
||||
Rectangle clipped = Rectangle.Intersect(window, _screen.Bounds);
|
||||
return clipped.IsEmpty ? null : clipped;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private double ScaleFactor => PhysicalWindow.Scale(this);
|
||||
|
||||
private System.Drawing.Point ToPhysical(Point dip) =>
|
||||
new(_screen.Bounds.X + (int)Math.Round(dip.X * ScaleFactor), _screen.Bounds.Y + (int)Math.Round(dip.Y * ScaleFactor));
|
||||
|
||||
private Rectangle ToPhysical(Rect dip)
|
||||
{
|
||||
System.Drawing.Point topLeft = ToPhysical(dip.TopLeft);
|
||||
System.Drawing.Point bottomRight = ToPhysical(dip.BottomRight);
|
||||
return Rectangle.FromLTRB(topLeft.X, topLeft.Y, bottomRight.X, bottomRight.Y);
|
||||
}
|
||||
|
||||
private Rect ToDip(Rectangle physical)
|
||||
{
|
||||
double scale = ScaleFactor;
|
||||
return new Rect((physical.X - _screen.Bounds.X) / scale, (physical.Y - _screen.Bounds.Y) / scale,
|
||||
physical.Width / scale, physical.Height / scale);
|
||||
}
|
||||
|
||||
private void ShowSelection(Rectangle? physical)
|
||||
{
|
||||
var full = new Rect(0, 0, ActualWidth, ActualHeight);
|
||||
if (physical is not Rectangle area || area.IsEmpty)
|
||||
{
|
||||
Dim.Data = new RectangleGeometry(full);
|
||||
SelectionRect.Visibility = Visibility.Collapsed;
|
||||
SizeBadge.Visibility = Visibility.Collapsed;
|
||||
return;
|
||||
}
|
||||
|
||||
Rect rect = ToDip(area);
|
||||
var geometry = new GeometryGroup { FillRule = FillRule.EvenOdd };
|
||||
geometry.Children.Add(new RectangleGeometry(full));
|
||||
geometry.Children.Add(new RectangleGeometry(rect));
|
||||
Dim.Data = geometry;
|
||||
|
||||
SelectionRect.Margin = new Thickness(rect.X, rect.Y, 0, 0);
|
||||
SelectionRect.Width = rect.Width;
|
||||
SelectionRect.Height = rect.Height;
|
||||
SelectionRect.Visibility = Visibility.Visible;
|
||||
|
||||
SizeText.Text = $"{area.Width} × {area.Height}";
|
||||
SizeBadge.Visibility = Visibility.Visible;
|
||||
SizeBadge.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
|
||||
double badgeTop = rect.Y - SizeBadge.DesiredSize.Height - 4;
|
||||
SizeBadge.Margin = new Thickness(rect.X, badgeTop < 0 ? rect.Y + 4 : badgeTop, 0, 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.Drawing;
|
||||
using System.Drawing.Imaging;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Windows;
|
||||
using System.Windows.Media.Imaging;
|
||||
using Kniip.Infrastructure;
|
||||
|
||||
namespace Kniip.Capture;
|
||||
|
||||
internal static class ScreenGrabber
|
||||
{
|
||||
/// <summary>Schermpixels in fysieke bureaubladcoördinaten (de app is per-monitor DPI-bewust).</summary>
|
||||
public static BitmapSource Capture(Rectangle bounds)
|
||||
{
|
||||
using var bitmap = new Bitmap(bounds.Width, bounds.Height, PixelFormat.Format32bppRgb);
|
||||
using (Graphics graphics = Graphics.FromImage(bitmap))
|
||||
{
|
||||
graphics.CopyFromScreen(bounds.X, bounds.Y, 0, 0, bounds.Size, CopyPixelOperation.SourceCopy);
|
||||
}
|
||||
|
||||
BitmapData data = bitmap.LockBits(new Rectangle(0, 0, bounds.Width, bounds.Height), ImageLockMode.ReadOnly,
|
||||
PixelFormat.Format32bppRgb);
|
||||
var pixels = new byte[bounds.Width * bounds.Height * 4];
|
||||
try
|
||||
{
|
||||
for (int y = 0; y < bounds.Height; y++)
|
||||
{
|
||||
Marshal.Copy(data.Scan0 + y * data.Stride, pixels, y * bounds.Width * 4, bounds.Width * 4);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
bitmap.UnlockBits(data);
|
||||
}
|
||||
|
||||
for (int i = 3; i < pixels.Length; i += 4)
|
||||
{
|
||||
pixels[i] = 255;
|
||||
}
|
||||
|
||||
return PixelUtil.CreateBitmap(pixels, bounds.Width, bounds.Height);
|
||||
}
|
||||
|
||||
/// <summary>Deel van een eerder vastgelegd scherm; <paramref name="area"/> is relatief aan dat scherm.</summary>
|
||||
public static BitmapSource Crop(BitmapSource source, Int32Rect area)
|
||||
{
|
||||
int stride = area.Width * 4;
|
||||
var pixels = new byte[stride * area.Height];
|
||||
source.CopyPixels(area, pixels, stride, 0);
|
||||
return PixelUtil.CreateBitmap(pixels, area.Width, area.Height);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
using Kniip.Editor.Annotations;
|
||||
|
||||
namespace Kniip.Editor;
|
||||
|
||||
/// <summary>
|
||||
/// Tekent het knipsel met alle annotaties, plus de selectie en het bijsnijkader.
|
||||
/// Het element is precies zo groot als de afbeelding in pixels; zoomen gebeurt met een LayoutTransform erboven.
|
||||
/// </summary>
|
||||
public sealed class AnnotationSurface : FrameworkElement
|
||||
{
|
||||
private static readonly Brush AccentBrush = RenderHelpers.Freeze(new SolidColorBrush(Palette.Accent));
|
||||
private static readonly Brush CropShadeBrush = RenderHelpers.Freeze(new SolidColorBrush(Color.FromArgb(120, 0, 0, 0)));
|
||||
|
||||
public EditorDocument? Document { get; set; }
|
||||
|
||||
public Annotation? Selected { get; set; }
|
||||
|
||||
public Rect? CropRect { get; set; }
|
||||
|
||||
/// <summary>Schaal van de weergave (DIP per afbeeldingspixel), om grepen altijd even groot te tonen.</summary>
|
||||
public double ViewScale { get; set; } = 1;
|
||||
|
||||
public double HandleSize => 9 / ViewScale;
|
||||
|
||||
public AnnotationSurface()
|
||||
{
|
||||
Focusable = true;
|
||||
FocusVisualStyle = null;
|
||||
ClipToBounds = true;
|
||||
}
|
||||
|
||||
protected override Size MeasureOverride(Size availableSize) =>
|
||||
Document == null ? new Size(0, 0) : new Size(Document.Width, Document.Height);
|
||||
|
||||
protected override void OnRender(DrawingContext dc)
|
||||
{
|
||||
if (Document == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
DocumentRenderer.Render(dc, Document);
|
||||
|
||||
if (Selected != null && !Selected.IsHidden && Document.Items.Contains(Selected))
|
||||
{
|
||||
DrawSelection(dc, Selected);
|
||||
}
|
||||
|
||||
if (CropRect is Rect crop)
|
||||
{
|
||||
var full = new RectangleGeometry(new Rect(0, 0, Document.Width, Document.Height));
|
||||
var shade = Geometry.Combine(full, new RectangleGeometry(crop), GeometryCombineMode.Exclude, null);
|
||||
dc.DrawGeometry(CropShadeBrush, null, shade);
|
||||
dc.DrawRectangle(null, CreateDashedPen(Brushes.White), crop);
|
||||
dc.DrawRectangle(null, CreateDashedPen(AccentBrush, 4), crop);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawSelection(DrawingContext dc, Annotation item)
|
||||
{
|
||||
Rect bounds = item.Bounds;
|
||||
double pad = 3 / ViewScale;
|
||||
bounds.Inflate(pad, pad);
|
||||
dc.DrawRectangle(null, CreateDashedPen(AccentBrush), bounds);
|
||||
|
||||
IReadOnlyList<Point> handles = item.GetHandles();
|
||||
if (handles.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
double size = HandleSize;
|
||||
var pen = new Pen(AccentBrush, 1.2 / ViewScale);
|
||||
foreach (Point h in handles)
|
||||
{
|
||||
dc.DrawRoundedRectangle(Brushes.White, pen, new Rect(h.X - size / 2, h.Y - size / 2, size, size),
|
||||
1.5 / ViewScale, 1.5 / ViewScale);
|
||||
}
|
||||
}
|
||||
|
||||
private Pen CreateDashedPen(Brush brush, double offset = 0) => new(brush, 1 / ViewScale)
|
||||
{
|
||||
DashStyle = new DashStyle(new[] { 4.0, 4.0 }, offset)
|
||||
};
|
||||
|
||||
public int HitHandle(Annotation item, Point p)
|
||||
{
|
||||
IReadOnlyList<Point> handles = item.GetHandles();
|
||||
double tolerance = HandleSize / 2 + 2 / ViewScale;
|
||||
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)
|
||||
{
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace Kniip.Editor.Annotations;
|
||||
|
||||
/// <summary>
|
||||
/// Een bewerkbaar object op het knipsel. Coördinaten zijn in pixels van de afbeelding;
|
||||
/// annotaties worden pas bij het exporteren "plat" gemaakt.
|
||||
/// </summary>
|
||||
public abstract class Annotation
|
||||
{
|
||||
public StyleSet Style { get; set; } = new();
|
||||
|
||||
/// <summary>Tijdelijk verborgen, bijvoorbeeld terwijl een tekstkader wordt bewerkt.</summary>
|
||||
public bool IsHidden { get; set; }
|
||||
|
||||
public abstract ToolKind Kind { get; }
|
||||
|
||||
public abstract Rect Bounds { get; }
|
||||
|
||||
public abstract void Render(DrawingContext dc, EditorDocument document);
|
||||
|
||||
public virtual bool HitTest(Point point, double tolerance)
|
||||
{
|
||||
Rect bounds = Bounds;
|
||||
bounds.Inflate(tolerance, tolerance);
|
||||
return bounds.Contains(point);
|
||||
}
|
||||
|
||||
public abstract void Move(Vector delta);
|
||||
|
||||
public virtual IReadOnlyList<Point> GetHandles() => Array.Empty<Point>();
|
||||
|
||||
public virtual void BeginHandleDrag(int index)
|
||||
{
|
||||
}
|
||||
|
||||
public virtual void MoveHandle(int index, Point point, bool constrain)
|
||||
{
|
||||
}
|
||||
|
||||
/// <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 Annotation Clone()
|
||||
{
|
||||
var clone = (Annotation)MemberwiseClone();
|
||||
clone.Style = Style.Clone();
|
||||
clone.IsHidden = false;
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System;
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
|
||||
namespace Kniip.Editor.Annotations;
|
||||
|
||||
/// <summary>Vervaagt of pixeleert een gebied. Het effect wordt berekend op de onderliggende afbeelding.</summary>
|
||||
public sealed class BlurAnnotation : RectAnnotation
|
||||
{
|
||||
private BitmapSource? _cache;
|
||||
private (Int32Rect Area, BlurMode Mode, int Strength, BitmapSource Source)? _cacheKey;
|
||||
|
||||
public override ToolKind Kind => ToolKind.Blur;
|
||||
|
||||
public override void Render(DrawingContext dc, EditorDocument document)
|
||||
{
|
||||
Int32Rect area = ToPixelArea(Rect, document.Width, document.Height);
|
||||
if (area.Width < 1 || area.Height < 1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var key = (area, Style.BlurMode, Style.BlurStrength, document.Image);
|
||||
if (_cache == null || !_cacheKey.Equals(key))
|
||||
{
|
||||
_cache = Style.BlurMode == BlurMode.Pixelate
|
||||
? ImageEffects.Pixelate(document, area, Math.Max(2, Style.BlurStrength * 2))
|
||||
: ImageEffects.Blur(document, area, Math.Max(1, Style.BlurStrength));
|
||||
_cacheKey = key;
|
||||
}
|
||||
|
||||
dc.DrawImage(_cache, new Rect(area.X, area.Y, area.Width, area.Height));
|
||||
}
|
||||
|
||||
private static Int32Rect ToPixelArea(Rect r, int width, int height)
|
||||
{
|
||||
int left = Math.Clamp((int)Math.Floor(r.Left), 0, width);
|
||||
int top = Math.Clamp((int)Math.Floor(r.Top), 0, height);
|
||||
int right = Math.Clamp((int)Math.Ceiling(r.Right), 0, width);
|
||||
int bottom = Math.Clamp((int)Math.Ceiling(r.Bottom), 0, height);
|
||||
return new Int32Rect(left, top, Math.Max(0, right - left), Math.Max(0, bottom - top));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace Kniip.Editor.Annotations;
|
||||
|
||||
/// <summary>Een muiscursor-stempel. <see cref="Position"/> is de "hotspot" (punt van de pijl of vinger).</summary>
|
||||
public sealed class CursorAnnotation : Annotation
|
||||
{
|
||||
private const double DesignSize = 24;
|
||||
|
||||
public Point Position { get; set; }
|
||||
|
||||
public override ToolKind Kind => ToolKind.Cursor;
|
||||
|
||||
private double Scale => Style.Size / DesignSize;
|
||||
|
||||
public override Rect Bounds
|
||||
{
|
||||
get
|
||||
{
|
||||
Rect r = CursorGeometries.For(Style.CursorKind).Bounds;
|
||||
double s = Scale;
|
||||
return new Rect(Position.X + r.X * s, Position.Y + r.Y * s, r.Width * s, r.Height * s);
|
||||
}
|
||||
}
|
||||
|
||||
public override void Render(DrawingContext dc, EditorDocument document)
|
||||
{
|
||||
dc.PushTransform(new TranslateTransform(Position.X, Position.Y));
|
||||
dc.PushTransform(new ScaleTransform(Scale, Scale));
|
||||
|
||||
CursorGeometries.Parts parts = CursorGeometries.PartsFor(Style.CursorKind);
|
||||
RenderHelpers.DrawWithShadow(dc, parts.Body, Brushes.White, CursorGeometries.OutlinePen, Style.Shadow);
|
||||
if (parts.Details != null)
|
||||
{
|
||||
dc.DrawGeometry(null, CursorGeometries.DetailPen, parts.Details);
|
||||
}
|
||||
|
||||
if (parts.Rays != null)
|
||||
{
|
||||
dc.DrawGeometry(null, CursorGeometries.RayPen, parts.Rays);
|
||||
}
|
||||
|
||||
dc.Pop();
|
||||
dc.Pop();
|
||||
}
|
||||
|
||||
public override void Move(Vector delta) => Position += delta;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace Kniip.Editor.Annotations;
|
||||
|
||||
/// <summary>Vectorcursors in een 24-eenheden ontwerpraster, met de hotspot op (0,0).</summary>
|
||||
internal static class CursorGeometries
|
||||
{
|
||||
public sealed record Parts(Geometry Body, Geometry? Details, Geometry? Rays);
|
||||
|
||||
public static readonly Pen OutlinePen = RenderHelpers.Freeze(new Pen(Brushes.Black, 1.2) { LineJoin = PenLineJoin.Round });
|
||||
public static readonly Pen DetailPen = RenderHelpers.Freeze(new Pen(Brushes.Black, 0.9) { StartLineCap = PenLineCap.Round, EndLineCap = PenLineCap.Round });
|
||||
public static readonly Pen RayPen = RenderHelpers.Freeze(new Pen(new SolidColorBrush(Palette.Red), 1.6) { StartLineCap = PenLineCap.Round, EndLineCap = PenLineCap.Round });
|
||||
|
||||
private static readonly Geometry ArrowBody = RenderHelpers.Freeze(
|
||||
Geometry.Parse("M0,0 L0,19.5 L4.7,15.2 L7.8,22.2 L11,20.8 L7.9,14 L14,14 Z"));
|
||||
|
||||
private static readonly Parts ArrowParts = new(ArrowBody, null, null);
|
||||
|
||||
private static readonly Parts HandParts = CreateHand();
|
||||
|
||||
private static readonly Parts ClickParts = new(ArrowBody, null, RenderHelpers.Freeze(
|
||||
Geometry.Parse("M-3,-3 L-6.5,-6.5 M0,-4.5 L0,-9 M3,-3 L6.5,-6.5 M-4.5,0 L-9,0")));
|
||||
|
||||
public static Parts PartsFor(CursorKind kind) => kind switch
|
||||
{
|
||||
CursorKind.Hand => HandParts,
|
||||
CursorKind.Click => ClickParts,
|
||||
_ => ArrowParts
|
||||
};
|
||||
|
||||
public static Geometry For(CursorKind kind)
|
||||
{
|
||||
Parts parts = PartsFor(kind);
|
||||
if (parts.Rays == null)
|
||||
{
|
||||
return parts.Body;
|
||||
}
|
||||
|
||||
var group = new GeometryGroup();
|
||||
group.Children.Add(parts.Body);
|
||||
group.Children.Add(parts.Rays);
|
||||
group.Freeze();
|
||||
return group;
|
||||
}
|
||||
|
||||
private static Parts CreateHand()
|
||||
{
|
||||
// Wijsvinger omhoog; hotspot op de vingertop.
|
||||
Geometry hand = new RectangleGeometry(new Rect(5, 0, 4, 13), 2, 2);
|
||||
hand = Union(hand, new RectangleGeometry(new Rect(9, 6.5, 3.6, 8), 1.8, 1.8));
|
||||
hand = Union(hand, new RectangleGeometry(new Rect(12.6, 7.5, 3.4, 8), 1.7, 1.7));
|
||||
hand = Union(hand, new RectangleGeometry(new Rect(16, 9, 3.2, 7), 1.6, 1.6));
|
||||
hand = Union(hand, new RectangleGeometry(new Rect(5, 11, 14.2, 11.5), 4, 4));
|
||||
var thumb = new RectangleGeometry(new Rect(0.5, 11.5, 8, 3.8), 1.9, 1.9)
|
||||
{
|
||||
Transform = new RotateTransform(45, 4.5, 13.4)
|
||||
};
|
||||
|
||||
Geometry body = Geometry.Combine(hand, thumb, GeometryCombineMode.Union, new TranslateTransform(-7, 0));
|
||||
body.Freeze();
|
||||
|
||||
Geometry details = Geometry.Parse("M2,7.5 L2,12 M5.6,8.5 L5.6,12 M9,10 L9,12");
|
||||
details.Freeze();
|
||||
return new Parts(body, details, null);
|
||||
}
|
||||
|
||||
private static Geometry Union(Geometry a, Geometry b) =>
|
||||
Geometry.Combine(a, b, GeometryCombineMode.Union, null);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System;
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
using Kniip.Editor.Emoji;
|
||||
|
||||
namespace Kniip.Editor.Annotations;
|
||||
|
||||
/// <summary>Een Twemoji-stempel, gecentreerd op <see cref="Center"/>; de breedte en hoogte zijn <see cref="StyleSet.Size"/>.</summary>
|
||||
public sealed class EmojiAnnotation : Annotation
|
||||
{
|
||||
private static readonly Brush ShadowFar = RenderHelpers.Freeze(new SolidColorBrush(Color.FromArgb(40, 0, 0, 0)));
|
||||
private static readonly Brush ShadowNear = RenderHelpers.Freeze(new SolidColorBrush(Color.FromArgb(55, 0, 0, 0)));
|
||||
|
||||
public Point Center { get; set; }
|
||||
|
||||
public override ToolKind Kind => ToolKind.Emoji;
|
||||
|
||||
public override Rect Bounds
|
||||
{
|
||||
get
|
||||
{
|
||||
double r = Style.Size / 2;
|
||||
return new Rect(Center.X - r, Center.Y - r, Style.Size, Style.Size);
|
||||
}
|
||||
}
|
||||
|
||||
public override void Render(DrawingContext dc, EditorDocument document)
|
||||
{
|
||||
ImageSource image = EmojiCatalog.Get(Style.Emoji, Style.SkinTone);
|
||||
Rect bounds = Bounds;
|
||||
|
||||
if (Style.Shadow)
|
||||
{
|
||||
double offset = Math.Max(2, Style.Size / 24);
|
||||
var mask = RenderHelpers.Freeze(new ImageBrush(image));
|
||||
DrawShadowLayer(dc, mask, bounds, ShadowFar, offset * 1.6);
|
||||
DrawShadowLayer(dc, mask, bounds, ShadowNear, offset);
|
||||
}
|
||||
|
||||
// De export tekent met NearestNeighbor; een geschaalde emoji moet toch vloeiend blijven.
|
||||
var group = new DrawingGroup();
|
||||
RenderOptions.SetBitmapScalingMode(group, BitmapScalingMode.HighQuality);
|
||||
using (DrawingContext gc = group.Open())
|
||||
{
|
||||
gc.DrawImage(image, bounds);
|
||||
}
|
||||
|
||||
dc.DrawDrawing(group);
|
||||
}
|
||||
|
||||
private static void DrawShadowLayer(DrawingContext dc, Brush mask, Rect bounds, Brush brush, double offset)
|
||||
{
|
||||
bounds.Offset(offset, offset);
|
||||
dc.PushOpacityMask(mask);
|
||||
dc.DrawRectangle(brush, null, bounds);
|
||||
dc.Pop();
|
||||
}
|
||||
|
||||
public override void Move(Vector delta) => Center += delta;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace Kniip.Editor.Annotations;
|
||||
|
||||
/// <summary>Markeerstift: half-transparant, als rechte horizontale streep of uit de vrije hand.</summary>
|
||||
public sealed class HighlightAnnotation : Annotation
|
||||
{
|
||||
private const byte HighlightAlpha = 110;
|
||||
|
||||
public List<Point> Points { get; set; } = new();
|
||||
|
||||
public bool Straight { get; set; }
|
||||
|
||||
public override ToolKind Kind => ToolKind.Highlight;
|
||||
|
||||
public override Rect Bounds
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Points.Count == 0)
|
||||
{
|
||||
return Rect.Empty;
|
||||
}
|
||||
|
||||
double minX = Points.Min(p => p.X), maxX = Points.Max(p => p.X);
|
||||
double minY = Points.Min(p => p.Y), maxY = Points.Max(p => p.Y);
|
||||
var r = new Rect(new Point(minX, minY), new Point(maxX, maxY));
|
||||
r.Inflate(Style.Thickness / 2, Style.Thickness / 2);
|
||||
return r;
|
||||
}
|
||||
}
|
||||
|
||||
public override bool IsDegenerate
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Points.Count < 2)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
Rect r = Bounds;
|
||||
return r.Width - Style.Thickness < 3 && r.Height - Style.Thickness < 3;
|
||||
}
|
||||
}
|
||||
|
||||
public override void Render(DrawingContext dc, EditorDocument document)
|
||||
{
|
||||
if (Points.Count < 2)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Color c = Style.Stroke;
|
||||
var color = Color.FromArgb(HighlightAlpha, c.R, c.G, c.B);
|
||||
Pen? pen = RenderHelpers.CreatePen(color, Style.Thickness, LineDash.Solid,
|
||||
Straight ? PenLineCap.Square : PenLineCap.Round);
|
||||
if (pen == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Eén geometrie, zodat overlappende delen niet dubbel zo donker worden.
|
||||
var geometry = new StreamGeometry();
|
||||
using (StreamGeometryContext ctx = geometry.Open())
|
||||
{
|
||||
ctx.BeginFigure(Points[0], false, false);
|
||||
ctx.PolyLineTo(Points.Skip(1).ToList(), true, true);
|
||||
}
|
||||
|
||||
geometry.Freeze();
|
||||
dc.DrawGeometry(null, pen, geometry);
|
||||
}
|
||||
|
||||
public override bool HitTest(Point point, double tolerance)
|
||||
{
|
||||
double limit = tolerance + Style.Thickness / 2;
|
||||
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)
|
||||
{
|
||||
for (int i = 0; i < Points.Count; i++)
|
||||
{
|
||||
Points[i] += delta;
|
||||
}
|
||||
}
|
||||
|
||||
public override IReadOnlyList<Point> GetHandles() =>
|
||||
Straight && Points.Count == 2 ? new[] { Points[0], Points[1] } : Array.Empty<Point>();
|
||||
|
||||
public override void MoveHandle(int index, Point point, bool constrain)
|
||||
{
|
||||
if (!Straight || Points.Count != 2)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Points[index] = new Point(point.X, Points[1 - index].Y);
|
||||
}
|
||||
|
||||
public override Annotation Clone()
|
||||
{
|
||||
var clone = (HighlightAnnotation)base.Clone();
|
||||
clone.Points = new List<Point>(Points);
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace Kniip.Editor.Annotations;
|
||||
|
||||
public sealed class LineAnnotation : Annotation
|
||||
{
|
||||
public LineAnnotation(bool isArrow)
|
||||
{
|
||||
IsArrow = isArrow;
|
||||
}
|
||||
|
||||
public bool IsArrow { get; }
|
||||
|
||||
public Point Start { get; set; }
|
||||
|
||||
public Point End { get; set; }
|
||||
|
||||
public override ToolKind Kind => IsArrow ? ToolKind.Arrow : ToolKind.Line;
|
||||
|
||||
public override Rect Bounds
|
||||
{
|
||||
get
|
||||
{
|
||||
var r = new Rect(Start, End);
|
||||
double pad = Style.Thickness / 2 + (IsArrow ? HeadLength / 2 : 0);
|
||||
r.Inflate(pad, pad);
|
||||
return r;
|
||||
}
|
||||
}
|
||||
|
||||
public override bool IsDegenerate => (End - Start).Length < 4;
|
||||
|
||||
private double HeadLength => Math.Max(12, Style.Thickness * 4);
|
||||
|
||||
public override void Render(DrawingContext dc, EditorDocument document)
|
||||
{
|
||||
Vector v = End - Start;
|
||||
double length = v.Length;
|
||||
if (length < 1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Pen? pen = RenderHelpers.CreatePen(Style.Stroke, Style.Thickness, Style.Dash);
|
||||
if (pen == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var geometry = new GeometryGroup { FillRule = FillRule.Nonzero };
|
||||
Brush? headFill = null;
|
||||
|
||||
if (IsArrow)
|
||||
{
|
||||
v.Normalize();
|
||||
double headLength = Math.Min(HeadLength, length);
|
||||
double halfWidth = headLength * 0.45;
|
||||
Point basePoint = End - v * headLength;
|
||||
var normal = new Vector(-v.Y, v.X);
|
||||
|
||||
geometry.Children.Add(new LineGeometry(Start, basePoint + v * Math.Min(2, headLength / 4)));
|
||||
|
||||
var head = new StreamGeometry();
|
||||
using (StreamGeometryContext ctx = head.Open())
|
||||
{
|
||||
ctx.BeginFigure(End, true, true);
|
||||
ctx.LineTo(basePoint + normal * halfWidth, true, true);
|
||||
ctx.LineTo(basePoint - normal * halfWidth, true, true);
|
||||
}
|
||||
|
||||
head.Freeze();
|
||||
headFill = RenderHelpers.BrushOrNull(Style.Stroke);
|
||||
|
||||
// Lijn en punt apart tekenen zodat de punt gevuld en niet gestippeld wordt.
|
||||
RenderHelpers.DrawWithShadow(dc, geometry, null, pen, Style.Shadow);
|
||||
var headPen = RenderHelpers.CreatePen(Style.Stroke, Math.Max(1, Style.Thickness / 3), LineDash.Solid);
|
||||
RenderHelpers.DrawWithShadow(dc, head, headFill, headPen, Style.Shadow);
|
||||
return;
|
||||
}
|
||||
|
||||
geometry.Children.Add(new LineGeometry(Start, End));
|
||||
RenderHelpers.DrawWithShadow(dc, geometry, null, pen, Style.Shadow);
|
||||
}
|
||||
|
||||
public override bool HitTest(Point point, double tolerance) =>
|
||||
RenderHelpers.DistanceToSegment(point, Start, End) <= tolerance + Style.Thickness / 2 + (IsArrow ? 4 : 0);
|
||||
|
||||
public override void Move(Vector delta)
|
||||
{
|
||||
Start += delta;
|
||||
End += delta;
|
||||
}
|
||||
|
||||
public override IReadOnlyList<Point> GetHandles() => new[] { Start, End };
|
||||
|
||||
public override void MoveHandle(int index, Point point, bool constrain)
|
||||
{
|
||||
if (index == 0)
|
||||
{
|
||||
Start = constrain ? RenderHelpers.SnapAngle(End, point) : point;
|
||||
}
|
||||
else
|
||||
{
|
||||
End = constrain ? RenderHelpers.SnapAngle(Start, point) : point;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Windows;
|
||||
|
||||
namespace Kniip.Editor.Annotations;
|
||||
|
||||
/// <summary>Basis voor annotaties die door een rechthoek worden beschreven, met 8 formaatgrepen.</summary>
|
||||
public abstract class RectAnnotation : Annotation
|
||||
{
|
||||
private Rect _dragOrigin;
|
||||
|
||||
public Rect Rect { get; set; }
|
||||
|
||||
public override Rect Bounds => Rect;
|
||||
|
||||
public override bool IsDegenerate => Rect.Width < 3 || Rect.Height < 3;
|
||||
|
||||
public override void Move(Vector delta) => Rect = Rect.Offset(Rect, delta);
|
||||
|
||||
public override IReadOnlyList<Point> GetHandles()
|
||||
{
|
||||
Rect r = Rect;
|
||||
double cx = r.Left + r.Width / 2;
|
||||
double cy = r.Top + r.Height / 2;
|
||||
return new[]
|
||||
{
|
||||
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)
|
||||
};
|
||||
}
|
||||
|
||||
public override void BeginHandleDrag(int index) => _dragOrigin = Rect;
|
||||
|
||||
public override void MoveHandle(int index, Point p, bool constrain)
|
||||
{
|
||||
Rect o = _dragOrigin;
|
||||
Rect = index switch
|
||||
{
|
||||
0 => FromAnchor(o.BottomRight, p, constrain),
|
||||
1 => new Rect(new Point(o.Left, p.Y), o.BottomRight),
|
||||
2 => FromAnchor(o.BottomLeft, p, constrain),
|
||||
3 => new Rect(o.TopLeft, new Point(p.X, o.Bottom)),
|
||||
4 => FromAnchor(o.TopLeft, p, constrain),
|
||||
5 => new Rect(o.TopLeft, new Point(o.Right, p.Y)),
|
||||
6 => FromAnchor(o.TopRight, p, constrain),
|
||||
7 => new Rect(new Point(p.X, o.Top), o.BottomRight),
|
||||
_ => Rect
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Rechthoek tussen een vast ankerpunt en de muis; met Shift een vierkant.</summary>
|
||||
public static Rect FromAnchor(Point anchor, Point p, bool square)
|
||||
{
|
||||
if (square)
|
||||
{
|
||||
double size = System.Math.Max(System.Math.Abs(p.X - anchor.X), System.Math.Abs(p.Y - anchor.Y));
|
||||
p = new Point(anchor.X + (p.X >= anchor.X ? size : -size), anchor.Y + (p.Y >= anchor.Y ? size : -size));
|
||||
}
|
||||
|
||||
return new Rect(anchor, p);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace Kniip.Editor.Annotations;
|
||||
|
||||
internal static class RenderHelpers
|
||||
{
|
||||
private static readonly FontFamily UiFont = new("Segoe UI");
|
||||
private static readonly Brush ShadowFar = Freeze(new SolidColorBrush(Color.FromArgb(40, 0, 0, 0)));
|
||||
private static readonly Brush ShadowNear = Freeze(new SolidColorBrush(Color.FromArgb(55, 0, 0, 0)));
|
||||
|
||||
public static T Freeze<T>(T freezable) where T : Freezable
|
||||
{
|
||||
freezable.Freeze();
|
||||
return freezable;
|
||||
}
|
||||
|
||||
public static Brush? BrushOrNull(Color color) =>
|
||||
color.A == 0 ? null : Freeze(new SolidColorBrush(color));
|
||||
|
||||
public static Pen? CreatePen(Color color, double thickness, LineDash dash, PenLineCap cap = PenLineCap.Round)
|
||||
{
|
||||
if (thickness <= 0 || color.A == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var pen = new Pen(new SolidColorBrush(color), thickness)
|
||||
{
|
||||
LineJoin = PenLineJoin.Round,
|
||||
StartLineCap = cap,
|
||||
EndLineCap = cap
|
||||
};
|
||||
|
||||
switch (dash)
|
||||
{
|
||||
case LineDash.Dash:
|
||||
pen.DashStyle = new DashStyle(new[] { 3.0, 2.0 }, 0);
|
||||
pen.DashCap = PenLineCap.Flat;
|
||||
break;
|
||||
case LineDash.Dot:
|
||||
pen.DashStyle = new DashStyle(new[] { 0.0, 2.0 }, 0);
|
||||
pen.DashCap = PenLineCap.Round;
|
||||
break;
|
||||
}
|
||||
|
||||
return Freeze(pen);
|
||||
}
|
||||
|
||||
/// <summary>Tekent een geometrie met een zachte schaduw rechtsonder.</summary>
|
||||
public static void DrawWithShadow(DrawingContext dc, Geometry geometry, Brush? fill, Pen? pen, bool shadow)
|
||||
{
|
||||
if (shadow)
|
||||
{
|
||||
double offset = Math.Max(2, (pen?.Thickness ?? 2) * 0.6);
|
||||
DrawShadowLayer(dc, geometry, fill != null, pen, ShadowFar, offset * 1.6);
|
||||
DrawShadowLayer(dc, geometry, fill != null, pen, ShadowNear, offset);
|
||||
}
|
||||
|
||||
dc.DrawGeometry(fill, pen, geometry);
|
||||
}
|
||||
|
||||
private static void DrawShadowLayer(DrawingContext dc, Geometry geometry, bool filled, Pen? pen, Brush brush, double offset)
|
||||
{
|
||||
Pen? shadowPen = null;
|
||||
if (pen != null)
|
||||
{
|
||||
shadowPen = new Pen(brush, pen.Thickness)
|
||||
{
|
||||
LineJoin = pen.LineJoin,
|
||||
StartLineCap = pen.StartLineCap,
|
||||
EndLineCap = pen.EndLineCap,
|
||||
DashStyle = pen.DashStyle,
|
||||
DashCap = pen.DashCap
|
||||
};
|
||||
shadowPen.Freeze();
|
||||
}
|
||||
|
||||
dc.PushTransform(new TranslateTransform(offset, offset));
|
||||
dc.DrawGeometry(filled ? brush : null, shadowPen, geometry);
|
||||
dc.Pop();
|
||||
}
|
||||
|
||||
public static FormattedText FormatText(string text, double fontSize, Color color, bool bold) =>
|
||||
new(text, CultureInfo.CurrentUICulture, FlowDirection.LeftToRight,
|
||||
new Typeface(UiFont, FontStyles.Normal, bold ? FontWeights.Bold : FontWeights.Normal, FontStretches.Normal),
|
||||
fontSize, Freeze(new SolidColorBrush(color)), 1.0);
|
||||
|
||||
public static double DistanceToSegment(Point p, Point a, Point b)
|
||||
{
|
||||
Vector ab = b - a;
|
||||
double lengthSquared = ab.LengthSquared;
|
||||
if (lengthSquared < 1e-9)
|
||||
{
|
||||
return (p - a).Length;
|
||||
}
|
||||
|
||||
double t = Math.Clamp(Vector.Multiply(p - a, ab) / lengthSquared, 0, 1);
|
||||
return (p - (a + t * ab)).Length;
|
||||
}
|
||||
|
||||
public static Point SnapAngle(Point origin, Point target, double stepDegrees = 45)
|
||||
{
|
||||
Vector v = target - origin;
|
||||
double length = v.Length;
|
||||
if (length < 1e-6)
|
||||
{
|
||||
return target;
|
||||
}
|
||||
|
||||
double angle = Math.Atan2(v.Y, v.X);
|
||||
double step = stepDegrees * Math.PI / 180;
|
||||
angle = Math.Round(angle / step) * step;
|
||||
return origin + new Vector(Math.Cos(angle) * length, Math.Sin(angle) * length);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace Kniip.Editor.Annotations;
|
||||
|
||||
public sealed class ShapeAnnotation : RectAnnotation
|
||||
{
|
||||
public ShapeAnnotation(bool isEllipse)
|
||||
{
|
||||
IsEllipse = isEllipse;
|
||||
}
|
||||
|
||||
public bool IsEllipse { get; }
|
||||
|
||||
public override ToolKind Kind => IsEllipse ? ToolKind.Ellipse : ToolKind.Rectangle;
|
||||
|
||||
public override void Render(DrawingContext dc, EditorDocument document)
|
||||
{
|
||||
Rect r = Rect;
|
||||
if (r.Width < 1 && r.Height < 1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Geometry geometry = IsEllipse
|
||||
? new EllipseGeometry(r)
|
||||
: new RectangleGeometry(r, Style.CornerRadius, Style.CornerRadius);
|
||||
|
||||
RenderHelpers.DrawWithShadow(dc, geometry, RenderHelpers.BrushOrNull(Style.Fill),
|
||||
RenderHelpers.CreatePen(Style.Stroke, Style.Thickness, Style.Dash), Style.Shadow);
|
||||
}
|
||||
|
||||
public override bool HitTest(Point point, double tolerance)
|
||||
{
|
||||
if (!IsEllipse)
|
||||
{
|
||||
return base.HitTest(point, tolerance);
|
||||
}
|
||||
|
||||
Rect r = Rect;
|
||||
r.Inflate(tolerance + Style.Thickness / 2, tolerance + Style.Thickness / 2);
|
||||
return new EllipseGeometry(r).FillContains(point);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using System;
|
||||
using System.Text;
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace Kniip.Editor.Annotations;
|
||||
|
||||
/// <summary>
|
||||
/// Genummerde stap-badge. Het nummer volgt uit de volgorde van de stappen met dezelfde nummering,
|
||||
/// dus na verwijderen of ongedaan maken loopt de reeks automatisch weer door.
|
||||
/// </summary>
|
||||
public sealed class StepAnnotation : Annotation
|
||||
{
|
||||
public Point Center { get; set; }
|
||||
|
||||
public override ToolKind Kind => ToolKind.Step;
|
||||
|
||||
public override Rect Bounds
|
||||
{
|
||||
get
|
||||
{
|
||||
double r = Style.Size / 2;
|
||||
return new Rect(Center.X - r, Center.Y - r, Style.Size, Style.Size);
|
||||
}
|
||||
}
|
||||
|
||||
public override void Render(DrawingContext dc, EditorDocument document)
|
||||
{
|
||||
double radius = Style.Size / 2;
|
||||
var circle = new EllipseGeometry(Center, radius, radius);
|
||||
Color fillColor = Style.Fill.A == 0 ? Palette.Red : Style.Fill;
|
||||
Pen? border = RenderHelpers.CreatePen(Style.Foreground, Math.Max(1.5, Style.Size / 16), LineDash.Solid);
|
||||
RenderHelpers.DrawWithShadow(dc, circle, RenderHelpers.BrushOrNull(fillColor), border, Style.Shadow);
|
||||
|
||||
string label = document.GetStepLabel(this);
|
||||
double factor = label.Length switch { 1 => 0.55, 2 => 0.46, 3 => 0.36, _ => 0.3 };
|
||||
FormattedText ft = RenderHelpers.FormatText(label, Style.Size * factor, Style.Foreground, true);
|
||||
dc.DrawText(ft, new Point(Center.X - ft.Width / 2, Center.Y - ft.Height / 2));
|
||||
}
|
||||
|
||||
public override bool HitTest(Point point, double tolerance) =>
|
||||
(point - Center).Length <= Style.Size / 2 + tolerance;
|
||||
|
||||
public override void Move(Vector delta) => Center += delta;
|
||||
|
||||
public static string FormatLabel(int ordinal, StepNumbering numbering) => numbering switch
|
||||
{
|
||||
StepNumbering.LowerAlpha => Alpha(ordinal).ToLowerInvariant(),
|
||||
StepNumbering.UpperAlpha => Alpha(ordinal),
|
||||
StepNumbering.Roman => Roman(ordinal),
|
||||
_ => ordinal.ToString()
|
||||
};
|
||||
|
||||
private static string Alpha(int n)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
while (n > 0)
|
||||
{
|
||||
n--;
|
||||
sb.Insert(0, (char)('A' + n % 26));
|
||||
n /= 26;
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string Roman(int n)
|
||||
{
|
||||
if (n <= 0 || n > 3999)
|
||||
{
|
||||
return n.ToString();
|
||||
}
|
||||
|
||||
int[] values = { 1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1 };
|
||||
string[] symbols = { "M", "CM", "D", "CD", "C", "XC", "L", "XL", "X", "IX", "V", "IV", "I" };
|
||||
var sb = new StringBuilder();
|
||||
for (int i = 0; i < values.Length; i++)
|
||||
{
|
||||
while (n >= values[i])
|
||||
{
|
||||
sb.Append(symbols[i]);
|
||||
n -= values[i];
|
||||
}
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using System;
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace Kniip.Editor.Annotations;
|
||||
|
||||
/// <summary>Tekst in een kader met achtergrond, rand en afgeronde hoeken.</summary>
|
||||
public sealed class TextAnnotation : Annotation
|
||||
{
|
||||
public Point Position { get; set; }
|
||||
|
||||
public string Text { get; set; } = string.Empty;
|
||||
|
||||
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 override Rect Bounds
|
||||
{
|
||||
get
|
||||
{
|
||||
FormattedText ft = Format(Text.Length == 0 ? " " : Text);
|
||||
double pad = Padding;
|
||||
return new Rect(Position, new Size(ft.WidthIncludingTrailingWhitespace + 2 * pad, ft.Height + 2 * pad));
|
||||
}
|
||||
}
|
||||
|
||||
public override bool IsDegenerate => string.IsNullOrWhiteSpace(Text);
|
||||
|
||||
private FormattedText Format(string text) =>
|
||||
RenderHelpers.FormatText(text, Style.FontSize, Style.Foreground, Style.Bold);
|
||||
|
||||
public override void Render(DrawingContext dc, EditorDocument document)
|
||||
{
|
||||
if (string.IsNullOrEmpty(Text))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
FormattedText ft = Format(Text);
|
||||
double pad = Padding;
|
||||
var box = new Rect(Position, new Size(ft.WidthIncludingTrailingWhitespace + 2 * pad, ft.Height + 2 * pad));
|
||||
|
||||
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);
|
||||
dc.DrawText(shadow, new Point(Position.X + pad + 1.5, Position.Y + pad + 1.5));
|
||||
}
|
||||
|
||||
dc.DrawText(ft, new Point(Position.X + pad, Position.Y + pad));
|
||||
}
|
||||
|
||||
public override void Move(Vector delta) => Position += delta;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<UserControl x:Class="Kniip.Editor.ColorPickerButton"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
VerticalAlignment="Center">
|
||||
<UserControl.Resources>
|
||||
<DrawingBrush x:Key="Checker" TileMode="Tile" Viewport="0,0,8,8" ViewportUnits="Absolute">
|
||||
<DrawingBrush.Drawing>
|
||||
<DrawingGroup>
|
||||
<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" />
|
||||
</DrawingGroup>
|
||||
</DrawingBrush.Drawing>
|
||||
</DrawingBrush>
|
||||
</UserControl.Resources>
|
||||
<Grid>
|
||||
<ToggleButton x:Name="Toggle" Style="{StaticResource FlatButton}" Padding="6,4" MinWidth="0" MinHeight="30"
|
||||
BorderBrush="#E0E0E0" Background="White">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Grid Width="20" Height="20">
|
||||
<Border x:Name="CheckerBack" CornerRadius="4" Background="{StaticResource Checker}" Visibility="Collapsed" />
|
||||
<Border x:Name="Swatch" CornerRadius="4" BorderThickness="0" />
|
||||
<Border x:Name="OutlineHole" CornerRadius="1.5" Margin="5" Background="White" Visibility="Collapsed" />
|
||||
<Grid x:Name="TextGlyph" Background="White" Visibility="Collapsed">
|
||||
<TextBlock Text="A" FontSize="14" HorizontalAlignment="Center" Margin="0,-1,0,0"
|
||||
Foreground="{StaticResource InkBrush}" />
|
||||
<Border x:Name="TextBar" Height="4" CornerRadius="1" VerticalAlignment="Bottom" Margin="2,0" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
<TextBlock Text="" Style="{StaticResource IconText}" FontSize="9" Margin="5,0,1,0" Foreground="#5C5C5C" />
|
||||
</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="White" BorderBrush="#D6D6D6" BorderThickness="1" CornerRadius="6" Padding="8" Margin="0,4,10,10">
|
||||
<Border.Effect>
|
||||
<DropShadowEffect BlurRadius="10" ShadowDepth="2" Opacity="0.18" />
|
||||
</Border.Effect>
|
||||
<StackPanel>
|
||||
<UniformGrid x:Name="SwatchGrid" Columns="7" />
|
||||
<Button Style="{StaticResource FlatButton}" Margin="0,6,0,0" HorizontalAlignment="Stretch" Click="MoreColors_Click">
|
||||
<TextBlock Text="Meer kleuren..." FontSize="12" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Popup>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,142 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Shapes;
|
||||
|
||||
namespace Kniip.Editor;
|
||||
|
||||
public partial class ColorPickerButton : UserControl
|
||||
{
|
||||
private Color _color = Colors.Red;
|
||||
private bool _allowTransparent;
|
||||
|
||||
public event Action<Color>? ColorChanged;
|
||||
|
||||
public ColorPickerButton()
|
||||
{
|
||||
InitializeComponent();
|
||||
BuildSwatches();
|
||||
UpdateSwatch();
|
||||
}
|
||||
|
||||
public bool AllowTransparent
|
||||
{
|
||||
get => _allowTransparent;
|
||||
set
|
||||
{
|
||||
_allowTransparent = value;
|
||||
BuildSwatches();
|
||||
}
|
||||
}
|
||||
|
||||
public Color SelectedColor
|
||||
{
|
||||
get => _color;
|
||||
set
|
||||
{
|
||||
_color = value;
|
||||
UpdateSwatch();
|
||||
}
|
||||
}
|
||||
|
||||
private void BuildSwatches()
|
||||
{
|
||||
SwatchGrid.Children.Clear();
|
||||
var colors = new List<Color>(Palette.Swatches);
|
||||
if (_allowTransparent)
|
||||
{
|
||||
colors.Add(Colors.Transparent);
|
||||
}
|
||||
|
||||
foreach (Color color in colors)
|
||||
{
|
||||
var visual = new Border
|
||||
{
|
||||
Width = 22,
|
||||
Height = 22,
|
||||
CornerRadius = new CornerRadius(4),
|
||||
BorderBrush = new SolidColorBrush(Color.FromArgb(40, 0, 0, 0)),
|
||||
BorderThickness = new Thickness(1),
|
||||
Background = color.A == 0 ? (Brush)FindResource("Checker") : new SolidColorBrush(color)
|
||||
};
|
||||
|
||||
var button = new Button
|
||||
{
|
||||
Style = (Style)FindResource("FlatButton"),
|
||||
MinWidth = 0,
|
||||
MinHeight = 0,
|
||||
Padding = new Thickness(3),
|
||||
Margin = new Thickness(1),
|
||||
Content = visual,
|
||||
ToolTip = color.A == 0 ? "Geen (transparant)" : $"#{color.R:X2}{color.G:X2}{color.B:X2}"
|
||||
};
|
||||
Color captured = color;
|
||||
button.Click += (_, _) => Pick(captured);
|
||||
SwatchGrid.Children.Add(button);
|
||||
}
|
||||
}
|
||||
|
||||
private void Pick(Color color)
|
||||
{
|
||||
Toggle.IsChecked = false;
|
||||
SelectedColor = color;
|
||||
ColorChanged?.Invoke(color);
|
||||
}
|
||||
|
||||
private void MoreColors_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
Toggle.IsChecked = false;
|
||||
using var dialog = new System.Windows.Forms.ColorDialog
|
||||
{
|
||||
FullOpen = true,
|
||||
AnyColor = true,
|
||||
Color = System.Drawing.Color.FromArgb(255, _color.R, _color.G, _color.B)
|
||||
};
|
||||
if (dialog.ShowDialog() == System.Windows.Forms.DialogResult.OK)
|
||||
{
|
||||
Pick(Color.FromRgb(dialog.Color.R, dialog.Color.G, dialog.Color.B));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Toon de kleur als rand (vierkant met gat) in plaats van als vlak, zoals bij lijnkleur.</summary>
|
||||
public bool ShowAsOutline
|
||||
{
|
||||
get => _showAsOutline;
|
||||
set
|
||||
{
|
||||
_showAsOutline = value;
|
||||
UpdateSwatch();
|
||||
}
|
||||
}
|
||||
|
||||
private bool _showAsOutline;
|
||||
|
||||
/// <summary>Toon een "A" met een kleurbalk eronder, zoals bij tekstkleur.</summary>
|
||||
public bool ShowAsText
|
||||
{
|
||||
get => _showAsText;
|
||||
set
|
||||
{
|
||||
_showAsText = value;
|
||||
UpdateSwatch();
|
||||
}
|
||||
}
|
||||
|
||||
private bool _showAsText;
|
||||
|
||||
private void UpdateSwatch()
|
||||
{
|
||||
TextGlyph.Visibility = _showAsText ? Visibility.Visible : Visibility.Collapsed;
|
||||
TextBar.Background = new SolidColorBrush(_color);
|
||||
TextBar.BorderBrush = new SolidColorBrush(Color.FromArgb(50, 0, 0, 0));
|
||||
TextBar.BorderThickness = new Thickness(_color.R > 230 && _color.G > 230 && _color.B > 230 ? 1 : 0);
|
||||
bool none = _color.A == 0;
|
||||
CheckerBack.Visibility = none || _color.A < 255 ? Visibility.Visible : Visibility.Collapsed;
|
||||
Swatch.Background = none ? Brushes.Transparent : new SolidColorBrush(_color);
|
||||
Swatch.BorderBrush = new SolidColorBrush(Color.FromArgb(40, 0, 0, 0));
|
||||
Swatch.BorderThickness = new Thickness(none ? 1 : 0);
|
||||
OutlineHole.Visibility = _showAsOutline && !none ? Visibility.Visible : Visibility.Collapsed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Windows;
|
||||
using System.Windows.Data;
|
||||
|
||||
namespace Kniip.Editor;
|
||||
|
||||
/// <summary>Zet een hoekstraal om naar de linkerbovenhoek van het voorbeeldicoon in de hoekenkeuze.</summary>
|
||||
public sealed class CornerPreviewConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
double radius = value is double d ? d : 0;
|
||||
return new CornerRadius(Math.Min(14, radius * 0.7), 0, 0, 0);
|
||||
}
|
||||
|
||||
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
@@ -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 cursorsoort, voor het icoon in de cursorkeuze.</summary>
|
||||
public sealed class CursorIconConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object value, Type targetType, object parameter, CultureInfo culture) =>
|
||||
CursorGeometries.For(value is CursorKind kind ? kind : CursorKind.Arrow);
|
||||
|
||||
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using Kniip.Editor.Annotations;
|
||||
|
||||
namespace Kniip.Editor;
|
||||
|
||||
internal static class DocumentRenderer
|
||||
{
|
||||
public static void Render(DrawingContext dc, EditorDocument document)
|
||||
{
|
||||
dc.DrawImage(document.Image, new Rect(0, 0, document.Width, document.Height));
|
||||
foreach (Annotation item in document.Items)
|
||||
{
|
||||
if (!item.IsHidden)
|
||||
{
|
||||
item.Render(dc, document);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Maakt de annotaties plat op de originele resolutie van het knipsel.</summary>
|
||||
public static BitmapSource Export(EditorDocument document)
|
||||
{
|
||||
var visual = new DrawingVisual();
|
||||
RenderOptions.SetBitmapScalingMode(visual, BitmapScalingMode.NearestNeighbor);
|
||||
TextOptions.SetTextRenderingMode(visual, TextRenderingMode.Grayscale);
|
||||
using (DrawingContext dc = visual.RenderOpen())
|
||||
{
|
||||
dc.PushClip(new RectangleGeometry(new Rect(0, 0, document.Width, document.Height)));
|
||||
Render(dc, document);
|
||||
dc.Pop();
|
||||
}
|
||||
|
||||
var bitmap = new RenderTargetBitmap(document.Width, document.Height, 96, 96, PixelFormats.Pbgra32);
|
||||
bitmap.Render(visual);
|
||||
bitmap.Freeze();
|
||||
return bitmap;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Windows;
|
||||
using System.Windows.Media.Imaging;
|
||||
using Kniip.Editor.Annotations;
|
||||
using Kniip.Infrastructure;
|
||||
|
||||
namespace Kniip.Editor;
|
||||
|
||||
public sealed record DocumentState(BitmapSource Image, byte[] Pixels, List<Annotation> Items);
|
||||
|
||||
/// <summary>Het knipsel (als Bgra32-pixels, 96 dpi zodat 1 DIP = 1 pixel) plus de annotaties erop.</summary>
|
||||
public sealed class EditorDocument
|
||||
{
|
||||
public EditorDocument(BitmapSource source)
|
||||
{
|
||||
SetPixels(PixelUtil.ToOpaqueBgra(source), source.PixelWidth, source.PixelHeight);
|
||||
}
|
||||
|
||||
public BitmapSource Image { get; private set; } = null!;
|
||||
|
||||
public byte[] Pixels { get; private set; } = Array.Empty<byte>();
|
||||
|
||||
public int Width => Image.PixelWidth;
|
||||
|
||||
public int Height => Image.PixelHeight;
|
||||
|
||||
public List<Annotation> Items { get; private set; } = new();
|
||||
|
||||
private void SetPixels(byte[] pixels, int width, int height)
|
||||
{
|
||||
Image = PixelUtil.CreateBitmap(pixels, width, height);
|
||||
Pixels = pixels;
|
||||
}
|
||||
|
||||
public DocumentState CaptureState() => new(Image, Pixels, Items.Select(i => i.Clone()).ToList());
|
||||
|
||||
public void Restore(DocumentState state)
|
||||
{
|
||||
Image = state.Image;
|
||||
Pixels = state.Pixels;
|
||||
Items = state.Items.Select(i => i.Clone()).ToList();
|
||||
}
|
||||
|
||||
public void Crop(Int32Rect area)
|
||||
{
|
||||
var cropped = new byte[area.Width * area.Height * 4];
|
||||
for (int y = 0; y < area.Height; y++)
|
||||
{
|
||||
Buffer.BlockCopy(Pixels, ((area.Y + y) * Width + area.X) * 4, cropped, y * area.Width * 4, area.Width * 4);
|
||||
}
|
||||
|
||||
SetPixels(cropped, area.Width, area.Height);
|
||||
var offset = new Vector(-area.X, -area.Y);
|
||||
foreach (Annotation item in Items)
|
||||
{
|
||||
item.Move(offset);
|
||||
}
|
||||
}
|
||||
|
||||
public string GetStepLabel(StepAnnotation step)
|
||||
{
|
||||
int ordinal = 0;
|
||||
foreach (Annotation item in Items)
|
||||
{
|
||||
if (item is StepAnnotation s && s.Style.StepNumbering == step.Style.StepNumbering)
|
||||
{
|
||||
ordinal++;
|
||||
if (ReferenceEquals(s, step))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return StepAnnotation.FormatLabel(Math.Max(1, ordinal), step.Style.StepNumbering);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
<Window x:Class="Kniip.Editor.EditorWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="clr-namespace:Kniip.Editor"
|
||||
Title="kniip" Width="1180" Height="780" MinWidth="760" MinHeight="420"
|
||||
WindowStartupLocation="CenterScreen" Background="{StaticResource WindowBrush}"
|
||||
FontFamily="Segoe UI" FontSize="13" UseLayoutRounding="True"
|
||||
Icon="pack://application:,,,/AppIcon.ico">
|
||||
<Window.Resources>
|
||||
<local:CornerPreviewConverter x:Key="CornerPreview" />
|
||||
<local:CursorIconConverter x:Key="CursorIcon" />
|
||||
|
||||
<!-- Dikte: stip die meegroeit met de lijndikte -->
|
||||
<DataTemplate x:Key="ThicknessTemplate">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Grid Width="16" Height="16" VerticalAlignment="Center">
|
||||
<Ellipse x:Name="Dot" Fill="{StaticResource InkBrush}" Width="{Binding}" Height="{Binding}"
|
||||
MinWidth="2" MinHeight="2" MaxWidth="15" MaxHeight="15"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
<Line x:Name="NoneLine" X1="3" Y1="13" X2="13" Y2="3" Stroke="#E81123" StrokeThickness="1.5" Visibility="Collapsed" />
|
||||
</Grid>
|
||||
<TextBlock x:Name="Label" Text="{Binding StringFormat={}{0} px}" Margin="8,0,0,0" VerticalAlignment="Center" FontSize="12" />
|
||||
</StackPanel>
|
||||
<DataTemplate.Triggers>
|
||||
<DataTrigger Binding="{Binding}" Value="0">
|
||||
<Setter TargetName="Dot" Property="Visibility" Value="Collapsed" />
|
||||
<Setter TargetName="NoneLine" Property="Visibility" Value="Visible" />
|
||||
<Setter TargetName="Label" Property="Text" Value="Geen" />
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding RelativeSource={RelativeSource AncestorType=ComboBoxItem}}" Value="{x:Null}">
|
||||
<Setter TargetName="Label" Property="Visibility" Value="Collapsed" />
|
||||
</DataTrigger>
|
||||
</DataTemplate.Triggers>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- Lijnstijl: voorbeeldlijn -->
|
||||
<DataTemplate x:Key="DashTemplate">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Line x:Name="Sample" X1="1" Y1="8" X2="35" Y2="8" Width="36" Height="16" Stroke="{StaticResource InkBrush}"
|
||||
StrokeThickness="2" VerticalAlignment="Center" />
|
||||
<TextBlock x:Name="Label" Text="Doorgetrokken" Margin="8,0,0,0" VerticalAlignment="Center" FontSize="12" />
|
||||
</StackPanel>
|
||||
<DataTemplate.Triggers>
|
||||
<DataTrigger Binding="{Binding}" Value="{x:Static local:LineDash.Dash}">
|
||||
<Setter TargetName="Sample" Property="StrokeDashArray" Value="3 2" />
|
||||
<Setter TargetName="Label" Property="Text" Value="Gestreept" />
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding}" Value="{x:Static local:LineDash.Dot}">
|
||||
<Setter TargetName="Sample" Property="StrokeDashArray" Value="0 2" />
|
||||
<Setter TargetName="Sample" Property="StrokeDashCap" Value="Round" />
|
||||
<Setter TargetName="Sample" Property="X1" Value="2" />
|
||||
<Setter TargetName="Label" Property="Text" Value="Gestippeld" />
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding RelativeSource={RelativeSource AncestorType=ComboBoxItem}}" Value="{x:Null}">
|
||||
<Setter TargetName="Label" Property="Visibility" Value="Collapsed" />
|
||||
<Setter TargetName="Sample" Property="Width" Value="28" />
|
||||
<Setter TargetName="Sample" Property="X2" Value="27" />
|
||||
</DataTrigger>
|
||||
</DataTemplate.Triggers>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- Hoeken: hoekje met de gekozen afronding -->
|
||||
<DataTemplate x:Key="CornerTemplate">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Grid Width="18" Height="18" VerticalAlignment="Center" ClipToBounds="True">
|
||||
<Border Width="26" Height="26" HorizontalAlignment="Left" VerticalAlignment="Top" Margin="2,2,0,0"
|
||||
BorderBrush="{StaticResource InkBrush}" BorderThickness="2,2,0,0"
|
||||
CornerRadius="{Binding Converter={StaticResource CornerPreview}}" />
|
||||
</Grid>
|
||||
<TextBlock x:Name="Label" Text="{Binding StringFormat={}{0} px}" Margin="8,0,0,0" VerticalAlignment="Center" FontSize="12" />
|
||||
</StackPanel>
|
||||
<DataTemplate.Triggers>
|
||||
<DataTrigger Binding="{Binding}" Value="0">
|
||||
<Setter TargetName="Label" Property="Text" Value="Recht" />
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding RelativeSource={RelativeSource AncestorType=ComboBoxItem}}" Value="{x:Null}">
|
||||
<Setter TargetName="Label" Property="Visibility" Value="Collapsed" />
|
||||
</DataTrigger>
|
||||
</DataTemplate.Triggers>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- Lettergrootte: "A" met het getal -->
|
||||
<DataTemplate x:Key="FontSizeTemplate">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="A" FontSize="15" VerticalAlignment="Center" />
|
||||
<TextBlock Text="{Binding}" FontSize="11" Margin="3,4,0,0" VerticalAlignment="Center" Foreground="{StaticResource SubtleTextBrush}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- Grootte (stappen, cursor): cirkel met het getal -->
|
||||
<DataTemplate x:Key="SizeTemplate">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Grid Width="16" Height="16" VerticalAlignment="Center">
|
||||
<Ellipse Stroke="{StaticResource InkBrush}" StrokeThickness="1" />
|
||||
<Path Data="M5,8 H11 M8,5 V11" Stroke="{StaticResource InkBrush}" StrokeThickness="1" />
|
||||
</Grid>
|
||||
<TextBlock Text="{Binding}" FontSize="11" Margin="4,0,0,0" VerticalAlignment="Center" Foreground="{StaticResource SubtleTextBrush}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- Stappenreeks: badge met het eerste teken -->
|
||||
<DataTemplate x:Key="NumberingTemplate">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Grid Width="18" Height="18" VerticalAlignment="Center">
|
||||
<Ellipse Fill="#E81123" />
|
||||
<TextBlock x:Name="Badge" Text="1" Foreground="White" FontSize="10"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
<TextBlock x:Name="Label" Text="1, 2, 3" Margin="8,0,0,0" VerticalAlignment="Center" FontSize="12" />
|
||||
</StackPanel>
|
||||
<DataTemplate.Triggers>
|
||||
<DataTrigger Binding="{Binding}" Value="{x:Static local:StepNumbering.LowerAlpha}">
|
||||
<Setter TargetName="Badge" Property="Text" Value="a" />
|
||||
<Setter TargetName="Label" Property="Text" Value="a, b, c" />
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding}" Value="{x:Static local:StepNumbering.UpperAlpha}">
|
||||
<Setter TargetName="Badge" Property="Text" Value="A" />
|
||||
<Setter TargetName="Label" Property="Text" Value="A, B, C" />
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding}" Value="{x:Static local:StepNumbering.Roman}">
|
||||
<Setter TargetName="Badge" Property="Text" Value="I" />
|
||||
<Setter TargetName="Label" Property="Text" Value="I, II, III" />
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding RelativeSource={RelativeSource AncestorType=ComboBoxItem}}" Value="{x:Null}">
|
||||
<Setter TargetName="Label" Property="Visibility" Value="Collapsed" />
|
||||
</DataTrigger>
|
||||
</DataTemplate.Triggers>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- Soort cursor: het cursoricoon zelf -->
|
||||
<DataTemplate x:Key="CursorKindTemplate">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Path Data="{Binding Converter={StaticResource CursorIcon}}" Fill="White" Stroke="{StaticResource InkBrush}"
|
||||
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" />
|
||||
</StackPanel>
|
||||
<DataTemplate.Triggers>
|
||||
<DataTrigger Binding="{Binding}" Value="{x:Static local:CursorKind.Click}">
|
||||
<Setter TargetName="Label" Property="Text" Value="Pijl met klik" />
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding}" Value="{x:Static local:CursorKind.Hand}">
|
||||
<Setter TargetName="Label" Property="Text" Value="Hand" />
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding RelativeSource={RelativeSource AncestorType=ComboBoxItem}}" Value="{x:Null}">
|
||||
<Setter TargetName="Label" Property="Visibility" Value="Collapsed" />
|
||||
</DataTrigger>
|
||||
</DataTemplate.Triggers>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- Effect: pixelblokjes of zachte vlek -->
|
||||
<DataTemplate x:Key="BlurModeTemplate">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Grid Width="16" Height="16" VerticalAlignment="Center">
|
||||
<Path x:Name="PixelIcon" Fill="{StaticResource 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" />
|
||||
<Ellipse x:Name="BlurIcon" Visibility="Collapsed">
|
||||
<Ellipse.Fill>
|
||||
<RadialGradientBrush>
|
||||
<GradientStop Color="#FF1A1A1A" Offset="0" />
|
||||
<GradientStop Color="#801A1A1A" Offset="0.5" />
|
||||
<GradientStop Color="#001A1A1A" Offset="1" />
|
||||
</RadialGradientBrush>
|
||||
</Ellipse.Fill>
|
||||
</Ellipse>
|
||||
</Grid>
|
||||
<TextBlock x:Name="Label" Text="Pixeleren" Margin="8,0,0,0" VerticalAlignment="Center" FontSize="12" />
|
||||
</StackPanel>
|
||||
<DataTemplate.Triggers>
|
||||
<DataTrigger Binding="{Binding}" Value="{x:Static local:BlurMode.Blur}">
|
||||
<Setter TargetName="PixelIcon" Property="Visibility" Value="Collapsed" />
|
||||
<Setter TargetName="BlurIcon" Property="Visibility" Value="Visible" />
|
||||
<Setter TargetName="Label" Property="Text" Value="Vervagen" />
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding RelativeSource={RelativeSource AncestorType=ComboBoxItem}}" Value="{x:Null}">
|
||||
<Setter TargetName="Label" Property="Visibility" Value="Collapsed" />
|
||||
</DataTrigger>
|
||||
</DataTemplate.Triggers>
|
||||
</DataTemplate>
|
||||
</Window.Resources>
|
||||
<DockPanel>
|
||||
<!-- Statusbalk -->
|
||||
<Border DockPanel.Dock="Bottom" Background="{StaticResource ToolbarBrush}" BorderBrush="{StaticResource DividerBrush}"
|
||||
BorderThickness="0,1,0,0" Padding="12,4">
|
||||
<DockPanel>
|
||||
<TextBlock x:Name="InfoText" DockPanel.Dock="Right" Foreground="{StaticResource SubtleTextBrush}" FontSize="12" />
|
||||
<TextBlock x:Name="HintText" Foreground="{StaticResource SubtleTextBrush}" FontSize="12" TextTrimming="CharacterEllipsis" />
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Gereedschappen en acties; rechtsklik voor vergrendelen, positie en zichtbare knoppen -->
|
||||
<Border x:Name="Toolbar" DockPanel.Dock="Top" Background="{StaticResource ToolbarBrush}" BorderBrush="{StaticResource DividerBrush}"
|
||||
BorderThickness="0,0,0,1" Padding="8,6" SizeChanged="Toolbar_SizeChanged" ContextMenuOpening="Toolbar_ContextMenuOpening">
|
||||
<Border.ContextMenu>
|
||||
<ContextMenu x:Name="ToolbarMenu" />
|
||||
</Border.ContextMenu>
|
||||
<local:ToolbarPanel x:Name="ToolbarItems" OrderChanged="ToolbarItems_OrderChanged">
|
||||
<RadioButton GroupName="Tools" Tag="Select" local:ToolbarPanel.ItemId="Select" local:ToolbarPanel.Title="Selecteren"
|
||||
Style="{StaticResource ToolToggle}" ToolTip="Selecteren en verplaatsen (V)" Checked="Tool_Checked">
|
||||
<Path Style="{StaticResource IconPath}" Data="M5,2.5 L5,16.5 L8.6,13.2 L11,18.4 L13.3,17.4 L11,12.3 L15.8,12.3 Z" />
|
||||
</RadioButton>
|
||||
<local:ToolbarSeparator local:ToolbarPanel.ItemId="Sep1" />
|
||||
<RadioButton GroupName="Tools" Tag="Rectangle" local:ToolbarPanel.ItemId="Rectangle" local:ToolbarPanel.Title="Rechthoek"
|
||||
Style="{StaticResource ToolToggle}" ToolTip="Rechthoek (R) - Shift voor vierkant" Checked="Tool_Checked">
|
||||
<Path Style="{StaticResource IconPath}" Data="M3,5 H17 V15 H3 Z" />
|
||||
</RadioButton>
|
||||
<RadioButton GroupName="Tools" Tag="Ellipse" local:ToolbarPanel.ItemId="Ellipse" local:ToolbarPanel.Title="Ellips"
|
||||
Style="{StaticResource ToolToggle}" ToolTip="Ellips (E) - Shift voor cirkel" Checked="Tool_Checked">
|
||||
<Path Style="{StaticResource IconPath}">
|
||||
<Path.Data>
|
||||
<EllipseGeometry Center="10,10" RadiusX="7.5" RadiusY="5.5" />
|
||||
</Path.Data>
|
||||
</Path>
|
||||
</RadioButton>
|
||||
<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">
|
||||
<Path Style="{StaticResource IconPath}" Data="M4,16 L16,4" />
|
||||
</RadioButton>
|
||||
<RadioButton GroupName="Tools" Tag="Arrow" local:ToolbarPanel.ItemId="Arrow" local:ToolbarPanel.Title="Pijl"
|
||||
Style="{StaticResource ToolToggle}" ToolTip="Pijl (A) - Shift voor hoeken van 45°" Checked="Tool_Checked">
|
||||
<Path Style="{StaticResource IconPath}" Data="M4,16 L15.5,4.5 M9,4.5 H15.5 V11" />
|
||||
</RadioButton>
|
||||
<RadioButton GroupName="Tools" Tag="Text" local:ToolbarPanel.ItemId="Text" local:ToolbarPanel.Title="Tekst"
|
||||
Style="{StaticResource ToolToggle}" ToolTip="Tekst in kader (T)" Checked="Tool_Checked">
|
||||
<Grid Width="20" Height="20">
|
||||
<Path Style="{StaticResource IconPath}" Data="M2.5,4 H17.5 V16 H2.5 Z" />
|
||||
<TextBlock Text="T" FontSize="11" HorizontalAlignment="Center" VerticalAlignment="Center" Margin="0,0,0,1" />
|
||||
</Grid>
|
||||
</RadioButton>
|
||||
<RadioButton GroupName="Tools" Tag="Step" local:ToolbarPanel.ItemId="Step" local:ToolbarPanel.Title="Stappen"
|
||||
Style="{StaticResource ToolToggle}" ToolTip="Stappen 1, 2, 3 / a, b, c (S)" Checked="Tool_Checked">
|
||||
<Grid Width="20" Height="20">
|
||||
<Ellipse Width="17" Height="17" Fill="#E81123" />
|
||||
<TextBlock Text="1" Foreground="White" FontSize="11" HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</RadioButton>
|
||||
<RadioButton GroupName="Tools" Tag="Cursor" local:ToolbarPanel.ItemId="Cursor" local:ToolbarPanel.Title="Cursor"
|
||||
Style="{StaticResource ToolToggle}" ToolTip="Cursor plaatsen (C)" Checked="Tool_Checked">
|
||||
<Path Style="{StaticResource IconPath}" Fill="White"
|
||||
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 GroupName="Tools" Tag="Emoji" local:ToolbarPanel.ItemId="Emoji" local:ToolbarPanel.Title="Emoji"
|
||||
Style="{StaticResource ToolToggle}" ToolTip="Emoji plaatsen (J)" Checked="Tool_Checked">
|
||||
<TextBlock Text="" Style="{StaticResource IconText}" />
|
||||
</RadioButton>
|
||||
<RadioButton GroupName="Tools" Tag="Highlight" local:ToolbarPanel.ItemId="Highlight" local:ToolbarPanel.Title="Markeerstift"
|
||||
Style="{StaticResource ToolToggle}" ToolTip="Markeerstift (H) - rechte horizontale streep, Shift wisselt" Checked="Tool_Checked">
|
||||
<Grid Width="20" Height="20">
|
||||
<Rectangle Width="17" Height="7" Fill="#A0FFE600" VerticalAlignment="Center" />
|
||||
<Path Style="{StaticResource IconPath}" Data="M2,10 H18" StrokeThickness="1" Stroke="#B39C00" Opacity="0.6" />
|
||||
</Grid>
|
||||
</RadioButton>
|
||||
<RadioButton GroupName="Tools" Tag="Blur" local:ToolbarPanel.ItemId="Blur" local:ToolbarPanel.Title="Vervagen / pixeleren"
|
||||
Style="{StaticResource ToolToggle}" ToolTip="Vervagen / pixeleren (B)" Checked="Tool_Checked">
|
||||
<Path Style="{StaticResource IconPath}" Fill="#1A1A1A" 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" />
|
||||
</RadioButton>
|
||||
<local:ToolbarSeparator local:ToolbarPanel.ItemId="Sep2" />
|
||||
<RadioButton GroupName="Tools" Tag="Crop" local:ToolbarPanel.ItemId="Crop" local:ToolbarPanel.Title="Bijsnijden"
|
||||
Style="{StaticResource ToolToggle}" ToolTip="Bijsnijden (X)" Checked="Tool_Checked">
|
||||
<TextBlock Text="" Style="{StaticResource IconText}" />
|
||||
</RadioButton>
|
||||
|
||||
<local:ToolbarSpacer local:ToolbarPanel.ItemId="Spacer" />
|
||||
|
||||
<Button local:ToolbarPanel.ItemId="New" local:ToolbarPanel.Title="Nieuw knipsel"
|
||||
Style="{StaticResource ProminentButton}" ToolTip="Knipsel wissen en een nieuw knipsel maken (Ctrl+N)" Click="NewSnip_Click">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="" Style="{StaticResource IconText}" />
|
||||
<TextBlock x:Name="NewLabel" Text="Nieuw" FontSize="12" VerticalAlignment="Center" Margin="6,0,0,0" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button local:ToolbarPanel.ItemId="Screencast" local:ToolbarPanel.Title="Screencast"
|
||||
Style="{StaticResource ProminentButton}" ToolTip="Screencast opnemen als MP4 of GIF" Click="Screencast_Click">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="" Style="{StaticResource IconText}" />
|
||||
<TextBlock x:Name="ScreencastLabel" Text="Screencast" FontSize="12" VerticalAlignment="Center" Margin="6,0,0,0" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<!-- Wat tussen de twee spacers staat, komt in het midden van de werkbalk -->
|
||||
<local:ToolbarSpacer local:ToolbarPanel.ItemId="Spacer2" />
|
||||
|
||||
<Button local:ToolbarPanel.ItemId="Clear" local:ToolbarPanel.Title="Wissen"
|
||||
Style="{StaticResource FlatButton}" ToolTip="Knipsel weggooien en opnieuw beginnen met een leeg canvas (Ctrl+Delete)" Click="Clear_Click">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="" Style="{StaticResource IconText}" />
|
||||
<TextBlock x:Name="ClearLabel" Text="Wissen" FontSize="12" VerticalAlignment="Center" Margin="6,0,0,0" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<local:ToolbarSeparator local:ToolbarPanel.ItemId="Sep3" />
|
||||
<Button x:Name="UndoButton" local:ToolbarPanel.ItemId="Undo" local:ToolbarPanel.Title="Ongedaan maken"
|
||||
Style="{StaticResource FlatButton}" ToolTip="Ongedaan maken (Ctrl+Z)" Click="Undo_Click">
|
||||
<TextBlock Text="" Style="{StaticResource IconText}" />
|
||||
</Button>
|
||||
<Button x:Name="RedoButton" local:ToolbarPanel.ItemId="Redo" local:ToolbarPanel.Title="Opnieuw"
|
||||
Style="{StaticResource FlatButton}" ToolTip="Opnieuw (Ctrl+Y)" Click="Redo_Click">
|
||||
<TextBlock Text="" Style="{StaticResource IconText}" />
|
||||
</Button>
|
||||
<Button x:Name="DeleteButton" local:ToolbarPanel.ItemId="Delete" local:ToolbarPanel.Title="Verwijderen"
|
||||
Style="{StaticResource FlatButton}" ToolTip="Geselecteerd object verwijderen (Delete)" Click="Delete_Click">
|
||||
<TextBlock Text="" Style="{StaticResource IconText}" />
|
||||
</Button>
|
||||
<local:ToolbarSeparator local:ToolbarPanel.ItemId="Sep4" />
|
||||
<Button local:ToolbarPanel.ItemId="ZoomOut" local:ToolbarPanel.Title="Uitzoomen"
|
||||
Style="{StaticResource FlatButton}" ToolTip="Uitzoomen (Ctrl+-)" Click="ZoomOut_Click">
|
||||
<TextBlock Text="" Style="{StaticResource IconText}" />
|
||||
</Button>
|
||||
<Button x:Name="ZoomButton" local:ToolbarPanel.ItemId="Zoom" local:ToolbarPanel.Title="Zoompercentage"
|
||||
Style="{StaticResource FlatButton}" ToolTip="Passend maken (Ctrl+0) / werkelijke grootte (Ctrl+1)" Click="ZoomReset_Click" MinWidth="56">
|
||||
<TextBlock x:Name="ZoomText" Text="100%" FontSize="12" />
|
||||
</Button>
|
||||
<Button local:ToolbarPanel.ItemId="ZoomIn" local:ToolbarPanel.Title="Inzoomen"
|
||||
Style="{StaticResource FlatButton}" ToolTip="Inzoomen (Ctrl++)" Click="ZoomIn_Click">
|
||||
<TextBlock Text="" Style="{StaticResource IconText}" />
|
||||
</Button>
|
||||
<local:ToolbarSeparator local:ToolbarPanel.ItemId="Sep5" />
|
||||
<Button local:ToolbarPanel.ItemId="Settings" local:ToolbarPanel.Title="Instellingen"
|
||||
Style="{StaticResource FlatButton}" ToolTip="Instellingen" Click="Settings_Click">
|
||||
<TextBlock Text="" Style="{StaticResource IconText}" />
|
||||
</Button>
|
||||
<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">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="" Style="{StaticResource IconText}" FontSize="14" />
|
||||
<TextBlock x:Name="SaveLabel" Text="Opslaan" Margin="6,0,0,0" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button x:Name="UploadButton" local:ToolbarPanel.ItemId="Upload" local:ToolbarPanel.Title="Uploaden"
|
||||
Style="{StaticResource SecondaryButton}" ToolTip="Uploaden en de link kopiëren (Ctrl+U)" Click="Upload_Click" Margin="4,0,0,0">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="" Style="{StaticResource IconText}" FontSize="14" />
|
||||
<TextBlock x:Name="UploadLabel" Text="Uploaden" Margin="6,0,0,0" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button local:ToolbarPanel.ItemId="Copy" local:ToolbarPanel.Title="Kopiëren"
|
||||
Style="{StaticResource AccentButton}" ToolTip="Kopiëren naar klembord (Ctrl+C)" Click="Copy_Click" Margin="4,0,0,0">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="" Style="{StaticResource IconText}" FontSize="14" Foreground="White" />
|
||||
<TextBlock x:Name="CopyLabel" Text="Kopiëren" Margin="6,0,0,0" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</local:ToolbarPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Eigenschappen van het actieve gereedschap of het geselecteerde object -->
|
||||
<Border DockPanel.Dock="Top" Background="White" BorderBrush="{StaticResource DividerBrush}" BorderThickness="0,0,0,1"
|
||||
Padding="12,4" MinHeight="42">
|
||||
<WrapPanel x:Name="PropsPanel" VerticalAlignment="Center" HorizontalAlignment="Center">
|
||||
<StackPanel x:Name="StrokeGroup" Style="{StaticResource PropGroup}">
|
||||
<local:ColorPickerButton x:Name="StrokePicker" ShowAsOutline="True" ToolTip="Randkleur" />
|
||||
</StackPanel>
|
||||
<StackPanel x:Name="FillGroup" Style="{StaticResource PropGroup}">
|
||||
<local:ColorPickerButton x:Name="FillPicker" AllowTransparent="True" ToolTip="Vulkleur" />
|
||||
</StackPanel>
|
||||
<StackPanel x:Name="ForegroundGroup" Style="{StaticResource PropGroup}">
|
||||
<local:ColorPickerButton x:Name="ForegroundPicker" ShowAsText="True" ToolTip="Tekstkleur" />
|
||||
</StackPanel>
|
||||
<StackPanel x:Name="ThicknessGroup" Style="{StaticResource PropGroup}">
|
||||
<ComboBox x:Name="ThicknessCombo" Style="{StaticResource MiniCombo}" ItemTemplate="{StaticResource ThicknessTemplate}"
|
||||
ToolTip="Dikte" SelectionChanged="Thickness_Changed" />
|
||||
</StackPanel>
|
||||
<StackPanel x:Name="DashGroup" Style="{StaticResource PropGroup}">
|
||||
<ComboBox x:Name="DashCombo" Style="{StaticResource MiniCombo}" ItemTemplate="{StaticResource DashTemplate}"
|
||||
ToolTip="Lijnstijl" SelectionChanged="Dash_Changed" />
|
||||
</StackPanel>
|
||||
<StackPanel x:Name="CornerGroup" Style="{StaticResource PropGroup}">
|
||||
<ComboBox x:Name="CornerCombo" Style="{StaticResource MiniCombo}" ItemTemplate="{StaticResource CornerTemplate}"
|
||||
ToolTip="Hoeken" SelectionChanged="Corner_Changed" />
|
||||
</StackPanel>
|
||||
<StackPanel x:Name="FontGroup" Style="{StaticResource PropGroup}">
|
||||
<ComboBox x:Name="FontSizeCombo" Style="{StaticResource MiniCombo}" ItemTemplate="{StaticResource FontSizeTemplate}"
|
||||
ToolTip="Lettergrootte" SelectionChanged="FontSize_Changed" />
|
||||
<ToggleButton x:Name="BoldToggle" Style="{StaticResource MiniToggle}" ToolTip="Vet" Checked="Bold_Changed" Unchecked="Bold_Changed">
|
||||
<TextBlock Text="B" FontWeight="Bold" FontSize="13" />
|
||||
</ToggleButton>
|
||||
</StackPanel>
|
||||
<StackPanel x:Name="NumberingGroup" Style="{StaticResource PropGroup}">
|
||||
<ComboBox x:Name="NumberingCombo" Style="{StaticResource MiniCombo}" ItemTemplate="{StaticResource NumberingTemplate}"
|
||||
ToolTip="Reeks" SelectionChanged="Numbering_Changed" />
|
||||
</StackPanel>
|
||||
<StackPanel x:Name="CursorGroup" Style="{StaticResource PropGroup}">
|
||||
<ComboBox x:Name="CursorKindCombo" Style="{StaticResource MiniCombo}" ItemTemplate="{StaticResource CursorKindTemplate}"
|
||||
ToolTip="Soort cursor" SelectionChanged="CursorKind_Changed" />
|
||||
</StackPanel>
|
||||
<StackPanel x:Name="EmojiGroup" Style="{StaticResource PropGroup}">
|
||||
<local:EmojiPickerButton x:Name="EmojiPicker" />
|
||||
</StackPanel>
|
||||
<StackPanel x:Name="SizeGroup" Style="{StaticResource PropGroup}">
|
||||
<ComboBox x:Name="SizeCombo" Style="{StaticResource MiniCombo}" ItemTemplate="{StaticResource SizeTemplate}"
|
||||
ToolTip="Grootte" SelectionChanged="Size_Changed" />
|
||||
</StackPanel>
|
||||
<StackPanel x:Name="BlurGroup" Style="{StaticResource PropGroup}">
|
||||
<ComboBox x:Name="BlurModeCombo" Style="{StaticResource MiniCombo}" ItemTemplate="{StaticResource BlurModeTemplate}"
|
||||
ToolTip="Effect" SelectionChanged="BlurMode_Changed" />
|
||||
<Border Background="White" BorderBrush="#E0E0E0" BorderThickness="1" CornerRadius="4" Height="30" Margin="2,0" Padding="8,0"
|
||||
ToolTip="Sterkte">
|
||||
<Slider x:Name="BlurStrengthSlider" Width="90" Minimum="1" Maximum="20" IsSnapToTickEnabled="True" TickFrequency="1"
|
||||
VerticalAlignment="Center" ValueChanged="BlurStrength_Changed" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<StackPanel x:Name="StraightGroup" Style="{StaticResource PropGroup}">
|
||||
<ToggleButton x:Name="StraightCheck" Style="{StaticResource MiniToggle}" ToolTip="Rechte horizontale streep (Shift wisselt)"
|
||||
Checked="Straight_Changed" Unchecked="Straight_Changed">
|
||||
<Path Data="M2,9 H16 M2,5 V13 M16,5 V13" Stroke="{StaticResource InkBrush}" StrokeThickness="1.5"
|
||||
StrokeStartLineCap="Round" StrokeEndLineCap="Round" Width="18" Height="18" />
|
||||
</ToggleButton>
|
||||
</StackPanel>
|
||||
<StackPanel x:Name="ShadowGroup" Style="{StaticResource PropGroup}">
|
||||
<ToggleButton x:Name="ShadowCheck" Style="{StaticResource MiniToggle}" ToolTip="Schaduw"
|
||||
Checked="Shadow_Changed" Unchecked="Shadow_Changed">
|
||||
<Grid Width="18" Height="18">
|
||||
<Rectangle Width="11" Height="11" RadiusX="2" RadiusY="2" Fill="#9A9A9A" Margin="5,5,0,0"
|
||||
HorizontalAlignment="Left" VerticalAlignment="Top" />
|
||||
<Rectangle Width="11" Height="11" RadiusX="2" RadiusY="2" Fill="White" Stroke="{StaticResource InkBrush}"
|
||||
StrokeThickness="1" Margin="2,2,0,0" HorizontalAlignment="Left" VerticalAlignment="Top" />
|
||||
</Grid>
|
||||
</ToggleButton>
|
||||
</StackPanel>
|
||||
<TextBlock x:Name="NoPropsText" Style="{StaticResource PropLabel}" VerticalAlignment="Center" />
|
||||
</WrapPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Canvas -->
|
||||
<ScrollViewer x:Name="Scroller" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto"
|
||||
Background="#E9E9E9" PreviewMouseWheel="Scroller_PreviewMouseWheel" Focusable="False">
|
||||
<Grid HorizontalAlignment="Center" VerticalAlignment="Center" Margin="24">
|
||||
<Border BorderBrush="#C8C8C8" BorderThickness="1" Margin="-1" SnapsToDevicePixels="True" />
|
||||
<Grid x:Name="CanvasHost">
|
||||
<Grid.LayoutTransform>
|
||||
<ScaleTransform x:Name="ZoomTransform" />
|
||||
</Grid.LayoutTransform>
|
||||
<local:AnnotationSurface x:Name="Surface"
|
||||
MouseLeftButtonDown="Surface_MouseLeftButtonDown"
|
||||
MouseMove="Surface_MouseMove"
|
||||
MouseLeftButtonUp="Surface_MouseLeftButtonUp"
|
||||
MouseRightButtonUp="Surface_MouseRightButtonUp"
|
||||
LostMouseCapture="Surface_LostMouseCapture" />
|
||||
<Canvas x:Name="TextLayer" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,191 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
|
||||
namespace Kniip.Editor.Emoji;
|
||||
|
||||
public sealed record EmojiCategory(string Id, string Title, string IconEmoji);
|
||||
|
||||
public sealed class EmojiInfo
|
||||
{
|
||||
public required string Unified { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public required string ShortName { get; init; }
|
||||
public required string Category { get; init; }
|
||||
public required string Keywords { get; init; }
|
||||
public required int SheetX { get; init; }
|
||||
public required int SheetY { get; init; }
|
||||
public IReadOnlyDictionary<SkinTone, (string Unified, int X, int Y)>? Skins { get; init; }
|
||||
|
||||
public bool HasSkinTones => Skins != null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// De Twemoji-set uit emoji-datasource (zie tools/fetch-emoji.ps1): één spritesheet met cellen van 64 px
|
||||
/// plus 1 px rand, en een compacte catalogus met Nederlandse en Engelse trefwoorden.
|
||||
/// </summary>
|
||||
public static class EmojiCatalog
|
||||
{
|
||||
private const int CellSize = 64;
|
||||
private const int CellStride = CellSize + 2;
|
||||
private const string AssetRoot = "pack://application:,,,/Assets/Emoji/";
|
||||
|
||||
public static readonly IReadOnlyList<EmojiCategory> Categories = new[]
|
||||
{
|
||||
new EmojiCategory("Smileys & Emotion", "Smileys en emotie", "1f600"),
|
||||
new EmojiCategory("People & Body", "Mensen", "1f44b"),
|
||||
new EmojiCategory("Animals & Nature", "Dieren en natuur", "1f43b"),
|
||||
new EmojiCategory("Food & Drink", "Eten en drinken", "1f354"),
|
||||
new EmojiCategory("Activities", "Activiteiten", "26bd"),
|
||||
new EmojiCategory("Travel & Places", "Reizen en plaatsen", "1f697"),
|
||||
new EmojiCategory("Objects", "Objecten", "1f4a1"),
|
||||
new EmojiCategory("Symbols", "Symbolen", "2764-fe0f"),
|
||||
new EmojiCategory("Flags", "Vlaggen", "1f3c1")
|
||||
};
|
||||
|
||||
private static readonly Lazy<BitmapSource> SheetLoader = new(LoadSheet);
|
||||
private static readonly Lazy<Data> DataLoader = new(LoadData);
|
||||
private static readonly ConcurrentDictionary<(int, int), ImageSource> Cells = new();
|
||||
|
||||
private sealed record Data(IReadOnlyList<EmojiInfo> All, IReadOnlyDictionary<string, EmojiInfo> ByUnified);
|
||||
|
||||
public static IReadOnlyList<EmojiInfo> All => DataLoader.Value.All;
|
||||
|
||||
/// <summary>Laadt de catalogus en het sheet alvast op de achtergrond, zodat de eerste keer kiezen niet hapert.</summary>
|
||||
public static void Preload() =>
|
||||
System.Threading.Tasks.Task.Run(() =>
|
||||
{
|
||||
_ = SheetLoader.Value;
|
||||
_ = DataLoader.Value;
|
||||
});
|
||||
|
||||
public static EmojiInfo? Find(string unified) =>
|
||||
DataLoader.Value.ByUnified.TryGetValue(unified, out EmojiInfo? info) ? info : null;
|
||||
|
||||
public static ImageSource Get(string unified, SkinTone tone)
|
||||
{
|
||||
EmojiInfo? info = Find(unified) ?? Find("1f44d");
|
||||
return info == null ? Placeholder : Get(info, tone);
|
||||
}
|
||||
|
||||
public static ImageSource Get(EmojiInfo info, SkinTone tone)
|
||||
{
|
||||
(int x, int y) = tone != SkinTone.Default && info.Skins != null && info.Skins.TryGetValue(tone, out var skin)
|
||||
? (skin.X, skin.Y)
|
||||
: (info.SheetX, info.SheetY);
|
||||
return Cells.GetOrAdd((x, y), key =>
|
||||
{
|
||||
var cell = new CroppedBitmap(SheetLoader.Value,
|
||||
new Int32Rect(key.Item1 * CellStride + 1, key.Item2 * CellStride + 1, CellSize, CellSize));
|
||||
cell.Freeze();
|
||||
return cell;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Het emojiteken zelf (met huidskleur), bijvoorbeeld voor de tooltip.</summary>
|
||||
public static string ToText(EmojiInfo info, SkinTone tone)
|
||||
{
|
||||
string unified = tone != SkinTone.Default && info.Skins != null && info.Skins.TryGetValue(tone, out var skin)
|
||||
? skin.Unified
|
||||
: info.Unified;
|
||||
return string.Concat(unified.Split('-').Select(cp => char.ConvertFromUtf32(Convert.ToInt32(cp, 16))));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Alle woorden uit de zoekterm moeten voorkomen; emoji waarvan de naam of shortcode met de term begint, komen eerst.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<EmojiInfo> Search(string query)
|
||||
{
|
||||
string[] words = query.Trim().ToLowerInvariant().Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (words.Length == 0)
|
||||
{
|
||||
return Array.Empty<EmojiInfo>();
|
||||
}
|
||||
|
||||
string first = words[0];
|
||||
return All
|
||||
.Where(e => words.All(w => HasWordStartingWith(e, w)))
|
||||
.Select(e => (Emoji: e, Rank: Rank(e, first)))
|
||||
.OrderBy(r => r.Rank)
|
||||
.Select(r => r.Emoji)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static bool HasWordStartingWith(EmojiInfo e, string word) =>
|
||||
e.Keywords.Contains("|" + word, StringComparison.Ordinal) || e.Keywords.Contains(" " + word, StringComparison.Ordinal);
|
||||
|
||||
private static int Rank(EmojiInfo e, string word)
|
||||
{
|
||||
if (e.ShortName.StartsWith(word, StringComparison.OrdinalIgnoreCase) || e.Name.StartsWith(word, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return e.Keywords.Contains("|" + word + "|", StringComparison.Ordinal) ? 1 : 2;
|
||||
}
|
||||
|
||||
private static readonly ImageSource Placeholder = CreatePlaceholder();
|
||||
|
||||
private static ImageSource CreatePlaceholder()
|
||||
{
|
||||
var image = new DrawingImage(new GeometryDrawing(Brushes.LightGray, null, new RectangleGeometry(new Rect(0, 0, CellSize, CellSize))));
|
||||
image.Freeze();
|
||||
return image;
|
||||
}
|
||||
|
||||
private static BitmapSource LoadSheet()
|
||||
{
|
||||
var sheet = new BitmapImage();
|
||||
sheet.BeginInit();
|
||||
sheet.UriSource = new Uri(AssetRoot + "twemoji-64.png");
|
||||
sheet.CacheOption = BitmapCacheOption.OnLoad;
|
||||
sheet.CreateOptions = BitmapCreateOptions.IgnoreColorProfile;
|
||||
sheet.EndInit();
|
||||
sheet.Freeze();
|
||||
return sheet;
|
||||
}
|
||||
|
||||
private static Data LoadData()
|
||||
{
|
||||
var all = new List<EmojiInfo>();
|
||||
var byUnified = new Dictionary<string, EmojiInfo>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
using (System.IO.Stream stream = Application.GetResourceStream(new Uri(AssetRoot + "emoji.json")).Stream)
|
||||
using (JsonDocument doc = JsonDocument.Parse(stream))
|
||||
{
|
||||
foreach (JsonElement e in doc.RootElement.EnumerateArray())
|
||||
{
|
||||
Dictionary<SkinTone, (string, int, int)>? skins = null;
|
||||
if (e.TryGetProperty("t", out JsonElement t))
|
||||
{
|
||||
skins = new Dictionary<SkinTone, (string, int, int)>();
|
||||
foreach (JsonProperty p in t.EnumerateObject())
|
||||
{
|
||||
skins[(SkinTone)int.Parse(p.Name)] = (p.Value[0].GetString()!, p.Value[1].GetInt32(), p.Value[2].GetInt32());
|
||||
}
|
||||
}
|
||||
|
||||
var info = new EmojiInfo
|
||||
{
|
||||
Unified = e.GetProperty("u").GetString()!,
|
||||
Category = e.GetProperty("c").GetString()!,
|
||||
SheetX = e.GetProperty("x").GetInt32(),
|
||||
SheetY = e.GetProperty("y").GetInt32(),
|
||||
ShortName = e.GetProperty("s").GetString()!,
|
||||
Name = e.GetProperty("n").GetString()!,
|
||||
Keywords = "|" + e.GetProperty("k").GetString()!,
|
||||
Skins = skins
|
||||
};
|
||||
all.Add(info);
|
||||
byUnified[info.Unified] = info;
|
||||
}
|
||||
}
|
||||
|
||||
return new Data(all, byUnified);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
<UserControl x:Class="Kniip.Editor.EmojiPickerButton"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="clr-namespace:Kniip.Editor"
|
||||
VerticalAlignment="Center">
|
||||
<Grid>
|
||||
<ToggleButton x:Name="Toggle" Style="{StaticResource FlatButton}" Padding="6,4" MinWidth="0" MinHeight="30"
|
||||
BorderBrush="#E0E0E0" Background="White" ToolTip="Emoji kiezen">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Image x:Name="Face" Width="20" Height="20" RenderOptions.BitmapScalingMode="HighQuality" />
|
||||
<TextBlock Text="" Style="{StaticResource IconText}" FontSize="9" Margin="5,0,1,0" Foreground="#5C5C5C" />
|
||||
</StackPanel>
|
||||
</ToggleButton>
|
||||
<Popup x:Name="PopupPanel" PlacementTarget="{Binding ElementName=Toggle}" Placement="Bottom" StaysOpen="False"
|
||||
AllowsTransparency="True" PopupAnimation="Fade" Opened="Popup_Opened"
|
||||
IsOpen="{Binding IsChecked, ElementName=Toggle, Mode=TwoWay}">
|
||||
<Border Background="White" BorderBrush="#D6D6D6" BorderThickness="1" CornerRadius="6" Padding="8" Margin="0,4,10,10"
|
||||
PreviewKeyDown="Popup_PreviewKeyDown">
|
||||
<Border.Resources>
|
||||
<DataTemplate DataType="{x:Type local:EmojiHeaderRow}">
|
||||
<TextBlock Text="{Binding Title}" FontSize="12" FontWeight="SemiBold" Foreground="{StaticResource SubtleTextBrush}"
|
||||
Margin="4,8,0,4" />
|
||||
</DataTemplate>
|
||||
<DataTemplate DataType="{x:Type local:EmojiCellRow}">
|
||||
<ItemsControl ItemsSource="{Binding Cells}" Focusable="False">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<StackPanel Orientation="Horizontal" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="{x:Type local:EmojiCell}">
|
||||
<Button Style="{StaticResource FlatButton}" Width="36" Height="36" MinWidth="0" MinHeight="0" Padding="0"
|
||||
Margin="0" Click="Cell_Click" MouseEnter="Cell_MouseEnter">
|
||||
<Image Source="{Binding Image}" Width="26" Height="26" RenderOptions.BitmapScalingMode="HighQuality" />
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</DataTemplate>
|
||||
</Border.Resources>
|
||||
<Border.Effect>
|
||||
<DropShadowEffect BlurRadius="10" ShadowDepth="2" Opacity="0.18" />
|
||||
</Border.Effect>
|
||||
<DockPanel Width="352">
|
||||
<!-- Zoeken -->
|
||||
<Grid DockPanel.Dock="Top" Margin="0,0,0,6">
|
||||
<TextBox x:Name="SearchBox" Height="30" Padding="28,0,6,0" VerticalContentAlignment="Center" FontSize="13"
|
||||
BorderBrush="#D6D6D6" TextChanged="SearchBox_TextChanged" />
|
||||
<TextBlock Text="" Style="{StaticResource IconText}" FontSize="12" Foreground="#8A8A8A"
|
||||
HorizontalAlignment="Left" Margin="9,0,0,0" IsHitTestVisible="False" />
|
||||
<TextBlock x:Name="SearchPlaceholder" Text="Zoeken (Nederlands of Engels)" Foreground="#8A8A8A" FontSize="13"
|
||||
VerticalAlignment="Center" Margin="30,0,0,0" IsHitTestVisible="False" />
|
||||
</Grid>
|
||||
|
||||
<!-- Categorieën -->
|
||||
<StackPanel x:Name="TabPanel" DockPanel.Dock="Top" Orientation="Horizontal" Margin="0,0,0,4" />
|
||||
|
||||
<!-- Voorbeeld en huidskleur -->
|
||||
<Border DockPanel.Dock="Bottom" BorderBrush="{StaticResource DividerBrush}" BorderThickness="0,1,0,0" Padding="2,8,0,0"
|
||||
Margin="0,4,0,0">
|
||||
<DockPanel>
|
||||
<StackPanel x:Name="TonePanel" DockPanel.Dock="Right" Orientation="Horizontal" VerticalAlignment="Center" />
|
||||
<Image x:Name="PreviewImage" Width="36" Height="36" RenderOptions.BitmapScalingMode="HighQuality" />
|
||||
<StackPanel VerticalAlignment="Center" Margin="8,0,6,0">
|
||||
<TextBlock x:Name="PreviewName" FontSize="13" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock x:Name="PreviewCode" FontSize="11" Foreground="{StaticResource SubtleTextBrush}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Raster -->
|
||||
<Grid Height="300">
|
||||
<ItemsControl x:Name="EmojiList" Focusable="False"
|
||||
VirtualizingPanel.IsVirtualizing="True" VirtualizingPanel.VirtualizationMode="Recycling"
|
||||
ScrollViewer.CanContentScroll="True">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<VirtualizingStackPanel />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.Template>
|
||||
<ControlTemplate TargetType="ItemsControl">
|
||||
<ScrollViewer x:Name="PART_Scroller" CanContentScroll="True" Focusable="False"
|
||||
HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
|
||||
<ItemsPresenter />
|
||||
</ScrollViewer>
|
||||
</ControlTemplate>
|
||||
</ItemsControl.Template>
|
||||
</ItemsControl>
|
||||
<TextBlock x:Name="NoResultsText" Text="Geen emoji gevonden" Foreground="{StaticResource SubtleTextBrush}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" Visibility="Collapsed" />
|
||||
</Grid>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</Popup>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,395 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
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.Threading;
|
||||
using Kniip.Editor.Emoji;
|
||||
|
||||
namespace Kniip.Editor;
|
||||
|
||||
public sealed record EmojiHeaderRow(string Title);
|
||||
|
||||
public sealed record EmojiCellRow(IReadOnlyList<EmojiCell> Cells);
|
||||
|
||||
public sealed class EmojiCell
|
||||
{
|
||||
private ImageSource? _image;
|
||||
|
||||
public EmojiCell(EmojiInfo info, SkinTone tone)
|
||||
{
|
||||
Info = info;
|
||||
Tone = tone;
|
||||
}
|
||||
|
||||
public EmojiInfo Info { get; }
|
||||
public SkinTone Tone { get; }
|
||||
|
||||
public ImageSource Image => _image ??= EmojiCatalog.Get(Info, Tone);
|
||||
}
|
||||
|
||||
/// <summary>Emoji-kiezer in de stijl van emoji-mart: zoeken, categorieën, huidskleur en recent gebruikt.</summary>
|
||||
public partial class EmojiPickerButton : UserControl
|
||||
{
|
||||
public const int MaxRecent = 18;
|
||||
private const int Columns = 9;
|
||||
private const string RecentId = "Recent";
|
||||
|
||||
private static readonly (SkinTone Tone, Color Color, string Name)[] Tones =
|
||||
{
|
||||
(SkinTone.Default, Color.FromRgb(0xFF, 0xC9, 0x3A), "Standaard"),
|
||||
(SkinTone.Light, Color.FromRgb(0xFA, 0xDC, 0xBC), "Licht"),
|
||||
(SkinTone.MediumLight, Color.FromRgb(0xE0, 0xBB, 0x95), "Middellicht"),
|
||||
(SkinTone.Medium, Color.FromRgb(0xBF, 0x8F, 0x68), "Middel"),
|
||||
(SkinTone.MediumDark, Color.FromRgb(0x9B, 0x64, 0x3D), "Middeldonker"),
|
||||
(SkinTone.Dark, Color.FromRgb(0x59, 0x45, 0x39), "Donker")
|
||||
};
|
||||
|
||||
private static readonly Brush AccentBrush = Annotations.RenderHelpers.Freeze(new SolidColorBrush(Palette.Accent));
|
||||
|
||||
private readonly List<(string Id, int RowIndex)> _sections = new();
|
||||
private readonly Dictionary<string, ToggleButton> _tabs = new();
|
||||
private string _emoji = "1f44d";
|
||||
private SkinTone _tone;
|
||||
private ScrollViewer? _scroller;
|
||||
private bool _syncingTabs;
|
||||
|
||||
/// <summary>Er is een andere emoji gekozen (codepunten zonder huidskleur).</summary>
|
||||
public event Action<string>? EmojiPicked;
|
||||
|
||||
public event Action<SkinTone>? SkinToneChanged;
|
||||
|
||||
public EmojiPickerButton()
|
||||
{
|
||||
InitializeComponent();
|
||||
BuildTones();
|
||||
IsVisibleChanged += (_, _) => UpdateFace();
|
||||
}
|
||||
|
||||
/// <summary>Lijst met recent gekozen emoji, nieuwste eerst; wordt door de eigenaar bewaard.</summary>
|
||||
public List<string>? Recent { get; set; }
|
||||
|
||||
public bool IsOpen => PopupPanel.IsOpen;
|
||||
|
||||
public string Emoji
|
||||
{
|
||||
get => _emoji;
|
||||
set
|
||||
{
|
||||
_emoji = value;
|
||||
UpdateFace();
|
||||
}
|
||||
}
|
||||
|
||||
public SkinTone SkinTone
|
||||
{
|
||||
get => _tone;
|
||||
set
|
||||
{
|
||||
_tone = value;
|
||||
UpdateToneButtons();
|
||||
UpdateFace();
|
||||
}
|
||||
}
|
||||
|
||||
// Het sheet pas laden als de knop echt te zien is.
|
||||
private void UpdateFace()
|
||||
{
|
||||
if (!IsVisible)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Face.Source = EmojiCatalog.Get(_emoji, _tone);
|
||||
if (EmojiCatalog.Find(_emoji) is { } info)
|
||||
{
|
||||
Toggle.ToolTip = $"Emoji kiezen ({info.Name})";
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Popup
|
||||
|
||||
private void Popup_Opened(object? sender, EventArgs e)
|
||||
{
|
||||
BuildTabs();
|
||||
SearchBox.Text = string.Empty;
|
||||
BuildRows();
|
||||
ShowPreview(EmojiCatalog.Find(_emoji));
|
||||
ScrollToRow(0);
|
||||
Dispatcher.BeginInvoke(DispatcherPriority.Input, () => Keyboard.Focus(SearchBox));
|
||||
}
|
||||
|
||||
private void Popup_PreviewKeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key == Key.Escape)
|
||||
{
|
||||
Toggle.IsChecked = false;
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (e.Key == Key.Enter && SearchBox.IsKeyboardFocused
|
||||
&& EmojiList.Items.OfType<EmojiCellRow>().FirstOrDefault()?.Cells.FirstOrDefault() is { } first)
|
||||
{
|
||||
Pick(first.Info);
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void SearchBox_TextChanged(object sender, TextChangedEventArgs e)
|
||||
{
|
||||
SearchPlaceholder.Visibility = SearchBox.Text.Length == 0 ? Visibility.Visible : Visibility.Collapsed;
|
||||
if (PopupPanel.IsOpen)
|
||||
{
|
||||
BuildRows();
|
||||
ScrollToRow(0);
|
||||
}
|
||||
}
|
||||
|
||||
private void Cell_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is FrameworkElement { DataContext: EmojiCell cell })
|
||||
{
|
||||
Pick(cell.Info);
|
||||
}
|
||||
}
|
||||
|
||||
private void Cell_MouseEnter(object sender, MouseEventArgs e)
|
||||
{
|
||||
if (sender is FrameworkElement { DataContext: EmojiCell cell })
|
||||
{
|
||||
ShowPreview(cell.Info);
|
||||
}
|
||||
}
|
||||
|
||||
private void Pick(EmojiInfo info)
|
||||
{
|
||||
if (Recent != null)
|
||||
{
|
||||
Recent.Remove(info.Unified);
|
||||
Recent.Insert(0, info.Unified);
|
||||
if (Recent.Count > MaxRecent)
|
||||
{
|
||||
Recent.RemoveRange(MaxRecent, Recent.Count - MaxRecent);
|
||||
}
|
||||
}
|
||||
|
||||
Toggle.IsChecked = false;
|
||||
Emoji = info.Unified;
|
||||
EmojiPicked?.Invoke(info.Unified);
|
||||
}
|
||||
|
||||
private void ShowPreview(EmojiInfo? info)
|
||||
{
|
||||
if (info == null)
|
||||
{
|
||||
PreviewImage.Source = null;
|
||||
PreviewName.Text = PreviewCode.Text = string.Empty;
|
||||
return;
|
||||
}
|
||||
|
||||
PreviewImage.Source = EmojiCatalog.Get(info, _tone);
|
||||
PreviewName.Text = info.Name.Length > 0 ? char.ToUpper(info.Name[0]) + info.Name[1..] : info.Name;
|
||||
PreviewCode.Text = $":{info.ShortName}:";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Raster
|
||||
|
||||
private IEnumerable<(string Id, string Title, IEnumerable<EmojiInfo> Items)> Sections()
|
||||
{
|
||||
string query = SearchBox.Text.Trim();
|
||||
if (query.Length > 0)
|
||||
{
|
||||
yield return ("Search", "Zoekresultaten", EmojiCatalog.Search(query));
|
||||
yield break;
|
||||
}
|
||||
|
||||
List<EmojiInfo> recent = RecentEmoji();
|
||||
if (recent.Count > 0)
|
||||
{
|
||||
yield return (RecentId, "Recent gebruikt", recent);
|
||||
}
|
||||
|
||||
foreach (IGrouping<string, EmojiInfo> group in EmojiCatalog.All.GroupBy(e => e.Category))
|
||||
{
|
||||
if (EmojiCatalog.Categories.FirstOrDefault(c => c.Id == group.Key) is { } category)
|
||||
{
|
||||
yield return (category.Id, category.Title, group);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private List<EmojiInfo> RecentEmoji() =>
|
||||
(Recent ?? new List<string>()).Select(EmojiCatalog.Find).OfType<EmojiInfo>().ToList();
|
||||
|
||||
private void BuildRows()
|
||||
{
|
||||
var rows = new List<object>();
|
||||
_sections.Clear();
|
||||
foreach ((string id, string title, IEnumerable<EmojiInfo> items) in Sections())
|
||||
{
|
||||
List<EmojiCell> cells = items.Select(info => new EmojiCell(info, _tone)).ToList();
|
||||
if (cells.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_sections.Add((id, rows.Count));
|
||||
rows.Add(new EmojiHeaderRow(title));
|
||||
for (int i = 0; i < cells.Count; i += Columns)
|
||||
{
|
||||
rows.Add(new EmojiCellRow(cells.GetRange(i, Math.Min(Columns, cells.Count - i))));
|
||||
}
|
||||
}
|
||||
|
||||
EmojiList.ItemsSource = rows;
|
||||
NoResultsText.Visibility = rows.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
|
||||
UpdateActiveTab(0);
|
||||
}
|
||||
|
||||
private ScrollViewer? Scroller
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_scroller == null)
|
||||
{
|
||||
EmojiList.ApplyTemplate();
|
||||
_scroller = EmojiList.Template.FindName("PART_Scroller", EmojiList) as ScrollViewer;
|
||||
if (_scroller != null)
|
||||
{
|
||||
_scroller.ScrollChanged += (_, _) => UpdateActiveTab((int)Math.Floor(_scroller.VerticalOffset));
|
||||
}
|
||||
}
|
||||
|
||||
return _scroller;
|
||||
}
|
||||
}
|
||||
|
||||
// Met CanContentScroll schuift de ScrollViewer per rij, dus de offset is een rij-index.
|
||||
private void ScrollToRow(int row) => Scroller?.ScrollToVerticalOffset(row);
|
||||
|
||||
// ---------------------------------------------------------------- Tabs en huidskleur
|
||||
|
||||
private void BuildTabs()
|
||||
{
|
||||
TabPanel.Children.Clear();
|
||||
_tabs.Clear();
|
||||
if (RecentEmoji().Count > 0)
|
||||
{
|
||||
AddTab(RecentId, "Recent gebruikt", new TextBlock { Text = "\uE81C", Style = (Style)FindResource("IconText") });
|
||||
}
|
||||
|
||||
foreach (EmojiCategory category in EmojiCatalog.Categories)
|
||||
{
|
||||
var icon = new Image { Source = EmojiCatalog.Get(category.IconEmoji, SkinTone.Default), Width = 18, Height = 18 };
|
||||
RenderOptions.SetBitmapScalingMode(icon, BitmapScalingMode.HighQuality);
|
||||
AddTab(category.Id, category.Title, icon);
|
||||
}
|
||||
}
|
||||
|
||||
private void AddTab(string id, string title, UIElement content)
|
||||
{
|
||||
var tab = new ToggleButton
|
||||
{
|
||||
Style = (Style)FindResource("ToolToggle"),
|
||||
MinWidth = 0,
|
||||
MinHeight = 0,
|
||||
Width = 35,
|
||||
Height = 32,
|
||||
Padding = new Thickness(2),
|
||||
Margin = new Thickness(0),
|
||||
ToolTip = title,
|
||||
Content = content
|
||||
};
|
||||
tab.Click += (_, _) =>
|
||||
{
|
||||
if (SearchBox.Text.Length > 0)
|
||||
{
|
||||
SearchBox.Text = string.Empty;
|
||||
}
|
||||
|
||||
int index = _sections.FindIndex(s => s.Id == id);
|
||||
if (index >= 0)
|
||||
{
|
||||
ScrollToRow(_sections[index].RowIndex);
|
||||
}
|
||||
|
||||
UpdateActiveTab(index >= 0 ? _sections[index].RowIndex : 0);
|
||||
};
|
||||
_tabs[id] = tab;
|
||||
TabPanel.Children.Add(tab);
|
||||
}
|
||||
|
||||
private void UpdateActiveTab(int firstVisibleRow)
|
||||
{
|
||||
if (_syncingTabs)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_syncingTabs = true;
|
||||
string? active = _sections.LastOrDefault(s => s.RowIndex <= firstVisibleRow).Id;
|
||||
foreach ((string id, ToggleButton tab) in _tabs)
|
||||
{
|
||||
tab.IsChecked = id == active;
|
||||
}
|
||||
|
||||
_syncingTabs = false;
|
||||
}
|
||||
|
||||
private void BuildTones()
|
||||
{
|
||||
foreach ((SkinTone tone, Color color, string name) in Tones)
|
||||
{
|
||||
var dot = new Border
|
||||
{
|
||||
Width = 16,
|
||||
Height = 16,
|
||||
CornerRadius = new CornerRadius(8),
|
||||
Background = new SolidColorBrush(color),
|
||||
BorderBrush = new SolidColorBrush(Color.FromArgb(40, 0, 0, 0)),
|
||||
BorderThickness = new Thickness(1)
|
||||
};
|
||||
var button = new Button
|
||||
{
|
||||
Style = (Style)FindResource("FlatButton"),
|
||||
MinWidth = 0,
|
||||
MinHeight = 0,
|
||||
Padding = new Thickness(3),
|
||||
Margin = new Thickness(0),
|
||||
Content = dot,
|
||||
Tag = tone,
|
||||
ToolTip = $"Huidskleur: {name.ToLowerInvariant()}"
|
||||
};
|
||||
button.Click += (_, _) => SetTone(tone);
|
||||
TonePanel.Children.Add(button);
|
||||
}
|
||||
|
||||
UpdateToneButtons();
|
||||
}
|
||||
|
||||
private void SetTone(SkinTone tone)
|
||||
{
|
||||
if (tone == _tone)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SkinTone = tone;
|
||||
double offset = Scroller?.VerticalOffset ?? 0;
|
||||
BuildRows();
|
||||
Scroller?.ScrollToVerticalOffset(offset);
|
||||
ShowPreview(EmojiCatalog.Find(_emoji));
|
||||
SkinToneChanged?.Invoke(tone);
|
||||
}
|
||||
|
||||
private void UpdateToneButtons()
|
||||
{
|
||||
foreach (Button button in TonePanel.Children.OfType<Button>())
|
||||
{
|
||||
bool selected = button.Tag is SkinTone tone && tone == _tone;
|
||||
button.BorderBrush = selected ? AccentBrush : Brushes.Transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
using System;
|
||||
using System.Windows;
|
||||
using System.Windows.Media.Imaging;
|
||||
using Kniip.Infrastructure;
|
||||
|
||||
namespace Kniip.Editor;
|
||||
|
||||
internal static class ImageEffects
|
||||
{
|
||||
public static BitmapSource Pixelate(EditorDocument doc, Int32Rect area, int blockSize)
|
||||
{
|
||||
byte[] src = doc.Pixels;
|
||||
int srcStride = doc.Width * 4;
|
||||
var dst = new byte[area.Width * area.Height * 4];
|
||||
int dstStride = area.Width * 4;
|
||||
|
||||
for (int by = 0; by < area.Height; by += blockSize)
|
||||
{
|
||||
int bh = Math.Min(blockSize, area.Height - by);
|
||||
for (int bx = 0; bx < area.Width; bx += blockSize)
|
||||
{
|
||||
int bw = Math.Min(blockSize, area.Width - bx);
|
||||
long b = 0, g = 0, r = 0;
|
||||
for (int y = 0; y < bh; y++)
|
||||
{
|
||||
int row = (area.Y + by + y) * srcStride + (area.X + bx) * 4;
|
||||
for (int x = 0; x < bw; x++)
|
||||
{
|
||||
int i = row + x * 4;
|
||||
b += src[i];
|
||||
g += src[i + 1];
|
||||
r += src[i + 2];
|
||||
}
|
||||
}
|
||||
|
||||
int count = bw * bh;
|
||||
byte ab = (byte)(b / count), ag = (byte)(g / count), ar = (byte)(r / count);
|
||||
for (int y = 0; y < bh; y++)
|
||||
{
|
||||
int row = (by + y) * dstStride + bx * 4;
|
||||
for (int x = 0; x < bw; x++)
|
||||
{
|
||||
int i = row + x * 4;
|
||||
dst[i] = ab;
|
||||
dst[i + 1] = ag;
|
||||
dst[i + 2] = ar;
|
||||
dst[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return PixelUtil.CreateBitmap(dst, area.Width, area.Height);
|
||||
}
|
||||
|
||||
/// <summary>Drie keer een box blur benadert een Gaussische vervaging.</summary>
|
||||
public static BitmapSource Blur(EditorDocument doc, Int32Rect area, int radius)
|
||||
{
|
||||
// Met marge rondom vervagen, zodat de randen van het gebied natuurlijk overlopen.
|
||||
int margin = radius * 3;
|
||||
int left = Math.Max(0, area.X - margin);
|
||||
int top = Math.Max(0, area.Y - margin);
|
||||
int right = Math.Min(doc.Width, area.X + area.Width + margin);
|
||||
int bottom = Math.Min(doc.Height, area.Y + area.Height + margin);
|
||||
int w = right - left;
|
||||
int h = bottom - top;
|
||||
|
||||
var buffer = new byte[w * h * 4];
|
||||
int srcStride = doc.Width * 4;
|
||||
for (int y = 0; y < h; y++)
|
||||
{
|
||||
Buffer.BlockCopy(doc.Pixels, (top + y) * srcStride + left * 4, buffer, y * w * 4, w * 4);
|
||||
}
|
||||
|
||||
var temp = new byte[buffer.Length];
|
||||
for (int pass = 0; pass < 3; pass++)
|
||||
{
|
||||
BoxBlurHorizontal(buffer, temp, w, h, radius);
|
||||
BoxBlurVertical(temp, buffer, w, h, radius);
|
||||
}
|
||||
|
||||
var dst = new byte[area.Width * area.Height * 4];
|
||||
int offsetX = area.X - left;
|
||||
int offsetY = area.Y - top;
|
||||
for (int y = 0; y < area.Height; y++)
|
||||
{
|
||||
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)
|
||||
{
|
||||
dst[i] = 255;
|
||||
}
|
||||
|
||||
return PixelUtil.CreateBitmap(dst, area.Width, area.Height);
|
||||
}
|
||||
|
||||
private static void BoxBlurHorizontal(byte[] src, byte[] dst, int w, int h, int r)
|
||||
{
|
||||
int window = 2 * r + 1;
|
||||
for (int y = 0; y < h; y++)
|
||||
{
|
||||
int row = y * w * 4;
|
||||
for (int c = 0; c < 3; c++)
|
||||
{
|
||||
int sum = 0;
|
||||
for (int i = -r; i <= r; i++)
|
||||
{
|
||||
sum += src[row + Math.Clamp(i, 0, w - 1) * 4 + c];
|
||||
}
|
||||
|
||||
for (int x = 0; x < w; x++)
|
||||
{
|
||||
dst[row + x * 4 + c] = (byte)(sum / window);
|
||||
int add = Math.Min(x + r + 1, w - 1);
|
||||
int remove = Math.Max(x - r, 0);
|
||||
sum += src[row + add * 4 + c] - src[row + remove * 4 + c];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void BoxBlurVertical(byte[] src, byte[] dst, int w, int h, int r)
|
||||
{
|
||||
int window = 2 * r + 1;
|
||||
int stride = w * 4;
|
||||
for (int x = 0; x < w; x++)
|
||||
{
|
||||
int col = x * 4;
|
||||
for (int c = 0; c < 3; c++)
|
||||
{
|
||||
int sum = 0;
|
||||
for (int i = -r; i <= r; i++)
|
||||
{
|
||||
sum += src[Math.Clamp(i, 0, h - 1) * stride + col + c];
|
||||
}
|
||||
|
||||
for (int y = 0; y < h; y++)
|
||||
{
|
||||
dst[y * stride + col + c] = (byte)(sum / window);
|
||||
int add = Math.Min(y + r + 1, h - 1);
|
||||
int remove = Math.Max(y - r, 0);
|
||||
sum += src[add * stride + col + c] - src[remove * stride + col + c];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using System;
|
||||
using System.Collections.Specialized;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using Kniip.Capture;
|
||||
|
||||
namespace Kniip.Editor;
|
||||
|
||||
internal static class ImageExporter
|
||||
{
|
||||
public static void Save(BitmapSource image, string path)
|
||||
{
|
||||
string ext = Path.GetExtension(path).ToLowerInvariant();
|
||||
BitmapEncoder encoder = ext is ".jpg" or ".jpeg"
|
||||
? new JpegBitmapEncoder { QualityLevel = 92 }
|
||||
: new PngBitmapEncoder();
|
||||
|
||||
BitmapSource frame = encoder is JpegBitmapEncoder
|
||||
? new FormatConvertedBitmap(image, PixelFormats.Bgr24, null, 0)
|
||||
: image;
|
||||
encoder.Frames.Add(BitmapFrame.Create(frame));
|
||||
|
||||
using var stream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None);
|
||||
encoder.Save(stream);
|
||||
}
|
||||
|
||||
public static MemoryStream EncodePng(BitmapSource image)
|
||||
{
|
||||
var encoder = new PngBitmapEncoder();
|
||||
encoder.Frames.Add(BitmapFrame.Create(image));
|
||||
var stream = new MemoryStream();
|
||||
encoder.Save(stream);
|
||||
stream.Position = 0;
|
||||
return stream;
|
||||
}
|
||||
}
|
||||
|
||||
internal static class ClipboardWriter
|
||||
{
|
||||
/// <summary>
|
||||
/// Zet de afbeelding als bitmap én als PNG op het klembord, met een markering zodat
|
||||
/// de eigen klembordbewaking dit niet als nieuw knipsel ziet.
|
||||
/// Met een pad staat ook het bestand zelf op het klembord.
|
||||
/// </summary>
|
||||
public static bool SetImage(BitmapSource image, string? filePath = null)
|
||||
{
|
||||
var data = new DataObject();
|
||||
data.SetImage(image);
|
||||
data.SetData("PNG", ImageExporter.EncodePng(image), false);
|
||||
data.SetData(ClipboardImageReader.ExportMarkerFormat, "1");
|
||||
if (!string.IsNullOrEmpty(filePath))
|
||||
{
|
||||
data.SetFileDropList(new StringCollection { filePath });
|
||||
}
|
||||
|
||||
return TrySet(data);
|
||||
}
|
||||
|
||||
/// <summary>Zet een opgeslagen bestand op het klembord, zodat het geplakt kan worden in Verkenner, Teams of Outlook.</summary>
|
||||
public static bool SetFile(string path)
|
||||
{
|
||||
var data = new DataObject();
|
||||
data.SetFileDropList(new StringCollection { path });
|
||||
return TrySet(data);
|
||||
}
|
||||
|
||||
public static bool SetText(string text)
|
||||
{
|
||||
var data = new DataObject();
|
||||
data.SetText(text);
|
||||
return TrySet(data);
|
||||
}
|
||||
|
||||
private static bool TrySet(DataObject data)
|
||||
{
|
||||
for (int attempt = 0; attempt < 5; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
Clipboard.SetDataObject(data, true);
|
||||
return true;
|
||||
}
|
||||
catch (COMException)
|
||||
{
|
||||
Thread.Sleep(60);
|
||||
}
|
||||
catch (ExternalException)
|
||||
{
|
||||
Thread.Sleep(60);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
using System;
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace Kniip.Editor;
|
||||
|
||||
public enum ToolKind
|
||||
{
|
||||
Select,
|
||||
Rectangle,
|
||||
Ellipse,
|
||||
Line,
|
||||
Arrow,
|
||||
Text,
|
||||
Step,
|
||||
Cursor,
|
||||
Emoji,
|
||||
Highlight,
|
||||
Blur,
|
||||
Crop
|
||||
}
|
||||
|
||||
public enum LineDash { Solid, Dash, Dot }
|
||||
|
||||
public enum StepNumbering { Numbers, LowerAlpha, UpperAlpha, Roman }
|
||||
|
||||
public enum BlurMode { Pixelate, Blur }
|
||||
|
||||
public enum CursorKind { Arrow, Hand, Click }
|
||||
|
||||
public enum SkinTone { Default, Light, MediumLight, Medium, MediumDark, Dark }
|
||||
|
||||
[Flags]
|
||||
public enum StyleProps
|
||||
{
|
||||
None = 0,
|
||||
Stroke = 1,
|
||||
Fill = 2,
|
||||
Foreground = 4,
|
||||
Thickness = 8,
|
||||
Dash = 16,
|
||||
Corner = 32,
|
||||
Shadow = 64,
|
||||
Font = 128,
|
||||
Numbering = 256,
|
||||
Size = 512,
|
||||
CursorKind = 1024,
|
||||
Blur = 2048,
|
||||
Straight = 4096,
|
||||
Emoji = 8192
|
||||
}
|
||||
|
||||
public sealed class StyleSet
|
||||
{
|
||||
public Color Stroke { get; set; } = Palette.Red;
|
||||
public Color Fill { get; set; } = Colors.Transparent;
|
||||
public Color Foreground { get; set; } = Colors.White;
|
||||
public double Thickness { get; set; } = 3;
|
||||
public LineDash Dash { get; set; } = LineDash.Solid;
|
||||
public double CornerRadius { get; set; }
|
||||
public bool Shadow { get; set; }
|
||||
public double FontSize { get; set; } = 20;
|
||||
public bool Bold { get; set; }
|
||||
public StepNumbering StepNumbering { get; set; } = StepNumbering.Numbers;
|
||||
public double Size { get; set; } = 32;
|
||||
public BlurMode BlurMode { get; set; } = BlurMode.Pixelate;
|
||||
public int BlurStrength { get; set; } = 6;
|
||||
public CursorKind CursorKind { get; set; } = CursorKind.Arrow;
|
||||
|
||||
/// <summary>Unicode-codepunten van de emoji zonder huidskleur, zoals "1f44d".</summary>
|
||||
public string Emoji { get; set; } = "1f44d";
|
||||
public SkinTone SkinTone { get; set; } = SkinTone.Default;
|
||||
|
||||
public StyleSet Clone() => (StyleSet)MemberwiseClone();
|
||||
|
||||
public static StyleSet DefaultFor(ToolKind tool) => tool switch
|
||||
{
|
||||
ToolKind.Line or ToolKind.Arrow => new StyleSet { Thickness = 4, Shadow = true },
|
||||
ToolKind.Text => new StyleSet
|
||||
{
|
||||
Stroke = Palette.Red,
|
||||
Fill = Colors.White,
|
||||
Foreground = Palette.Ink,
|
||||
Thickness = 2,
|
||||
CornerRadius = 4,
|
||||
FontSize = 20,
|
||||
Shadow = true
|
||||
},
|
||||
ToolKind.Step => new StyleSet { Fill = Palette.Red, Foreground = Colors.White, Size = 32, Shadow = true },
|
||||
ToolKind.Cursor => new StyleSet { Size = 32, Shadow = true },
|
||||
ToolKind.Emoji => new StyleSet { Size = 48 },
|
||||
ToolKind.Highlight => new StyleSet { Stroke = Palette.Yellow, Thickness = 20 },
|
||||
ToolKind.Blur => new StyleSet { BlurMode = BlurMode.Pixelate, BlurStrength = 6 },
|
||||
_ => new StyleSet()
|
||||
};
|
||||
|
||||
public static StyleProps PropsFor(ToolKind tool) => tool switch
|
||||
{
|
||||
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.Line or ToolKind.Arrow => StyleProps.Stroke | StyleProps.Thickness | StyleProps.Dash | StyleProps.Shadow,
|
||||
ToolKind.Text => StyleProps.Stroke | StyleProps.Fill | StyleProps.Foreground | StyleProps.Thickness | StyleProps.Dash
|
||||
| StyleProps.Corner | StyleProps.Shadow | StyleProps.Font,
|
||||
ToolKind.Step => StyleProps.Fill | StyleProps.Foreground | StyleProps.Numbering | StyleProps.Size | StyleProps.Shadow,
|
||||
ToolKind.Cursor => StyleProps.CursorKind | StyleProps.Size | StyleProps.Shadow,
|
||||
ToolKind.Emoji => StyleProps.Emoji | StyleProps.Size | StyleProps.Shadow,
|
||||
ToolKind.Highlight => StyleProps.Stroke | StyleProps.Thickness | StyleProps.Straight,
|
||||
ToolKind.Blur => StyleProps.Blur,
|
||||
_ => StyleProps.None
|
||||
};
|
||||
}
|
||||
|
||||
public static class Palette
|
||||
{
|
||||
public static readonly Color Red = Color.FromRgb(0xE8, 0x11, 0x23);
|
||||
public static readonly Color Yellow = Color.FromRgb(0xFF, 0xE6, 0x00);
|
||||
public static readonly Color Ink = Color.FromRgb(0x1A, 0x1A, 0x1A);
|
||||
public static readonly Color Accent = Color.FromRgb(0x00, 0x67, 0xC0);
|
||||
|
||||
public static readonly Color[] Swatches =
|
||||
{
|
||||
Red,
|
||||
Color.FromRgb(0xF7, 0x63, 0x0C),
|
||||
Color.FromRgb(0xFF, 0xB9, 0x00),
|
||||
Yellow,
|
||||
Color.FromRgb(0x16, 0xC6, 0x0C),
|
||||
Color.FromRgb(0x00, 0xB7, 0xC3),
|
||||
Color.FromRgb(0x00, 0x78, 0xD4),
|
||||
Color.FromRgb(0x87, 0x64, 0xB8),
|
||||
Color.FromRgb(0xE3, 0x00, 0x8C),
|
||||
Colors.Black,
|
||||
Color.FromRgb(0x50, 0x50, 0x50),
|
||||
Color.FromRgb(0xA0, 0xA0, 0xA0),
|
||||
Colors.White
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Controls.Primitives;
|
||||
using System.Windows.Documents;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace Kniip.Editor;
|
||||
|
||||
/// <summary>
|
||||
/// Werkbalk die zijn items op één regel zet, met <see cref="ToolbarSpacer"/>s die de overige ruimte opvullen;
|
||||
/// bij twee spacers komt de groep ertussen in het midden. Past het niet, dan begint elke groep op een nieuwe
|
||||
/// regel (begin, midden of eind uitgelijnd) en lopen te lange groepen door. Als de werkbalk niet vergrendeld is, kun je de items verslepen om de volgorde te veranderen.
|
||||
/// </summary>
|
||||
public sealed class ToolbarPanel : Panel
|
||||
{
|
||||
public static readonly DependencyProperty OrientationProperty = DependencyProperty.Register(
|
||||
nameof(Orientation), typeof(Orientation), typeof(ToolbarPanel),
|
||||
new FrameworkPropertyMetadata(Orientation.Horizontal, FrameworkPropertyMetadataOptions.AffectsMeasure, OnOrientationChanged));
|
||||
|
||||
public static readonly DependencyProperty IsLockedProperty = DependencyProperty.Register(
|
||||
nameof(IsLocked), typeof(bool), typeof(ToolbarPanel), new PropertyMetadata(true, OnIsLockedChanged));
|
||||
|
||||
public static readonly DependencyProperty ItemIdProperty = DependencyProperty.RegisterAttached(
|
||||
"ItemId", typeof(string), typeof(ToolbarPanel), new PropertyMetadata(null));
|
||||
|
||||
public static readonly DependencyProperty TitleProperty = DependencyProperty.RegisterAttached(
|
||||
"Title", typeof(string), typeof(ToolbarPanel), new PropertyMetadata(null));
|
||||
|
||||
private const double DragThreshold = 4;
|
||||
|
||||
private readonly List<ToolbarLine> _lines = new();
|
||||
private readonly HashSet<UIElement> _skipped = new();
|
||||
|
||||
private UIElement? _pressedItem;
|
||||
private Point _pressPoint;
|
||||
private bool _dragging;
|
||||
private int _insertIndex = -1;
|
||||
private InsertionAdorner? _adorner;
|
||||
|
||||
public Orientation Orientation
|
||||
{
|
||||
get => (Orientation)GetValue(OrientationProperty);
|
||||
set => SetValue(OrientationProperty, value);
|
||||
}
|
||||
|
||||
public bool IsLocked
|
||||
{
|
||||
get => (bool)GetValue(IsLockedProperty);
|
||||
set => SetValue(IsLockedProperty, value);
|
||||
}
|
||||
|
||||
public static string? GetItemId(DependencyObject element) => (string?)element.GetValue(ItemIdProperty);
|
||||
|
||||
public static void SetItemId(DependencyObject element, string? value) => element.SetValue(ItemIdProperty, value);
|
||||
|
||||
public static string? GetTitle(DependencyObject element) => (string?)element.GetValue(TitleProperty);
|
||||
|
||||
public static void SetTitle(DependencyObject element, string? value) => element.SetValue(TitleProperty, value);
|
||||
|
||||
/// <summary>Na het verslepen van een item.</summary>
|
||||
public event EventHandler? OrderChanged;
|
||||
|
||||
/// <summary>Lengte (breedte of hoogte) die de zichtbare items samen op één regel nodig hebben.</summary>
|
||||
public double SingleLineLength { get; private set; }
|
||||
|
||||
public IEnumerable<UIElement> Items => InternalChildren.Cast<UIElement>();
|
||||
|
||||
private bool Horizontal => Orientation == Orientation.Horizontal;
|
||||
|
||||
private double Main(Size size) => Horizontal ? size.Width : size.Height;
|
||||
|
||||
private double Cross(Size size) => Horizontal ? size.Height : size.Width;
|
||||
|
||||
private static void OnOrientationChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
foreach (UIElement child in ((ToolbarPanel)d).InternalChildren)
|
||||
{
|
||||
child.InvalidateMeasure();
|
||||
}
|
||||
}
|
||||
|
||||
private static void OnIsLockedChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
var panel = (ToolbarPanel)d;
|
||||
panel.Cursor = panel.IsLocked ? null : Cursors.SizeAll;
|
||||
panel.Background = panel.IsLocked ? null : Brushes.Transparent;
|
||||
panel.EndDrag(commit: false);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Opmaak
|
||||
|
||||
protected override Size MeasureOverride(Size availableSize)
|
||||
{
|
||||
var unlimited = new Size(double.PositiveInfinity, double.PositiveInfinity);
|
||||
foreach (UIElement child in InternalChildren)
|
||||
{
|
||||
child.Measure(unlimited);
|
||||
}
|
||||
|
||||
_lines.Clear();
|
||||
_skipped.Clear();
|
||||
List<UIElement> items = WithoutLooseSeparators(InternalChildren.Cast<UIElement>()
|
||||
.Where(c => c.Visibility != Visibility.Collapsed));
|
||||
SingleLineLength = items.Sum(c => Main(c.DesiredSize));
|
||||
|
||||
double limit = Main(availableSize);
|
||||
if (SingleLineLength <= limit)
|
||||
{
|
||||
var line = new ToolbarLine(align: 0);
|
||||
items.ForEach(c => line.Add(c, Main(c.DesiredSize), Cross(c.DesiredSize)));
|
||||
_lines.Add(line);
|
||||
}
|
||||
else
|
||||
{
|
||||
WrapInto(items, limit);
|
||||
}
|
||||
|
||||
double main = _lines.Count == 0 ? 0 : _lines.Max(l => l.Main);
|
||||
double cross = _lines.Sum(l => l.Cross);
|
||||
return Horizontal ? new Size(main, cross) : new Size(cross, main);
|
||||
}
|
||||
|
||||
private void WrapInto(List<UIElement> items, double limit)
|
||||
{
|
||||
ToolbarLine? line = null;
|
||||
bool hasMiddle = items.Count(i => i is ToolbarSpacer) >= 2;
|
||||
int segment = 0;
|
||||
foreach (UIElement child in items)
|
||||
{
|
||||
if (child is ToolbarSpacer)
|
||||
{
|
||||
_skipped.Add(child);
|
||||
segment++;
|
||||
line = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
double length = Main(child.DesiredSize);
|
||||
if (line != null && line.Items.Count > 0 && line.Main + length > limit)
|
||||
{
|
||||
line.TrimTrailingSeparator(_skipped);
|
||||
line = null;
|
||||
}
|
||||
|
||||
if (line == null)
|
||||
{
|
||||
if (child is ToolbarSeparator)
|
||||
{
|
||||
_skipped.Add(child);
|
||||
continue;
|
||||
}
|
||||
|
||||
line = new ToolbarLine(segment == 0 ? 0 : hasMiddle && segment == 1 ? 0.5 : 1);
|
||||
_lines.Add(line);
|
||||
}
|
||||
|
||||
line.Add(child, length, Cross(child.DesiredSize));
|
||||
}
|
||||
|
||||
line?.TrimTrailingSeparator(_skipped);
|
||||
}
|
||||
|
||||
/// <summary>Scheidingslijnen aan het begin of eind, naast de spacer of direct na elkaar weglaten.</summary>
|
||||
private List<UIElement> WithoutLooseSeparators(IEnumerable<UIElement> source)
|
||||
{
|
||||
var result = new List<UIElement>();
|
||||
foreach (UIElement child in source)
|
||||
{
|
||||
bool loose = child is ToolbarSeparator
|
||||
&& (result.Count == 0 || result[^1] is ToolbarSeparator or ToolbarSpacer);
|
||||
if (child is ToolbarSpacer && result.Count > 0 && result[^1] is ToolbarSeparator)
|
||||
{
|
||||
_skipped.Add(result[^1]);
|
||||
result.RemoveAt(result.Count - 1);
|
||||
}
|
||||
|
||||
if (loose)
|
||||
{
|
||||
_skipped.Add(child);
|
||||
}
|
||||
else
|
||||
{
|
||||
result.Add(child);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.Count > 0 && result[^1] is ToolbarSeparator last)
|
||||
{
|
||||
_skipped.Add(last);
|
||||
result.RemoveAt(result.Count - 1);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
protected override Size ArrangeOverride(Size finalSize)
|
||||
{
|
||||
foreach (UIElement child in _skipped)
|
||||
{
|
||||
child.Arrange(new Rect());
|
||||
}
|
||||
|
||||
double total = Main(finalSize);
|
||||
double crossPos = 0;
|
||||
foreach (ToolbarLine line in _lines)
|
||||
{
|
||||
double extra = Math.Max(0, total - line.Main);
|
||||
Queue<double> gaps = SpacerGaps(line, total, extra);
|
||||
double offset = gaps.Count == 0 ? extra * line.Align : 0;
|
||||
foreach (UIElement child in line.Items)
|
||||
{
|
||||
double length = Main(child.DesiredSize) + (child is ToolbarSpacer ? gaps.Dequeue() : 0);
|
||||
child.Arrange(Horizontal
|
||||
? new Rect(offset, crossPos, length, line.Cross)
|
||||
: new Rect(crossPos, offset, line.Cross, length));
|
||||
offset += length;
|
||||
}
|
||||
|
||||
crossPos += line.Cross;
|
||||
}
|
||||
|
||||
return finalSize;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ruimte per spacer. Met twee spacers staat de middelste groep precies in het midden van de werkbalk,
|
||||
/// tenzij hij dan over de groep links of rechts heen zou vallen.
|
||||
/// </summary>
|
||||
private Queue<double> SpacerGaps(ToolbarLine line, double total, double extra)
|
||||
{
|
||||
List<int> spacers = line.Items.Select((item, index) => (item, index))
|
||||
.Where(x => x.item is ToolbarSpacer).Select(x => x.index).ToList();
|
||||
if (spacers.Count != 2)
|
||||
{
|
||||
return new Queue<double>(spacers.Select(_ => extra / spacers.Count));
|
||||
}
|
||||
|
||||
double Length(int from, int to) => line.Items.Skip(from).Take(to - from).Sum(i => Main(i.DesiredSize));
|
||||
double left = Length(0, spacers[0]);
|
||||
double middle = Length(spacers[0] + 1, spacers[1]);
|
||||
double right = Length(spacers[1] + 1, line.Items.Count);
|
||||
double start = Math.Clamp((total - middle) / 2, left, Math.Max(left, total - right - middle));
|
||||
return new Queue<double>(new[] { start - left, Math.Max(0, total - right - middle - start) });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Verslepen
|
||||
|
||||
protected override void OnPreviewMouseLeftButtonDown(MouseButtonEventArgs e)
|
||||
{
|
||||
base.OnPreviewMouseLeftButtonDown(e);
|
||||
if (IsLocked)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Ontgrendeld: knoppen reageren niet op klikken, je pakt ze alleen op.
|
||||
e.Handled = true;
|
||||
Point p = e.GetPosition(this);
|
||||
UIElement? item = ItemAt(p);
|
||||
if (item == null || item is ToolbarSpacer)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_pressedItem = item;
|
||||
_pressPoint = p;
|
||||
CaptureMouse();
|
||||
}
|
||||
|
||||
protected override void OnPreviewMouseMove(MouseEventArgs e)
|
||||
{
|
||||
base.OnPreviewMouseMove(e);
|
||||
if (_pressedItem == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Point p = e.GetPosition(this);
|
||||
if (!_dragging)
|
||||
{
|
||||
if (Math.Abs(p.X - _pressPoint.X) < DragThreshold && Math.Abs(p.Y - _pressPoint.Y) < DragThreshold)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_dragging = true;
|
||||
_pressedItem.Opacity = 0.4;
|
||||
if (AdornerLayer.GetAdornerLayer(this) is { } layer)
|
||||
{
|
||||
_adorner = new InsertionAdorner(this);
|
||||
layer.Add(_adorner);
|
||||
}
|
||||
}
|
||||
|
||||
_insertIndex = FindInsertIndex(p, out Rect marker);
|
||||
_adorner?.Show(_insertIndex < 0 ? Rect.Empty : marker);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
protected override void OnPreviewMouseLeftButtonUp(MouseButtonEventArgs e)
|
||||
{
|
||||
base.OnPreviewMouseLeftButtonUp(e);
|
||||
if (_pressedItem != null)
|
||||
{
|
||||
EndDrag(commit: _dragging);
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnLostMouseCapture(MouseEventArgs e)
|
||||
{
|
||||
base.OnLostMouseCapture(e);
|
||||
EndDrag(commit: false);
|
||||
}
|
||||
|
||||
private void EndDrag(bool commit)
|
||||
{
|
||||
UIElement? item = _pressedItem;
|
||||
int target = _insertIndex;
|
||||
_pressedItem = null;
|
||||
_insertIndex = -1;
|
||||
_dragging = false;
|
||||
if (_adorner != null)
|
||||
{
|
||||
AdornerLayer.GetAdornerLayer(this)?.Remove(_adorner);
|
||||
_adorner = null;
|
||||
}
|
||||
|
||||
if (item == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
item.Opacity = 1;
|
||||
if (IsMouseCaptured)
|
||||
{
|
||||
ReleaseMouseCapture();
|
||||
}
|
||||
|
||||
if (!commit || target < 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int from = InternalChildren.IndexOf(item);
|
||||
if (target > from)
|
||||
{
|
||||
target--;
|
||||
}
|
||||
|
||||
if (target == from)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
InternalChildren.RemoveAt(from);
|
||||
InternalChildren.Insert(target, item);
|
||||
OrderChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
// Op positie in plaats van via het klikdoel: uitgeschakelde knoppen krijgen zelf geen muisinvoer.
|
||||
private UIElement? ItemAt(Point p) =>
|
||||
InternalChildren.Cast<UIElement>().FirstOrDefault(child =>
|
||||
child.Visibility != Visibility.Collapsed && !_skipped.Contains(child)
|
||||
&& LayoutInformation.GetLayoutSlot((FrameworkElement)child).Contains(p));
|
||||
|
||||
/// <summary>Plek in de lijst waar het gesleepte item terechtkomt, plus de positie van de invoegstreep.</summary>
|
||||
private int FindInsertIndex(Point p, out Rect marker)
|
||||
{
|
||||
marker = Rect.Empty;
|
||||
UIElement? best = null;
|
||||
Rect bestRect = Rect.Empty;
|
||||
double bestDistance = double.MaxValue;
|
||||
foreach (UIElement child in InternalChildren)
|
||||
{
|
||||
if (child.Visibility == Visibility.Collapsed || _skipped.Contains(child))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Rect rect = LayoutInformation.GetLayoutSlot((FrameworkElement)child);
|
||||
if (rect.Width <= 0 || rect.Height <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
double dx = Math.Max(0, Math.Max(rect.Left - p.X, p.X - rect.Right));
|
||||
double dy = Math.Max(0, Math.Max(rect.Top - p.Y, p.Y - rect.Bottom));
|
||||
double distance = dx * dx + dy * dy;
|
||||
if (distance < bestDistance)
|
||||
{
|
||||
bestDistance = distance;
|
||||
best = child;
|
||||
bestRect = rect;
|
||||
}
|
||||
}
|
||||
|
||||
if (best == null)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
bool after = Horizontal ? p.X > bestRect.Left + bestRect.Width / 2 : p.Y > bestRect.Top + bestRect.Height / 2;
|
||||
marker = Horizontal
|
||||
? new Rect((after ? bestRect.Right : bestRect.Left) - 1, bestRect.Top + 3, 2, Math.Max(0, bestRect.Height - 6))
|
||||
: new Rect(bestRect.Left + 3, (after ? bestRect.Bottom : bestRect.Top) - 1, Math.Max(0, bestRect.Width - 6), 2);
|
||||
return InternalChildren.IndexOf(best) + (after ? 1 : 0);
|
||||
}
|
||||
|
||||
private sealed class ToolbarLine
|
||||
{
|
||||
public ToolbarLine(double align) => Align = align;
|
||||
|
||||
/// <summary>0 = begin, 0,5 = midden, 1 = eind (als de regel geen spacer bevat).</summary>
|
||||
public double Align { get; }
|
||||
|
||||
public List<UIElement> Items { get; } = new();
|
||||
|
||||
public double Main { get; private set; }
|
||||
|
||||
public double Cross { get; private set; }
|
||||
|
||||
public void Add(UIElement child, double main, double cross)
|
||||
{
|
||||
Items.Add(child);
|
||||
Main += main;
|
||||
Cross = Math.Max(Cross, cross);
|
||||
}
|
||||
|
||||
public void TrimTrailingSeparator(HashSet<UIElement> skipped)
|
||||
{
|
||||
if (Items.Count > 0 && Items[^1] is ToolbarSeparator separator)
|
||||
{
|
||||
Items.RemoveAt(Items.Count - 1);
|
||||
Main -= separator.DesiredSize.Width + separator.DesiredSize.Height;
|
||||
skipped.Add(separator);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InsertionAdorner : Adorner
|
||||
{
|
||||
private Rect _marker = Rect.Empty;
|
||||
|
||||
public InsertionAdorner(UIElement adornedElement) : base(adornedElement)
|
||||
{
|
||||
IsHitTestVisible = false;
|
||||
}
|
||||
|
||||
public void Show(Rect marker)
|
||||
{
|
||||
_marker = marker;
|
||||
InvalidateVisual();
|
||||
}
|
||||
|
||||
protected override void OnRender(DrawingContext dc)
|
||||
{
|
||||
if (!_marker.IsEmpty)
|
||||
{
|
||||
dc.DrawRectangle(TryFindResource("AccentBrush") as Brush ?? Brushes.DodgerBlue, null, _marker);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Flexibele ruimte in een <see cref="ToolbarPanel"/>; scheidt de groep aan het begin van die aan het eind.</summary>
|
||||
public sealed class ToolbarSpacer : FrameworkElement
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Dunne scheidingslijn die meedraait met de richting van de werkbalk.</summary>
|
||||
public sealed class ToolbarSeparator : FrameworkElement
|
||||
{
|
||||
private const double Thickness = 13;
|
||||
|
||||
private bool Vertical => VisualTreeHelper.GetParent(this) is ToolbarPanel { Orientation: Orientation.Vertical };
|
||||
|
||||
protected override Size MeasureOverride(Size availableSize) =>
|
||||
Vertical ? new Size(0, Thickness) : new Size(Thickness, 0);
|
||||
|
||||
protected override void OnRender(DrawingContext dc)
|
||||
{
|
||||
if (RenderSize.Width <= 0 || RenderSize.Height <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Brush brush = TryFindResource("DividerBrush") as Brush ?? Brushes.LightGray;
|
||||
Rect line = Vertical
|
||||
? 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));
|
||||
dc.DrawRectangle(brush, null, line);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Kniip.Editor;
|
||||
|
||||
/// <summary>Undo/redo op basis van momentopnamen van het document (annotaties zijn klein; pixels worden gedeeld).</summary>
|
||||
public sealed class UndoStack
|
||||
{
|
||||
private const int Limit = 100;
|
||||
|
||||
private readonly LinkedList<DocumentState> _undo = new();
|
||||
private readonly Stack<DocumentState> _redo = new();
|
||||
|
||||
public event Action? Changed;
|
||||
|
||||
public bool CanUndo => _undo.Count > 0;
|
||||
|
||||
public bool CanRedo => _redo.Count > 0;
|
||||
|
||||
public void Push(DocumentState stateBeforeChange)
|
||||
{
|
||||
_undo.AddLast(stateBeforeChange);
|
||||
if (_undo.Count > Limit)
|
||||
{
|
||||
_undo.RemoveFirst();
|
||||
}
|
||||
|
||||
_redo.Clear();
|
||||
Changed?.Invoke();
|
||||
}
|
||||
|
||||
public DocumentState? Undo(DocumentState current)
|
||||
{
|
||||
if (_undo.Last == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
DocumentState state = _undo.Last.Value;
|
||||
_undo.RemoveLast();
|
||||
_redo.Push(current);
|
||||
Changed?.Invoke();
|
||||
return state;
|
||||
}
|
||||
|
||||
public DocumentState? Redo(DocumentState current)
|
||||
{
|
||||
if (_redo.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
DocumentState state = _redo.Pop();
|
||||
_undo.AddLast(current);
|
||||
Changed?.Invoke();
|
||||
return state;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Threading;
|
||||
using Kniip.Interop;
|
||||
|
||||
namespace Kniip.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Systeembrede sneltoets via een low-level toetsenbordhaak. Anders dan RegisterHotKey werkt dit ook
|
||||
/// voor Print Screen, dat Windows zelf vaak al heeft vastgelegd. De haak draait op een eigen thread,
|
||||
/// zodat een drukke UI-thread het typen in andere programma's niet vertraagt.
|
||||
/// </summary>
|
||||
internal sealed class GlobalHotkeyHook : IDisposable
|
||||
{
|
||||
/// <summary>Niet-toegewezen virtuele toets om het Startmenu (Win) of de menubalk (Alt) te onderdrukken.</summary>
|
||||
private const byte MaskKey = 0xE8;
|
||||
|
||||
private readonly Dispatcher _dispatcher;
|
||||
private readonly Thread _thread;
|
||||
private readonly ManualResetEventSlim _ready = new();
|
||||
private readonly NativeMethods.LowLevelKeyboardProc _proc;
|
||||
private uint _threadId;
|
||||
private IntPtr _hook;
|
||||
|
||||
private readonly object _gate = new();
|
||||
private IReadOnlyDictionary<HotkeyGesture, Action> _bindings = new Dictionary<HotkeyGesture, Action>();
|
||||
private Action<HotkeyGesture>? _capture;
|
||||
private int _heldVk;
|
||||
|
||||
public GlobalHotkeyHook(Dispatcher dispatcher)
|
||||
{
|
||||
_dispatcher = dispatcher;
|
||||
_proc = HookProc;
|
||||
_thread = new Thread(Run) { IsBackground = true, Name = "kniip toetsenbordhaak" };
|
||||
_thread.Start();
|
||||
_ready.Wait();
|
||||
}
|
||||
|
||||
public bool IsActive => _hook != IntPtr.Zero;
|
||||
|
||||
/// <summary>Sneltoetsen en de actie die ze op de UI-thread starten.</summary>
|
||||
public IReadOnlyDictionary<HotkeyGesture, Action> Bindings
|
||||
{
|
||||
get { lock (_gate) return _bindings; }
|
||||
set { lock (_gate) _bindings = value; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Zolang dit gezet is, gaat elke toets (behalve losse Ctrl/Alt/Shift/Win) naar deze handler
|
||||
/// op de UI-thread in plaats van naar het actieve programma, en start de sneltoets niets.
|
||||
/// </summary>
|
||||
public Action<HotkeyGesture>? Capture
|
||||
{
|
||||
get { lock (_gate) return _capture; }
|
||||
set { lock (_gate) _capture = value; }
|
||||
}
|
||||
|
||||
private void Run()
|
||||
{
|
||||
_threadId = NativeMethods.GetCurrentThreadId();
|
||||
_hook = NativeMethods.SetWindowsHookEx(NativeMethods.WH_KEYBOARD_LL, _proc, NativeMethods.GetModuleHandle(null), 0);
|
||||
_ready.Set();
|
||||
if (_hook == IntPtr.Zero)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
while (NativeMethods.GetMessage(out NativeMethods.MSG _, IntPtr.Zero, 0, 0) > 0)
|
||||
{
|
||||
}
|
||||
|
||||
NativeMethods.UnhookWindowsHookEx(_hook);
|
||||
_hook = IntPtr.Zero;
|
||||
}
|
||||
|
||||
private IntPtr HookProc(int nCode, IntPtr wParam, IntPtr lParam)
|
||||
{
|
||||
if (nCode >= 0 && Handle(wParam.ToInt32(), Marshal.PtrToStructure<NativeMethods.KBDLLHOOKSTRUCT>(lParam)))
|
||||
{
|
||||
return new IntPtr(1);
|
||||
}
|
||||
|
||||
return NativeMethods.CallNextHookEx(_hook, nCode, wParam, lParam);
|
||||
}
|
||||
|
||||
/// <returns>True als de toets ingeslikt moet worden.</returns>
|
||||
private bool Handle(int message, NativeMethods.KBDLLHOOKSTRUCT data)
|
||||
{
|
||||
int vk = (int)data.vkCode;
|
||||
if (vk == MaskKey)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (message is NativeMethods.WM_KEYUP or NativeMethods.WM_SYSKEYUP)
|
||||
{
|
||||
if (vk != _heldVk)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_heldVk = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message is not (NativeMethods.WM_KEYDOWN or NativeMethods.WM_SYSKEYDOWN))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (vk == _heldVk)
|
||||
{
|
||||
// Automatische herhaling bij ingedrukt houden: inslikken, maar niet opnieuw starten.
|
||||
return true;
|
||||
}
|
||||
|
||||
Key key = KeyInterop.KeyFromVirtualKey(vk);
|
||||
if (key == Key.None || HotkeyGesture.IsModifierKey(key))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var pressed = new HotkeyGesture(CurrentModifiers(), key);
|
||||
Action<HotkeyGesture>? capture;
|
||||
IReadOnlyDictionary<HotkeyGesture, Action> bindings;
|
||||
lock (_gate)
|
||||
{
|
||||
capture = _capture;
|
||||
bindings = _bindings;
|
||||
}
|
||||
|
||||
if (capture != null)
|
||||
{
|
||||
_dispatcher.BeginInvoke(() => capture(pressed));
|
||||
}
|
||||
else if (bindings.TryGetValue(pressed, out Action? action))
|
||||
{
|
||||
_dispatcher.BeginInvoke(action);
|
||||
}
|
||||
else
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_heldVk = vk;
|
||||
if ((pressed.Modifiers & (ModifierKeys.Alt | ModifierKeys.Windows)) != 0)
|
||||
{
|
||||
NativeMethods.keybd_event(MaskKey, 0, 0, UIntPtr.Zero);
|
||||
NativeMethods.keybd_event(MaskKey, 0, NativeMethods.KEYEVENTF_KEYUP, UIntPtr.Zero);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static ModifierKeys CurrentModifiers()
|
||||
{
|
||||
ModifierKeys modifiers = ModifierKeys.None;
|
||||
if (IsDown(NativeMethods.VK_CONTROL)) modifiers |= ModifierKeys.Control;
|
||||
if (IsDown(NativeMethods.VK_MENU)) modifiers |= ModifierKeys.Alt;
|
||||
if (IsDown(NativeMethods.VK_SHIFT)) modifiers |= ModifierKeys.Shift;
|
||||
if (IsDown(NativeMethods.VK_LWIN) || IsDown(NativeMethods.VK_RWIN)) modifiers |= ModifierKeys.Windows;
|
||||
return modifiers;
|
||||
}
|
||||
|
||||
private static bool IsDown(int vk) => (NativeMethods.GetAsyncKeyState(vk) & 0x8000) != 0;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_threadId != 0)
|
||||
{
|
||||
NativeMethods.PostThreadMessage(_threadId, NativeMethods.WM_QUIT, IntPtr.Zero, IntPtr.Zero);
|
||||
_thread.Join(TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
_ready.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Windows.Input;
|
||||
|
||||
namespace Kniip.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Toetscombinatie voor de toetsenbordhaak. Anders dan bij <see cref="KeyGesture"/> mag Print Screen
|
||||
/// zonder Ctrl/Alt/Shift/Win. Opgeslagen als tekst, bijvoorbeeld "PrintScreen" of "Ctrl+Shift+S".
|
||||
/// </summary>
|
||||
internal readonly record struct HotkeyGesture(ModifierKeys Modifiers, Key Key)
|
||||
{
|
||||
/// <summary>Gedeelde enumwaarden (Snapshot = PrintScreen, Return = Enter, ...) krijgen één vaste naam.</summary>
|
||||
private static readonly Dictionary<Key, (string Stored, string Display)> Names = new()
|
||||
{
|
||||
[Key.PrintScreen] = ("PrintScreen", "Print Screen"),
|
||||
[Key.Enter] = ("Enter", "Enter"),
|
||||
[Key.CapsLock] = ("CapsLock", "Caps Lock"),
|
||||
[Key.PageUp] = ("PageUp", "Page Up"),
|
||||
[Key.PageDown] = ("PageDown", "Page Down"),
|
||||
[Key.Scroll] = ("Scroll", "Scroll Lock"),
|
||||
[Key.Back] = ("Back", "Backspace"),
|
||||
};
|
||||
|
||||
public static bool TryParse(string? text, out HotkeyGesture gesture)
|
||||
{
|
||||
gesture = default;
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string[] parts = text.Split('+', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (parts.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
ModifierKeys modifiers = ModifierKeys.None;
|
||||
for (int i = 0; i < parts.Length - 1; i++)
|
||||
{
|
||||
switch (parts[i].ToLowerInvariant())
|
||||
{
|
||||
case "ctrl":
|
||||
case "control":
|
||||
modifiers |= ModifierKeys.Control;
|
||||
break;
|
||||
case "alt":
|
||||
modifiers |= ModifierKeys.Alt;
|
||||
break;
|
||||
case "shift":
|
||||
modifiers |= ModifierKeys.Shift;
|
||||
break;
|
||||
case "win":
|
||||
case "windows":
|
||||
modifiers |= ModifierKeys.Windows;
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
string keyText = parts[^1].Replace(" ", string.Empty);
|
||||
// KeyGesture schrijft cijfertoetsen als "1"; Enum.TryParse zou dat als enumwaarde 1 lezen.
|
||||
if (keyText.Length == 1 && char.IsAsciiDigit(keyText[0]))
|
||||
{
|
||||
keyText = "D" + keyText;
|
||||
}
|
||||
|
||||
if (!Enum.TryParse(keyText, true, out Key key) || key == Key.None || IsModifierKey(key))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
gesture = new HotkeyGesture(modifiers, key);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool IsModifierKey(Key key) =>
|
||||
key is Key.LeftCtrl or Key.RightCtrl or Key.LeftShift or Key.RightShift or Key.LeftAlt or Key.RightAlt
|
||||
or Key.LWin or Key.RWin or Key.System;
|
||||
|
||||
/// <summary>
|
||||
/// Zonder Ctrl/Alt/Shift/Win alleen toetsen die je niet gebruikt om te typen,
|
||||
/// anders zou de haak die toets in alle programma's opeten.
|
||||
/// </summary>
|
||||
public bool IsAllowed =>
|
||||
Modifiers is not (ModifierKeys.None or ModifierKeys.Shift)
|
||||
|| Key is Key.PrintScreen or Key.Pause or Key.Scroll or (>= Key.F1 and <= Key.F24);
|
||||
|
||||
public override string ToString() => Join("+", Names.TryGetValue(Key, out var name) ? name.Stored : Key.ToString());
|
||||
|
||||
public string DisplayText => Join(" + ", Names.TryGetValue(Key, out var name) ? name.Display : DisplayKey(Key));
|
||||
|
||||
private string Join(string separator, string key)
|
||||
{
|
||||
var parts = new List<string>(5);
|
||||
if (Modifiers.HasFlag(ModifierKeys.Control)) parts.Add("Ctrl");
|
||||
if (Modifiers.HasFlag(ModifierKeys.Alt)) parts.Add("Alt");
|
||||
if (Modifiers.HasFlag(ModifierKeys.Shift)) parts.Add("Shift");
|
||||
if (Modifiers.HasFlag(ModifierKeys.Windows)) parts.Add("Win");
|
||||
parts.Add(key);
|
||||
return string.Join(separator, parts);
|
||||
}
|
||||
|
||||
private static string DisplayKey(Key key) => key switch
|
||||
{
|
||||
>= Key.D0 and <= Key.D9 => ((char)('0' + (key - Key.D0))).ToString(),
|
||||
>= Key.NumPad0 and <= Key.NumPad9 => $"Num {key - Key.NumPad0}",
|
||||
_ => key.ToString()
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
|
||||
namespace Kniip.Infrastructure;
|
||||
|
||||
internal static class PixelUtil
|
||||
{
|
||||
/// <summary>
|
||||
/// Zet een afbeelding om naar Bgra32 met volledig dekkende pixels.
|
||||
/// Klembord-DIB's hebben vaak alpha 0; screenshots zijn altijd ondoorzichtig.
|
||||
/// </summary>
|
||||
public static byte[] ToOpaqueBgra(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);
|
||||
for (int i = 3; i < pixels.Length; i += 4)
|
||||
{
|
||||
pixels[i] = 255;
|
||||
}
|
||||
|
||||
return pixels;
|
||||
}
|
||||
|
||||
public static BitmapSource CreateBitmap(byte[] pixels, int width, int height)
|
||||
{
|
||||
var bitmap = BitmapSource.Create(width, height, 96, 96, PixelFormats.Bgra32, null, pixels, width * 4);
|
||||
bitmap.Freeze();
|
||||
return bitmap;
|
||||
}
|
||||
|
||||
public static string ComputeHash(BitmapSource source)
|
||||
{
|
||||
byte[] pixels = ToOpaqueBgra(source);
|
||||
byte[] hash = SHA256.HashData(pixels);
|
||||
return $"{source.PixelWidth}x{source.PixelHeight}:{Convert.ToHexString(hash)}";
|
||||
}
|
||||
|
||||
public static BitmapSource LoadFile(string path)
|
||||
{
|
||||
byte[] bytes = File.ReadAllBytes(path);
|
||||
return Decode(new MemoryStream(bytes));
|
||||
}
|
||||
|
||||
public static BitmapSource Decode(Stream stream)
|
||||
{
|
||||
var decoder = BitmapDecoder.Create(stream, BitmapCreateOptions.PreservePixelFormat, BitmapCacheOption.OnLoad);
|
||||
BitmapFrame frame = decoder.Frames[0];
|
||||
frame.Freeze();
|
||||
return frame;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
using System;
|
||||
using Kniip.Interop;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace Kniip.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Registratie per gebruiker (HKCU, geen beheerdersrechten nodig) voor "Openen met" en autostart.
|
||||
/// </summary>
|
||||
internal static class ShellIntegration
|
||||
{
|
||||
private const string ProgId = "Kniip.Image";
|
||||
private const string AppKeyName = "Kniip.exe";
|
||||
private const string RunKeyPath = @"Software\Microsoft\Windows\CurrentVersion\Run";
|
||||
private const string RunValueName = "Kniip";
|
||||
|
||||
public static readonly string[] ImageExtensions = { ".png", ".jpg", ".jpeg", ".bmp", ".gif" };
|
||||
|
||||
private static string ExePath => Environment.ProcessPath
|
||||
?? throw new InvalidOperationException("Pad van de applicatie is onbekend.");
|
||||
|
||||
private static string OpenCommand => $"\"{ExePath}\" \"%1\"";
|
||||
|
||||
public static bool IsOpenWithRegistered()
|
||||
{
|
||||
using var key = Registry.CurrentUser.OpenSubKey($@"Software\Classes\{ProgId}\shell\open\command");
|
||||
return key?.GetValue(null) is string command
|
||||
&& string.Equals(command, OpenCommand, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public static void RegisterOpenWith()
|
||||
{
|
||||
using (var prog = Registry.CurrentUser.CreateSubKey($@"Software\Classes\{ProgId}"))
|
||||
{
|
||||
prog.SetValue(null, "kniip afbeelding");
|
||||
prog.SetValue("FriendlyTypeName", "kniip afbeelding");
|
||||
}
|
||||
using (var icon = Registry.CurrentUser.CreateSubKey($@"Software\Classes\{ProgId}\DefaultIcon"))
|
||||
{
|
||||
icon.SetValue(null, $"\"{ExePath}\",0");
|
||||
}
|
||||
using (var cmd = Registry.CurrentUser.CreateSubKey($@"Software\Classes\{ProgId}\shell\open\command"))
|
||||
{
|
||||
cmd.SetValue(null, OpenCommand);
|
||||
}
|
||||
|
||||
using (var app = Registry.CurrentUser.CreateSubKey($@"Software\Classes\Applications\{AppKeyName}"))
|
||||
{
|
||||
app.SetValue("FriendlyAppName", "kniip editor");
|
||||
}
|
||||
using (var appCmd = Registry.CurrentUser.CreateSubKey($@"Software\Classes\Applications\{AppKeyName}\shell\open\command"))
|
||||
{
|
||||
appCmd.SetValue(null, OpenCommand);
|
||||
}
|
||||
using (var types = Registry.CurrentUser.CreateSubKey($@"Software\Classes\Applications\{AppKeyName}\SupportedTypes"))
|
||||
{
|
||||
foreach (string ext in ImageExtensions)
|
||||
{
|
||||
types.SetValue(ext, string.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (string ext in ImageExtensions)
|
||||
{
|
||||
using var openWith = Registry.CurrentUser.CreateSubKey($@"Software\Classes\{ext}\OpenWithProgids");
|
||||
openWith.SetValue(ProgId, Array.Empty<byte>(), RegistryValueKind.None);
|
||||
}
|
||||
|
||||
NotifyShell();
|
||||
}
|
||||
|
||||
public static void UnregisterOpenWith()
|
||||
{
|
||||
Registry.CurrentUser.DeleteSubKeyTree($@"Software\Classes\{ProgId}", false);
|
||||
Registry.CurrentUser.DeleteSubKeyTree($@"Software\Classes\Applications\{AppKeyName}", false);
|
||||
|
||||
foreach (string ext in ImageExtensions)
|
||||
{
|
||||
using var openWith = Registry.CurrentUser.OpenSubKey($@"Software\Classes\{ext}\OpenWithProgids", true);
|
||||
openWith?.DeleteValue(ProgId, false);
|
||||
}
|
||||
|
||||
NotifyShell();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Zet de registratie van voor de hernoeming (Knip+ / KnipPlus.exe) om naar kniip,
|
||||
/// zodat autostart en "Openen met" niet naar een verdwenen exe blijven wijzen.
|
||||
/// </summary>
|
||||
public static void MigrateLegacyRegistration()
|
||||
{
|
||||
const string legacyProgId = "KnipPlus.Image";
|
||||
const string legacyAppKeyName = "KnipPlus.exe";
|
||||
const string legacyRunValueName = "KnipPlus";
|
||||
|
||||
bool hadAutostart;
|
||||
using (var run = Registry.CurrentUser.OpenSubKey(RunKeyPath, true))
|
||||
{
|
||||
hadAutostart = run?.GetValue(legacyRunValueName) is string;
|
||||
run?.DeleteValue(legacyRunValueName, false);
|
||||
}
|
||||
|
||||
bool hadOpenWith;
|
||||
using (var prog = Registry.CurrentUser.OpenSubKey($@"Software\Classes\{legacyProgId}"))
|
||||
{
|
||||
hadOpenWith = prog != null;
|
||||
}
|
||||
|
||||
if (hadOpenWith)
|
||||
{
|
||||
Registry.CurrentUser.DeleteSubKeyTree($@"Software\Classes\{legacyProgId}", false);
|
||||
Registry.CurrentUser.DeleteSubKeyTree($@"Software\Classes\Applications\{legacyAppKeyName}", false);
|
||||
foreach (string ext in ImageExtensions)
|
||||
{
|
||||
using var openWith = Registry.CurrentUser.OpenSubKey($@"Software\Classes\{ext}\OpenWithProgids", true);
|
||||
openWith?.DeleteValue(legacyProgId, false);
|
||||
}
|
||||
}
|
||||
|
||||
if (hadAutostart)
|
||||
{
|
||||
SetAutostart(true);
|
||||
}
|
||||
|
||||
if (hadOpenWith)
|
||||
{
|
||||
RegisterOpenWith();
|
||||
}
|
||||
}
|
||||
|
||||
public static bool IsAutostartEnabled()
|
||||
{
|
||||
using var key = Registry.CurrentUser.OpenSubKey(RunKeyPath);
|
||||
return key?.GetValue(RunValueName) is string;
|
||||
}
|
||||
|
||||
public static void SetAutostart(bool enabled)
|
||||
{
|
||||
using var key = Registry.CurrentUser.CreateSubKey(RunKeyPath);
|
||||
if (enabled)
|
||||
{
|
||||
key.SetValue(RunValueName, $"\"{ExePath}\" --tray");
|
||||
}
|
||||
else
|
||||
{
|
||||
key.DeleteValue(RunValueName, false);
|
||||
}
|
||||
}
|
||||
|
||||
private static void NotifyShell() =>
|
||||
NativeMethods.SHChangeNotify(NativeMethods.SHCNE_ASSOCCHANGED, 0, IntPtr.Zero, IntPtr.Zero);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.IO.Pipes;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Kniip.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Zorgt dat kniip maar één keer draait. Een tweede start stuurt zijn argumenten
|
||||
/// via een named pipe door naar de eerste instantie en sluit daarna af.
|
||||
/// </summary>
|
||||
internal sealed class SingleInstance : IDisposable
|
||||
{
|
||||
private static readonly string BaseName = "Kniip-" + Environment.UserName;
|
||||
private static readonly string PipeName = BaseName + "-pipe";
|
||||
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private Mutex? _mutex;
|
||||
|
||||
public bool TryAcquire()
|
||||
{
|
||||
_mutex = new Mutex(true, BaseName + "-mutex", out bool createdNew);
|
||||
return createdNew;
|
||||
}
|
||||
|
||||
public static bool SendToPrimary(string[] args)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = new NamedPipeClientStream(".", PipeName, PipeDirection.Out);
|
||||
client.Connect(3000);
|
||||
using var writer = new StreamWriter(client, Encoding.UTF8);
|
||||
writer.Write(string.Join("\n", args));
|
||||
writer.Flush();
|
||||
return true;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void StartServer(Action<string[]> onArguments)
|
||||
{
|
||||
Task.Run(async () =>
|
||||
{
|
||||
while (!_cts.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var server = new NamedPipeServerStream(PipeName, PipeDirection.In, 1,
|
||||
PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
|
||||
await server.WaitForConnectionAsync(_cts.Token);
|
||||
using var reader = new StreamReader(server, Encoding.UTF8);
|
||||
string content = await reader.ReadToEndAsync(_cts.Token);
|
||||
string[] args = content.Length == 0
|
||||
? Array.Empty<string>()
|
||||
: content.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
onArguments(args);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
await Task.Delay(500);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts.Cancel();
|
||||
if (_mutex != null)
|
||||
{
|
||||
try { _mutex.ReleaseMutex(); } catch (ApplicationException) { }
|
||||
_mutex.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using System;
|
||||
using System.Drawing;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace Kniip.Infrastructure;
|
||||
|
||||
internal sealed class TrayIcon : IDisposable
|
||||
{
|
||||
private readonly NotifyIcon _icon;
|
||||
private const string RecordText = "Screencast opnemen (MP4)...";
|
||||
|
||||
private readonly ToolStripMenuItem _pauseItem;
|
||||
private readonly ToolStripMenuItem _snipItem;
|
||||
private readonly ToolStripMenuItem _windowItem;
|
||||
private readonly ToolStripMenuItem _recordItem;
|
||||
private readonly ToolStripMenuItem _gifItem;
|
||||
|
||||
public event Action? OpenFromClipboardRequested;
|
||||
public event Action? SnipRequested;
|
||||
public event Action? WindowSnipRequested;
|
||||
public event Action? RecordRequested;
|
||||
public event Action? GifRequested;
|
||||
public event Action? OpenFileRequested;
|
||||
public event Action? SettingsRequested;
|
||||
public event Action? ExitRequested;
|
||||
public event Action<bool>? PauseChanged;
|
||||
|
||||
public TrayIcon()
|
||||
{
|
||||
var menu = new ContextMenuStrip();
|
||||
var openClipboard = new ToolStripMenuItem("Bewerken vanaf klembord", null, (_, _) => OpenFromClipboardRequested?.Invoke())
|
||||
{
|
||||
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);
|
||||
_windowItem = new ToolStripMenuItem("Venster knippen", null, (_, _) => WindowSnipRequested?.Invoke());
|
||||
menu.Items.Add(_windowItem);
|
||||
_recordItem = new ToolStripMenuItem(RecordText, null, (_, _) => RecordRequested?.Invoke());
|
||||
menu.Items.Add(_recordItem);
|
||||
_gifItem = new ToolStripMenuItem("Screencast opnemen (GIF)...", null, (_, _) => GifRequested?.Invoke());
|
||||
menu.Items.Add(_gifItem);
|
||||
menu.Items.Add(new ToolStripSeparator());
|
||||
|
||||
_pauseItem = new ToolStripMenuItem("Knipsels niet automatisch oppikken") { CheckOnClick = true };
|
||||
_pauseItem.CheckedChanged += (_, _) => PauseChanged?.Invoke(_pauseItem.Checked);
|
||||
menu.Items.Add(_pauseItem);
|
||||
menu.Items.Add("Instellingen...", null, (_, _) => SettingsRequested?.Invoke());
|
||||
menu.Items.Add(new ToolStripSeparator());
|
||||
menu.Items.Add("Afsluiten", null, (_, _) => ExitRequested?.Invoke());
|
||||
|
||||
_icon = new NotifyIcon
|
||||
{
|
||||
Icon = LoadIcon(),
|
||||
Text = "kniip - annotaties voor het Knipprogramma",
|
||||
ContextMenuStrip = menu,
|
||||
Visible = true
|
||||
};
|
||||
_icon.MouseClick += (_, e) =>
|
||||
{
|
||||
if (e.Button == MouseButtons.Left)
|
||||
{
|
||||
OpenFromClipboardRequested?.Invoke();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public bool IsPaused => _pauseItem.Checked;
|
||||
|
||||
/// <summary>Toont de sneltoetsen naast de menu-items; null = geen sneltoets.</summary>
|
||||
public void SetHotkeys(string? snip, string? window, string? record, string? gif)
|
||||
{
|
||||
_snipItem.ShortcutKeyDisplayString = snip;
|
||||
_windowItem.ShortcutKeyDisplayString = window;
|
||||
_recordItem.ShortcutKeyDisplayString = record;
|
||||
_gifItem.ShortcutKeyDisplayString = gif;
|
||||
}
|
||||
|
||||
/// <summary>Menutekst voor de opnameknop; null zet de standaardtekst terug.</summary>
|
||||
public void SetRecordItem(string? text, bool recording)
|
||||
{
|
||||
_recordItem.Text = text ?? RecordText;
|
||||
_gifItem.Visible = text == null;
|
||||
_icon.Text = recording ? "kniip - opname loopt" : "kniip - annotaties voor het Knipprogramma";
|
||||
}
|
||||
|
||||
public void ShowBalloon(string title, string text) =>
|
||||
_icon.ShowBalloonTip(4000, title, text, ToolTipIcon.Info);
|
||||
|
||||
private static Icon LoadIcon()
|
||||
{
|
||||
var info = System.Windows.Application.GetResourceStream(new Uri("pack://application:,,,/AppIcon.ico"));
|
||||
if (info == null)
|
||||
{
|
||||
return SystemIcons.Application;
|
||||
}
|
||||
|
||||
using var stream = info.Stream;
|
||||
return new Icon(stream, SystemInformation.SmallIconSize);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_icon.Visible = false;
|
||||
_icon.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Kniip.Interop;
|
||||
|
||||
internal static class NativeMethods
|
||||
{
|
||||
public const int WM_CLIPBOARDUPDATE = 0x031D;
|
||||
public const int SHCNE_ASSOCCHANGED = 0x08000000;
|
||||
public static readonly IntPtr HWND_MESSAGE = new(-3);
|
||||
|
||||
public static readonly Guid FOLDERID_Screenshots = new("b7bede81-df94-4682-a7d8-57a52620b86f");
|
||||
public static readonly Guid FOLDERID_Pictures = new("33E28130-4E1E-4676-835A-98395C3BC3BB");
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static extern bool AddClipboardFormatListener(IntPtr hwnd);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static extern bool RemoveClipboardFormatListener(IntPtr hwnd);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
public static extern IntPtr GetClipboardOwner();
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
public static extern uint GetClipboardSequenceNumber();
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
|
||||
|
||||
[DllImport("shell32.dll")]
|
||||
private static extern int SHGetKnownFolderPath([MarshalAs(UnmanagedType.LPStruct)] Guid rfid, uint dwFlags, IntPtr hToken, out IntPtr ppszPath);
|
||||
|
||||
[DllImport("shell32.dll")]
|
||||
public static extern void SHChangeNotify(int wEventId, uint uFlags, IntPtr dwItem1, IntPtr dwItem2);
|
||||
|
||||
public const int WM_HOTKEY = 0x0312;
|
||||
public const uint MOD_ALT = 0x0001;
|
||||
public const uint MOD_CONTROL = 0x0002;
|
||||
public const uint MOD_SHIFT = 0x0004;
|
||||
public const uint MOD_WIN = 0x0008;
|
||||
public const uint MOD_NOREPEAT = 0x4000;
|
||||
|
||||
public const int GWL_EXSTYLE = -20;
|
||||
public const int WS_EX_TRANSPARENT = 0x00000020;
|
||||
public const int WS_EX_TOOLWINDOW = 0x00000080;
|
||||
public const int WS_EX_NOACTIVATE = 0x08000000;
|
||||
public const uint WDA_EXCLUDEFROMCAPTURE = 0x00000011;
|
||||
|
||||
public const uint SWP_NOSIZE = 0x0001;
|
||||
public const uint SWP_NOZORDER = 0x0004;
|
||||
public const uint SWP_NOACTIVATE = 0x0010;
|
||||
public static readonly IntPtr HWND_TOPMOST = new(-1);
|
||||
|
||||
private const int DWMWA_EXTENDED_FRAME_BOUNDS = 9;
|
||||
private const int DWMWA_CLOAKED = 14;
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct RECT
|
||||
{
|
||||
public int Left;
|
||||
public int Top;
|
||||
public int Right;
|
||||
public int Bottom;
|
||||
|
||||
public readonly int Width => Right - Left;
|
||||
public readonly int Height => Bottom - Top;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct POINT
|
||||
{
|
||||
public int X;
|
||||
public int Y;
|
||||
}
|
||||
|
||||
public delegate bool EnumWindowsProc(IntPtr hwnd, IntPtr lParam);
|
||||
|
||||
public const int WH_KEYBOARD_LL = 13;
|
||||
public const int WM_KEYDOWN = 0x0100;
|
||||
public const int WM_KEYUP = 0x0101;
|
||||
public const int WM_SYSKEYDOWN = 0x0104;
|
||||
public const int WM_SYSKEYUP = 0x0105;
|
||||
public const uint WM_QUIT = 0x0012;
|
||||
public const int VK_SHIFT = 0x10;
|
||||
public const int VK_CONTROL = 0x11;
|
||||
public const int VK_MENU = 0x12;
|
||||
public const int VK_LWIN = 0x5B;
|
||||
public const int VK_RWIN = 0x5C;
|
||||
public const uint KEYEVENTF_KEYUP = 0x0002;
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct KBDLLHOOKSTRUCT
|
||||
{
|
||||
public uint vkCode;
|
||||
public uint scanCode;
|
||||
public uint flags;
|
||||
public uint time;
|
||||
public IntPtr dwExtraInfo;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct MSG
|
||||
{
|
||||
public IntPtr hwnd;
|
||||
public uint message;
|
||||
public IntPtr wParam;
|
||||
public IntPtr lParam;
|
||||
public uint time;
|
||||
public POINT pt;
|
||||
}
|
||||
|
||||
public delegate IntPtr LowLevelKeyboardProc(int nCode, IntPtr wParam, IntPtr lParam);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
public static extern IntPtr SetWindowsHookEx(int idHook, LowLevelKeyboardProc callback, IntPtr hMod, uint threadId);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static extern bool UnhookWindowsHookEx(IntPtr hook);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
public static extern IntPtr CallNextHookEx(IntPtr hook, int nCode, IntPtr wParam, IntPtr lParam);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
public static extern short GetAsyncKeyState(int vk);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
public static extern void keybd_event(byte vk, byte scan, uint flags, UIntPtr extraInfo);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
public static extern int GetMessage(out MSG msg, IntPtr hWnd, uint filterMin, uint filterMax);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static extern bool PostThreadMessage(uint threadId, uint msg, IntPtr wParam, IntPtr lParam);
|
||||
|
||||
[DllImport("kernel32.dll")]
|
||||
public static extern uint GetCurrentThreadId();
|
||||
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
|
||||
public static extern IntPtr GetModuleHandle(string? moduleName);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static extern bool RegisterHotKey(IntPtr hWnd, int id, uint fsModifiers, uint vk);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static extern bool UnregisterHotKey(IntPtr hWnd, int id);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int x, int y, int cx, int cy, uint flags);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static extern bool SetWindowDisplayAffinity(IntPtr hWnd, uint affinity);
|
||||
|
||||
[DllImport("user32.dll", EntryPoint = "GetWindowLongPtrW")]
|
||||
public static extern IntPtr GetWindowLongPtr(IntPtr hWnd, int index);
|
||||
|
||||
[DllImport("user32.dll", EntryPoint = "SetWindowLongPtrW")]
|
||||
public static extern IntPtr SetWindowLongPtr(IntPtr hWnd, int index, IntPtr value);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static extern bool GetWindowRect(IntPtr hWnd, out RECT rect);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static extern bool GetCursorPos(out POINT point);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static extern bool EnumWindows(EnumWindowsProc callback, IntPtr lParam);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static extern bool IsWindowVisible(IntPtr hWnd);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static extern bool IsIconic(IntPtr hWnd);
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||
public static extern int GetClassName(IntPtr hWnd, System.Text.StringBuilder className, int maxCount);
|
||||
|
||||
[DllImport("dwmapi.dll")]
|
||||
private static extern int DwmGetWindowAttribute(IntPtr hwnd, int attribute, out RECT value, int size);
|
||||
|
||||
[DllImport("dwmapi.dll")]
|
||||
private static extern int DwmGetWindowAttribute(IntPtr hwnd, int attribute, out int value, int size);
|
||||
|
||||
public static void AddExtendedStyle(IntPtr hwnd, int style)
|
||||
{
|
||||
long current = GetWindowLongPtr(hwnd, GWL_EXSTYLE).ToInt64();
|
||||
SetWindowLongPtr(hwnd, GWL_EXSTYLE, new IntPtr(current | (long)style));
|
||||
}
|
||||
|
||||
/// <summary>Zichtbare vensterrand zonder de onzichtbare schaduwrand van Windows 10/11.</summary>
|
||||
public static RECT GetVisibleWindowRect(IntPtr hwnd)
|
||||
{
|
||||
if (DwmGetWindowAttribute(hwnd, DWMWA_EXTENDED_FRAME_BOUNDS, out RECT rect, Marshal.SizeOf<RECT>()) == 0)
|
||||
{
|
||||
return rect;
|
||||
}
|
||||
|
||||
GetWindowRect(hwnd, out rect);
|
||||
return rect;
|
||||
}
|
||||
|
||||
public static bool IsCloaked(IntPtr hwnd) =>
|
||||
DwmGetWindowAttribute(hwnd, DWMWA_CLOAKED, out int cloaked, sizeof(int)) == 0 && cloaked != 0;
|
||||
|
||||
public static string? GetKnownFolderPath(Guid folderId)
|
||||
{
|
||||
if (SHGetKnownFolderPath(folderId, 0, IntPtr.Zero, out IntPtr path) != 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return Marshal.PtrToStringUni(path);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FreeCoTaskMem(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
|
||||
<SupportedOSPlatformVersion>10.0.17763.0</SupportedOSPlatformVersion>
|
||||
<!-- ScreenRecorderLib is een native C++/CLI-assembly en vereist een vast platform. -->
|
||||
<Platform>x64</Platform>
|
||||
<PlatformTarget>x64</PlatformTarget>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<UseWPF>true</UseWPF>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<ImplicitUsings>disable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>Kniip</AssemblyName>
|
||||
<RootNamespace>Kniip</RootNamespace>
|
||||
<ApplicationIcon>AppIcon.ico</ApplicationIcon>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<Product>kniip</Product>
|
||||
<Description>Annotatie-editor naast het Windows Knipprogramma</Description>
|
||||
<Version>1.0.0</Version>
|
||||
<SatelliteResourceLanguages>nl;en</SatelliteResourceLanguages>
|
||||
<!-- WPF regelt DPI via app.manifest; de WinForms-analyzer is hier niet van toepassing. -->
|
||||
<NoWarn>$(NoWarn);WFAC010</NoWarn>
|
||||
<!-- Geen .pdb/.xml van ScreenRecorderLib (15 MB) naast de exe. -->
|
||||
<AllowedReferenceRelatedFileExtensions>.none</AllowedReferenceRelatedFileExtensions>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Resource Include="AppIcon.ico" />
|
||||
<Resource Include="Assets\Emoji\emoji.json;Assets\Emoji\twemoji-64.png" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ScreenRecorderLib" Version="7.0.1" />
|
||||
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,50 @@
|
||||
<Window x:Class="Kniip.Notifications.SnipToast"
|
||||
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" UseLayoutRounding="True">
|
||||
<Border Margin="16" Background="#FCFCFC" CornerRadius="8" BorderBrush="#DADADA" BorderThickness="1">
|
||||
<Border.Effect>
|
||||
<DropShadowEffect BlurRadius="18" ShadowDepth="3" Opacity="0.22" />
|
||||
</Border.Effect>
|
||||
<Grid Width="340" Margin="14,12,10,14">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<StackPanel Grid.ColumnSpan="2" Orientation="Horizontal" Margin="0,0,0,10">
|
||||
<Image Source="pack://application:,,,/AppIcon.ico" Width="16" Height="16" />
|
||||
<TextBlock Text="kniip" Margin="6,0,0,0" FontSize="12" Foreground="#5C5C5C" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="2" Style="{StaticResource FlatButton}" MinWidth="28" MinHeight="24" Padding="4,2"
|
||||
VerticalAlignment="Top" Click="Close_Click" ToolTip="Sluiten">
|
||||
<TextBlock Text="" Style="{StaticResource IconText}" FontSize="10" />
|
||||
</Button>
|
||||
|
||||
<Border Grid.Row="1" Width="110" Height="72" CornerRadius="4" BorderBrush="#E0E0E0" BorderThickness="1"
|
||||
Background="#EBEBEB" Cursor="Hand" MouseLeftButtonUp="Thumbnail_Click" ClipToBounds="True">
|
||||
<Image x:Name="Thumbnail" Stretch="Uniform" RenderOptions.BitmapScalingMode="HighQuality" />
|
||||
</Border>
|
||||
<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 x:Name="DetailText" Foreground="#5C5C5C" FontSize="12" Margin="0,2,0,8" />
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Button Style="{StaticResource AccentButton}" Margin="0,0,6,0" Click="Edit_Click">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="" Style="{StaticResource IconText}" FontSize="13" Margin="0,0,6,0" Foreground="White" />
|
||||
<TextBlock Text="Bewerken" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button Style="{StaticResource SecondaryButton}" Margin="0" Content="Negeren" Click="Close_Click" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Window>
|
||||
@@ -0,0 +1,78 @@
|
||||
using System;
|
||||
using System.Windows;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media.Imaging;
|
||||
using System.Windows.Threading;
|
||||
|
||||
namespace Kniip.Notifications;
|
||||
|
||||
/// <summary>
|
||||
/// Kleine melding rechtsonder na een nieuw knipsel. Er is er altijd maar één; een nieuw knipsel vervangt de vorige.
|
||||
/// </summary>
|
||||
public partial class SnipToast : Window
|
||||
{
|
||||
private static SnipToast? _current;
|
||||
|
||||
private readonly DispatcherTimer _timer = new() { Interval = TimeSpan.FromSeconds(8) };
|
||||
private Action? _onEdit;
|
||||
|
||||
private SnipToast()
|
||||
{
|
||||
InitializeComponent();
|
||||
_timer.Tick += (_, _) =>
|
||||
{
|
||||
if (!IsMouseOver)
|
||||
{
|
||||
Close();
|
||||
}
|
||||
};
|
||||
SizeChanged += (_, _) => PlaceBottomRight();
|
||||
Closed += (_, _) =>
|
||||
{
|
||||
_timer.Stop();
|
||||
if (ReferenceEquals(_current, this))
|
||||
{
|
||||
_current = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public static void ShowFor(BitmapSource image, Action onEdit)
|
||||
{
|
||||
_current ??= new SnipToast();
|
||||
_current.Update(image, onEdit);
|
||||
}
|
||||
|
||||
private void Update(BitmapSource image, Action onEdit)
|
||||
{
|
||||
_onEdit = onEdit;
|
||||
Thumbnail.Source = image;
|
||||
DetailText.Text = $"{image.PixelWidth} × {image.PixelHeight} px · klik om aantekeningen toe te voegen";
|
||||
if (!IsVisible)
|
||||
{
|
||||
Show();
|
||||
}
|
||||
|
||||
PlaceBottomRight();
|
||||
_timer.Stop();
|
||||
_timer.Start();
|
||||
}
|
||||
|
||||
private void PlaceBottomRight()
|
||||
{
|
||||
Rect area = SystemParameters.WorkArea;
|
||||
Left = area.Right - ActualWidth;
|
||||
Top = area.Bottom - ActualHeight;
|
||||
}
|
||||
|
||||
private void Edit_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
Action? onEdit = _onEdit;
|
||||
Close();
|
||||
onEdit?.Invoke();
|
||||
}
|
||||
|
||||
private void Thumbnail_Click(object sender, MouseButtonEventArgs e) => Edit_Click(sender, e);
|
||||
|
||||
private void Close_Click(object sender, RoutedEventArgs e) => Close();
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Kniip.Recording;
|
||||
|
||||
/// <summary>
|
||||
/// Standaard audioapparaten via Core Audio. ScreenRecorderLib crasht (niet afvangbaar) als er bijvoorbeeld
|
||||
/// geen afspeelapparaat is, dus kniip zoekt de apparaten zelf op en geeft alleen bestaande door.
|
||||
/// </summary>
|
||||
internal static class AudioDevices
|
||||
{
|
||||
private const int ERender = 0;
|
||||
private const int ECapture = 1;
|
||||
private const int EConsole = 0;
|
||||
|
||||
public static string? DefaultPlaybackId => GetDefaultId(ERender);
|
||||
|
||||
public static string? DefaultMicrophoneId => GetDefaultId(ECapture);
|
||||
|
||||
private static string? GetDefaultId(int dataFlow)
|
||||
{
|
||||
try
|
||||
{
|
||||
var enumerator = (IMMDeviceEnumerator)new MMDeviceEnumerator();
|
||||
try
|
||||
{
|
||||
if (enumerator.GetDefaultAudioEndpoint(dataFlow, EConsole, out IMMDevice? device) != 0 || device == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return device.GetId(out string? id) == 0 ? id : null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.ReleaseComObject(device);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.ReleaseComObject(enumerator);
|
||||
}
|
||||
}
|
||||
catch (COMException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
[ComImport, Guid("BCDE0395-E52F-467C-8E3D-C4579291692E")]
|
||||
private class MMDeviceEnumerator
|
||||
{
|
||||
}
|
||||
|
||||
[ComImport, Guid("A95664D2-9614-4F35-A746-DE8DB63617E6"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
private interface IMMDeviceEnumerator
|
||||
{
|
||||
void EnumAudioEndpoints();
|
||||
|
||||
[PreserveSig]
|
||||
int GetDefaultAudioEndpoint(int dataFlow, int role, out IMMDevice? device);
|
||||
}
|
||||
|
||||
[ComImport, Guid("D666063F-1587-4E43-81F1-B948E807363F"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
private interface IMMDevice
|
||||
{
|
||||
void Activate();
|
||||
|
||||
void OpenPropertyStore();
|
||||
|
||||
[PreserveSig]
|
||||
int GetId([MarshalAs(UnmanagedType.LPWStr)] out string? id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,522 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace Kniip.Recording;
|
||||
|
||||
/// <summary>
|
||||
/// Geanimeerde GIF-encoder voor schermopnames. Elk frame krijgt een eigen palet van maximaal 255 kleuren
|
||||
/// (exact als het frame zo weinig kleuren heeft, anders via octree-quantisatie). Alleen het gewijzigde deel
|
||||
/// wordt opgeslagen en ongewijzigde pixels daarin zijn transparant, zodat bestanden klein blijven.
|
||||
/// </summary>
|
||||
internal sealed class GifEncoder : IDisposable
|
||||
{
|
||||
private const int TransparentIndex = 255;
|
||||
private const int MaxColors = 255;
|
||||
|
||||
private readonly Stream _stream;
|
||||
private readonly int _width;
|
||||
private readonly int _height;
|
||||
private byte[]? _previous;
|
||||
private bool _finished;
|
||||
|
||||
public GifEncoder(Stream stream, int width, int height)
|
||||
{
|
||||
_stream = stream;
|
||||
_width = width;
|
||||
_height = height;
|
||||
WriteHeader();
|
||||
}
|
||||
|
||||
/// <summary>Voegt een BGRA-frame toe dat <paramref name="delayCentiseconds"/> zichtbaar blijft.</summary>
|
||||
public void AddFrame(byte[] bgra, int delayCentiseconds)
|
||||
{
|
||||
int left = 0, top = 0, right = _width - 1, bottom = _height - 1;
|
||||
if (_previous != null && !FindChangedArea(bgra, _previous, out left, out top, out right, out bottom))
|
||||
{
|
||||
// Niets veranderd: één transparante pixel zodat de vertraging behouden blijft.
|
||||
left = top = right = bottom = 0;
|
||||
}
|
||||
|
||||
int areaWidth = right - left + 1;
|
||||
int areaHeight = bottom - top + 1;
|
||||
var argb = new int[areaWidth * areaHeight];
|
||||
var changed = new bool[argb.Length];
|
||||
for (int y = 0; y < areaHeight; y++)
|
||||
{
|
||||
int rowOffset = ((top + y) * _width + left) * 4;
|
||||
for (int x = 0; x < areaWidth; x++)
|
||||
{
|
||||
int i = rowOffset + x * 4;
|
||||
int color = (bgra[i + 2] << 16) | (bgra[i + 1] << 8) | bgra[i];
|
||||
argb[y * areaWidth + x] = color;
|
||||
changed[y * areaWidth + x] = _previous == null
|
||||
|| bgra[i] != _previous[i] || bgra[i + 1] != _previous[i + 1] || bgra[i + 2] != _previous[i + 2];
|
||||
}
|
||||
}
|
||||
|
||||
(int[] palette, byte[] indices) = Quantize(argb, changed);
|
||||
WriteFrame(left, top, areaWidth, areaHeight, palette, indices, Math.Max(2, delayCentiseconds), _previous != null);
|
||||
_previous = bgra;
|
||||
}
|
||||
|
||||
public void Finish()
|
||||
{
|
||||
if (_finished)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_finished = true;
|
||||
_stream.WriteByte(0x3B);
|
||||
_stream.Flush();
|
||||
}
|
||||
|
||||
public void Dispose() => Finish();
|
||||
|
||||
private bool FindChangedArea(byte[] current, byte[] previous, out int left, out int top, out int right, out int bottom)
|
||||
{
|
||||
left = _width;
|
||||
top = _height;
|
||||
right = -1;
|
||||
bottom = -1;
|
||||
for (int y = 0; y < _height; y++)
|
||||
{
|
||||
int row = y * _width * 4;
|
||||
for (int x = 0; x < _width; x++)
|
||||
{
|
||||
int i = row + x * 4;
|
||||
if (current[i] != previous[i] || current[i + 1] != previous[i + 1] || current[i + 2] != previous[i + 2])
|
||||
{
|
||||
if (x < left) left = x;
|
||||
if (x > right) right = x;
|
||||
if (y < top) top = y;
|
||||
bottom = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return right >= 0;
|
||||
}
|
||||
|
||||
private static (int[] Palette, byte[] Indices) Quantize(int[] colors, bool[] changed)
|
||||
{
|
||||
var exact = new Dictionary<int, byte>();
|
||||
bool fits = true;
|
||||
for (int i = 0; i < colors.Length && fits; i++)
|
||||
{
|
||||
if (changed[i] && !exact.ContainsKey(colors[i]))
|
||||
{
|
||||
if (exact.Count == MaxColors)
|
||||
{
|
||||
fits = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
exact[colors[i]] = (byte)exact.Count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int[] palette;
|
||||
Func<int, byte> lookup;
|
||||
if (fits)
|
||||
{
|
||||
palette = new int[exact.Count];
|
||||
foreach (KeyValuePair<int, byte> entry in exact)
|
||||
{
|
||||
palette[entry.Value] = entry.Key;
|
||||
}
|
||||
|
||||
lookup = color => exact[color];
|
||||
}
|
||||
else
|
||||
{
|
||||
var octree = new Octree();
|
||||
for (int i = 0; i < colors.Length; i++)
|
||||
{
|
||||
if (changed[i])
|
||||
{
|
||||
octree.Add(colors[i]);
|
||||
}
|
||||
}
|
||||
|
||||
palette = octree.BuildPalette(MaxColors);
|
||||
var cache = new Dictionary<int, byte>();
|
||||
int[] localPalette = palette;
|
||||
lookup = color =>
|
||||
{
|
||||
if (!cache.TryGetValue(color, out byte index))
|
||||
{
|
||||
index = Nearest(localPalette, color);
|
||||
cache[color] = index;
|
||||
}
|
||||
|
||||
return index;
|
||||
};
|
||||
}
|
||||
|
||||
var indices = new byte[colors.Length];
|
||||
for (int i = 0; i < colors.Length; i++)
|
||||
{
|
||||
indices[i] = changed[i] ? lookup(colors[i]) : (byte)TransparentIndex;
|
||||
}
|
||||
|
||||
return (palette, indices);
|
||||
}
|
||||
|
||||
private static byte Nearest(int[] palette, int color)
|
||||
{
|
||||
int r = (color >> 16) & 0xFF, g = (color >> 8) & 0xFF, b = color & 0xFF;
|
||||
int best = 0, bestDistance = int.MaxValue;
|
||||
for (int i = 0; i < palette.Length; i++)
|
||||
{
|
||||
int pr = (palette[i] >> 16) & 0xFF, pg = (palette[i] >> 8) & 0xFF, pb = palette[i] & 0xFF;
|
||||
int distance = (r - pr) * (r - pr) * 2 + (g - pg) * (g - pg) * 4 + (b - pb) * (b - pb) * 3;
|
||||
if (distance < bestDistance)
|
||||
{
|
||||
bestDistance = distance;
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
|
||||
return (byte)best;
|
||||
}
|
||||
|
||||
private void WriteHeader()
|
||||
{
|
||||
_stream.Write(Encoding.ASCII.GetBytes("GIF89a"));
|
||||
WriteUInt16(_width);
|
||||
WriteUInt16(_height);
|
||||
_stream.WriteByte(0x70); // geen globaal palet, 8 bits kleurresolutie
|
||||
_stream.WriteByte(0);
|
||||
_stream.WriteByte(0);
|
||||
|
||||
// NETSCAPE2.0: oneindig herhalen.
|
||||
_stream.Write(new byte[] { 0x21, 0xFF, 0x0B });
|
||||
_stream.Write(Encoding.ASCII.GetBytes("NETSCAPE2.0"));
|
||||
_stream.Write(new byte[] { 0x03, 0x01, 0x00, 0x00, 0x00 });
|
||||
}
|
||||
|
||||
private void WriteFrame(int left, int top, int width, int height, int[] palette, byte[] indices, int delay, bool transparent)
|
||||
{
|
||||
// Graphic Control Extension: frame laten staan (disposal 1), eventueel met transparante index.
|
||||
_stream.Write(new byte[] { 0x21, 0xF9, 0x04, (byte)((1 << 2) | (transparent ? 1 : 0)) });
|
||||
WriteUInt16(delay);
|
||||
_stream.WriteByte(TransparentIndex);
|
||||
_stream.WriteByte(0);
|
||||
|
||||
_stream.WriteByte(0x2C);
|
||||
WriteUInt16(left);
|
||||
WriteUInt16(top);
|
||||
WriteUInt16(width);
|
||||
WriteUInt16(height);
|
||||
_stream.WriteByte(0x87); // lokaal palet van 256 kleuren
|
||||
|
||||
var table = new byte[256 * 3];
|
||||
for (int i = 0; i < palette.Length; i++)
|
||||
{
|
||||
table[i * 3] = (byte)(palette[i] >> 16);
|
||||
table[i * 3 + 1] = (byte)(palette[i] >> 8);
|
||||
table[i * 3 + 2] = (byte)palette[i];
|
||||
}
|
||||
|
||||
_stream.Write(table);
|
||||
LzwEncoder.Encode(_stream, indices);
|
||||
}
|
||||
|
||||
private void WriteUInt16(int value)
|
||||
{
|
||||
_stream.WriteByte((byte)(value & 0xFF));
|
||||
_stream.WriteByte((byte)((value >> 8) & 0xFF));
|
||||
}
|
||||
|
||||
/// <summary>Klassieke octree-kleurreductie.</summary>
|
||||
private sealed class Octree
|
||||
{
|
||||
private readonly List<Node>[] _levels = new List<Node>[8];
|
||||
private readonly Node _root;
|
||||
private int _leafCount;
|
||||
|
||||
public Octree()
|
||||
{
|
||||
for (int i = 0; i < _levels.Length; i++)
|
||||
{
|
||||
_levels[i] = new List<Node>();
|
||||
}
|
||||
|
||||
_root = new Node();
|
||||
}
|
||||
|
||||
public void Add(int color)
|
||||
{
|
||||
int r = (color >> 16) & 0xFF, g = (color >> 8) & 0xFF, b = color & 0xFF;
|
||||
Node node = _root;
|
||||
for (int level = 0; level < 8; level++)
|
||||
{
|
||||
if (node.IsLeaf)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
int shift = 7 - level;
|
||||
int index = (((r >> shift) & 1) << 2) | (((g >> shift) & 1) << 1) | ((b >> shift) & 1);
|
||||
node.Children ??= new Node?[8];
|
||||
Node? child = node.Children[index];
|
||||
if (child == null)
|
||||
{
|
||||
child = new Node { IsLeaf = level == 7 };
|
||||
node.Children[index] = child;
|
||||
if (child.IsLeaf)
|
||||
{
|
||||
_leafCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
_levels[level + 1].Add(child);
|
||||
}
|
||||
}
|
||||
|
||||
node = child;
|
||||
}
|
||||
|
||||
node.Red += r;
|
||||
node.Green += g;
|
||||
node.Blue += b;
|
||||
node.Count++;
|
||||
}
|
||||
|
||||
public int[] BuildPalette(int maxColors)
|
||||
{
|
||||
for (int level = 7; level > 0 && _leafCount > maxColors; level--)
|
||||
{
|
||||
List<Node> nodes = _levels[level];
|
||||
nodes.Sort((a, b) => a.TotalCount().CompareTo(b.TotalCount()));
|
||||
foreach (Node node in nodes)
|
||||
{
|
||||
if (_leafCount <= maxColors)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
_leafCount -= node.Merge() - 1;
|
||||
}
|
||||
}
|
||||
|
||||
var palette = new List<int>();
|
||||
Collect(_root, palette);
|
||||
if (palette.Count > maxColors)
|
||||
{
|
||||
palette.RemoveRange(maxColors, palette.Count - maxColors);
|
||||
}
|
||||
|
||||
return palette.ToArray();
|
||||
}
|
||||
|
||||
private static void Collect(Node node, List<int> palette)
|
||||
{
|
||||
if (node.IsLeaf)
|
||||
{
|
||||
if (node.Count > 0)
|
||||
{
|
||||
palette.Add(((int)(node.Red / node.Count) << 16) | ((int)(node.Green / node.Count) << 8)
|
||||
| (int)(node.Blue / node.Count));
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.Children == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (Node? child in node.Children)
|
||||
{
|
||||
if (child != null)
|
||||
{
|
||||
Collect(child, palette);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Node
|
||||
{
|
||||
public bool IsLeaf;
|
||||
public long Red, Green, Blue, Count;
|
||||
public Node?[]? Children;
|
||||
|
||||
public long TotalCount()
|
||||
{
|
||||
if (IsLeaf || Children == null)
|
||||
{
|
||||
return Count;
|
||||
}
|
||||
|
||||
long total = 0;
|
||||
foreach (Node? child in Children)
|
||||
{
|
||||
if (child != null)
|
||||
{
|
||||
total += child.TotalCount();
|
||||
}
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
/// <summary>Voegt de kinderen samen tot dit blad; geeft het aantal verdwenen bladeren terug.</summary>
|
||||
public int Merge()
|
||||
{
|
||||
if (IsLeaf || Children == null)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
int leaves = 0;
|
||||
foreach (Node? child in Children)
|
||||
{
|
||||
if (child == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
int childLeaves = child.Merge();
|
||||
leaves += childLeaves;
|
||||
Red += child.Red;
|
||||
Green += child.Green;
|
||||
Blue += child.Blue;
|
||||
Count += child.Count;
|
||||
}
|
||||
|
||||
Children = null;
|
||||
IsLeaf = true;
|
||||
return Math.Max(leaves, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>GIF-variant van LZW (variabele codelengte tot 12 bits, LSB eerst, blokken van 255 bytes).</summary>
|
||||
private static class LzwEncoder
|
||||
{
|
||||
private const int MinCodeSize = 8;
|
||||
private const int ClearCode = 1 << MinCodeSize;
|
||||
private const int EndCode = ClearCode + 1;
|
||||
private const int MaxCode = 4095;
|
||||
|
||||
public static void Encode(Stream stream, byte[] pixels)
|
||||
{
|
||||
stream.WriteByte(MinCodeSize);
|
||||
var output = new BlockWriter(stream);
|
||||
var table = new Dictionary<int, int>(4096);
|
||||
int codeSize = MinCodeSize + 1;
|
||||
int nextCode = EndCode + 1;
|
||||
|
||||
output.Write(ClearCode, codeSize);
|
||||
if (pixels.Length == 0)
|
||||
{
|
||||
output.Write(EndCode, codeSize);
|
||||
output.Flush();
|
||||
return;
|
||||
}
|
||||
|
||||
int prefix = pixels[0];
|
||||
for (int i = 1; i < pixels.Length; i++)
|
||||
{
|
||||
int key = (prefix << 8) | pixels[i];
|
||||
if (table.TryGetValue(key, out int code))
|
||||
{
|
||||
prefix = code;
|
||||
continue;
|
||||
}
|
||||
|
||||
output.Write(prefix, codeSize);
|
||||
if (nextCode <= MaxCode)
|
||||
{
|
||||
table[key] = nextCode++;
|
||||
if (nextCode > (1 << codeSize) && codeSize < 12)
|
||||
{
|
||||
codeSize++;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
output.Write(ClearCode, codeSize);
|
||||
table.Clear();
|
||||
codeSize = MinCodeSize + 1;
|
||||
nextCode = EndCode + 1;
|
||||
}
|
||||
|
||||
prefix = pixels[i];
|
||||
}
|
||||
|
||||
output.Write(prefix, codeSize);
|
||||
// De decoder voegt na deze laatste code nog een tabelregel toe en kan daardoor een bit breder lezen.
|
||||
if (nextCode >= (1 << codeSize) && codeSize < 12)
|
||||
{
|
||||
codeSize++;
|
||||
}
|
||||
|
||||
output.Write(EndCode, codeSize);
|
||||
output.Flush();
|
||||
}
|
||||
|
||||
private sealed class BlockWriter
|
||||
{
|
||||
private readonly Stream _stream;
|
||||
private readonly byte[] _block = new byte[255];
|
||||
private int _blockLength;
|
||||
private int _bitBuffer;
|
||||
private int _bitCount;
|
||||
|
||||
public BlockWriter(Stream stream) => _stream = stream;
|
||||
|
||||
public void Write(int code, int size)
|
||||
{
|
||||
_bitBuffer |= code << _bitCount;
|
||||
_bitCount += size;
|
||||
while (_bitCount >= 8)
|
||||
{
|
||||
AddByte((byte)(_bitBuffer & 0xFF));
|
||||
_bitBuffer >>= 8;
|
||||
_bitCount -= 8;
|
||||
}
|
||||
}
|
||||
|
||||
public void Flush()
|
||||
{
|
||||
if (_bitCount > 0)
|
||||
{
|
||||
AddByte((byte)(_bitBuffer & 0xFF));
|
||||
_bitBuffer = 0;
|
||||
_bitCount = 0;
|
||||
}
|
||||
|
||||
WriteBlock();
|
||||
_stream.WriteByte(0);
|
||||
}
|
||||
|
||||
private void AddByte(byte value)
|
||||
{
|
||||
_block[_blockLength++] = value;
|
||||
if (_blockLength == _block.Length)
|
||||
{
|
||||
WriteBlock();
|
||||
}
|
||||
}
|
||||
|
||||
private void WriteBlock()
|
||||
{
|
||||
if (_blockLength == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_stream.WriteByte((byte)_blockLength);
|
||||
_stream.Write(_block, 0, _blockLength);
|
||||
_blockLength = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Kniip.Recording;
|
||||
|
||||
/// <summary>Zet (een deel van) een MP4 om naar een geanimeerde GIF.</summary>
|
||||
internal static class GifExporter
|
||||
{
|
||||
public static Task ExportAsync(string source, string target, TimeSpan start, TimeSpan end, int fps, int maxWidth,
|
||||
IProgress<double>? progress, CancellationToken cancellationToken) =>
|
||||
Task.Run(() => Export(source, target, start, end, fps, maxWidth, progress, cancellationToken), cancellationToken);
|
||||
|
||||
private static void Export(string source, string target, TimeSpan start, TimeSpan end, int fps, int maxWidth,
|
||||
IProgress<double>? progress, CancellationToken cancellationToken)
|
||||
{
|
||||
fps = Math.Clamp(fps, 1, 50);
|
||||
using var reader = new VideoFrameReader(source);
|
||||
(int width, int height) = TargetSize(reader.Width, reader.Height, maxWidth);
|
||||
TimeSpan step = TimeSpan.FromTicks(TimeSpan.TicksPerSecond / fps);
|
||||
if (end <= start)
|
||||
{
|
||||
end = start + step;
|
||||
}
|
||||
|
||||
string temp = target + ".tmp";
|
||||
try
|
||||
{
|
||||
using (var stream = new FileStream(temp, FileMode.Create, FileAccess.Write, FileShare.None, 1 << 16))
|
||||
using (var encoder = new GifEncoder(stream, width, height))
|
||||
{
|
||||
var writer = new FrameWriter(encoder, start);
|
||||
VideoFrame? current = null;
|
||||
TimeSpan nextTarget = start;
|
||||
|
||||
foreach (VideoFrame frame in reader.ReadFrames(start, cancellationToken))
|
||||
{
|
||||
// Het frame dat op tijdstip t zichtbaar is, is het laatste frame met een tijd <= t.
|
||||
while (nextTarget < end && frame.Time > nextTarget)
|
||||
{
|
||||
writer.Show(current ?? frame, nextTarget, width, height);
|
||||
nextTarget += step;
|
||||
}
|
||||
|
||||
current = frame;
|
||||
if (nextTarget >= end)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
progress?.Report(Math.Clamp((frame.Time - start).TotalMilliseconds / (end - start).TotalMilliseconds, 0, 1));
|
||||
}
|
||||
|
||||
while (current != null && nextTarget < end)
|
||||
{
|
||||
writer.Show(current, nextTarget, width, height);
|
||||
nextTarget += step;
|
||||
}
|
||||
|
||||
writer.Complete(end);
|
||||
encoder.Finish();
|
||||
}
|
||||
|
||||
File.Move(temp, target, overwrite: true);
|
||||
progress?.Report(1);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(temp))
|
||||
{
|
||||
File.Delete(temp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static (int Width, int Height) TargetSize(int width, int height, int maxWidth)
|
||||
{
|
||||
if (maxWidth <= 0 || width <= maxWidth)
|
||||
{
|
||||
return (width, height);
|
||||
}
|
||||
|
||||
return (maxWidth, Math.Max(1, (int)Math.Round(height * (double)maxWidth / width)));
|
||||
}
|
||||
|
||||
/// <summary>Voegt opeenvolgende identieke frames samen tot één frame met een langere vertraging.</summary>
|
||||
private sealed class FrameWriter
|
||||
{
|
||||
private readonly GifEncoder _encoder;
|
||||
private readonly TimeSpan _origin;
|
||||
private VideoFrame? _source;
|
||||
private byte[]? _pending;
|
||||
private TimeSpan _pendingStart;
|
||||
private int _writtenCentiseconds;
|
||||
|
||||
public FrameWriter(GifEncoder encoder, TimeSpan origin)
|
||||
{
|
||||
_encoder = encoder;
|
||||
_origin = origin;
|
||||
}
|
||||
|
||||
public void Show(VideoFrame frame, TimeSpan time, int width, int height)
|
||||
{
|
||||
if (ReferenceEquals(frame, _source))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
byte[] pixels = Scale(frame, width, height);
|
||||
_source = frame;
|
||||
if (_pending != null && pixels.AsSpan().SequenceEqual(_pending))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Flush(time);
|
||||
_pending = pixels;
|
||||
_pendingStart = time;
|
||||
}
|
||||
|
||||
public void Complete(TimeSpan end) => Flush(end);
|
||||
|
||||
private void Flush(TimeSpan until)
|
||||
{
|
||||
if (_pending == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Afronden op de tijdlijn in plaats van per frame, zodat afrondingsfouten zich niet opstapelen.
|
||||
int endCentiseconds = (int)Math.Round((until - _origin).TotalMilliseconds / 10);
|
||||
int delay = Math.Max(1, endCentiseconds - _writtenCentiseconds);
|
||||
_encoder.AddFrame(_pending, delay);
|
||||
_writtenCentiseconds += delay;
|
||||
_pending = null;
|
||||
}
|
||||
|
||||
private static byte[] Scale(VideoFrame frame, int width, int height) =>
|
||||
frame.Width == width && frame.Height == height
|
||||
? frame.Pixels
|
||||
: BoxScale(frame.Pixels, frame.Width, frame.Height, width, height);
|
||||
|
||||
/// <summary>Verkleinen door per doelpixel het gemiddelde van het bronvlak te nemen.</summary>
|
||||
private static byte[] BoxScale(byte[] source, int sourceWidth, int sourceHeight, int width, int height)
|
||||
{
|
||||
var result = new byte[width * height * 4];
|
||||
double scaleX = (double)sourceWidth / width;
|
||||
double scaleY = (double)sourceHeight / height;
|
||||
for (int y = 0; y < height; y++)
|
||||
{
|
||||
int y0 = (int)(y * scaleY);
|
||||
int y1 = Math.Max(y0 + 1, Math.Min(sourceHeight, (int)Math.Ceiling((y + 1) * scaleY)));
|
||||
for (int x = 0; x < width; x++)
|
||||
{
|
||||
int x0 = (int)(x * scaleX);
|
||||
int x1 = Math.Max(x0 + 1, Math.Min(sourceWidth, (int)Math.Ceiling((x + 1) * scaleX)));
|
||||
int b = 0, g = 0, r = 0, count = 0;
|
||||
for (int sy = y0; sy < y1; sy++)
|
||||
{
|
||||
int row = sy * sourceWidth * 4;
|
||||
for (int sx = x0; sx < x1; sx++)
|
||||
{
|
||||
int i = row + sx * 4;
|
||||
b += source[i];
|
||||
g += source[i + 1];
|
||||
r += source[i + 2];
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
int o = (y * width + x) * 4;
|
||||
result[o] = (byte)(b / count);
|
||||
result[o + 1] = (byte)(g / count);
|
||||
result[o + 2] = (byte)(r / count);
|
||||
result[o + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.Text;
|
||||
using System.Windows;
|
||||
using System.Windows.Interop;
|
||||
using System.Windows.Media;
|
||||
using Kniip.Interop;
|
||||
|
||||
namespace Kniip.Recording;
|
||||
|
||||
/// <summary>Hulpfuncties om WPF-vensters in fysieke pixels te plaatsen (per-monitor DPI v2).</summary>
|
||||
internal static class PhysicalWindow
|
||||
{
|
||||
public static IntPtr Handle(Window window) => new WindowInteropHelper(window).EnsureHandle();
|
||||
|
||||
public static double Scale(Visual visual) => VisualTreeHelper.GetDpi(visual).DpiScaleX;
|
||||
|
||||
/// <summary>
|
||||
/// Twee keer plaatsen: de eerste keer kan het venster naar een scherm met andere DPI springen,
|
||||
/// waarna WPF de grootte aanpast; de tweede keer zet de juiste fysieke grootte terug.
|
||||
/// </summary>
|
||||
public static void Place(Window window, Rectangle bounds, bool resize = true)
|
||||
{
|
||||
IntPtr hwnd = Handle(window);
|
||||
uint flags = NativeMethods.SWP_NOZORDER | NativeMethods.SWP_NOACTIVATE | (resize ? 0 : NativeMethods.SWP_NOSIZE);
|
||||
NativeMethods.SetWindowPos(hwnd, IntPtr.Zero, bounds.X, bounds.Y, bounds.Width, bounds.Height, flags);
|
||||
NativeMethods.SetWindowPos(hwnd, IntPtr.Zero, bounds.X, bounds.Y, bounds.Width, bounds.Height, flags);
|
||||
}
|
||||
|
||||
public static Rectangle GetBounds(Window window)
|
||||
{
|
||||
NativeMethods.GetWindowRect(Handle(window), out NativeMethods.RECT rect);
|
||||
return new Rectangle(rect.Left, rect.Top, rect.Width, rect.Height);
|
||||
}
|
||||
|
||||
/// <summary>Venster onzichtbaar maken voor schermopnames (Windows 10 2004 en nieuwer).</summary>
|
||||
public static void ExcludeFromCapture(Window window) =>
|
||||
NativeMethods.SetWindowDisplayAffinity(Handle(window), NativeMethods.WDA_EXCLUDEFROMCAPTURE);
|
||||
|
||||
/// <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)
|
||||
{
|
||||
var result = new List<Rectangle>();
|
||||
int ownPid = Environment.ProcessId;
|
||||
var className = new StringBuilder(64);
|
||||
NativeMethods.EnumWindows((hwnd, _) =>
|
||||
{
|
||||
if (!NativeMethods.IsWindowVisible(hwnd) || NativeMethods.IsIconic(hwnd) || NativeMethods.IsCloaked(hwnd))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
NativeMethods.GetWindowThreadProcessId(hwnd, out uint pid);
|
||||
long exStyle = NativeMethods.GetWindowLongPtr(hwnd, NativeMethods.GWL_EXSTYLE).ToInt64();
|
||||
if ((pid == ownPid && !includeOwn) || (exStyle & NativeMethods.WS_EX_TRANSPARENT) != 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
className.Clear();
|
||||
NativeMethods.GetClassName(hwnd, className, className.Capacity);
|
||||
string name = className.ToString();
|
||||
if (name is "Progman" or "WorkerW")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
NativeMethods.RECT rect = NativeMethods.GetVisibleWindowRect(hwnd);
|
||||
if (rect.Width > 8 && rect.Height > 8)
|
||||
{
|
||||
result.Add(new Rectangle(rect.Left, rect.Top, rect.Width, rect.Height));
|
||||
}
|
||||
|
||||
return true;
|
||||
}, IntPtr.Zero);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Windows;
|
||||
using System.Windows.Threading;
|
||||
using Kniip.Interop;
|
||||
using Kniip.Settings;
|
||||
using Screen = System.Windows.Forms.Screen;
|
||||
|
||||
namespace Kniip.Recording;
|
||||
|
||||
internal enum RecordingStage
|
||||
{
|
||||
Idle,
|
||||
Selecting,
|
||||
Ready,
|
||||
Countdown,
|
||||
Recording,
|
||||
Paused,
|
||||
Finishing
|
||||
}
|
||||
|
||||
/// <summary>Stuurt een screencast aan: gebied kiezen, knoppenbalk, aftellen, opnemen en afronden.</summary>
|
||||
internal sealed class RecordingController
|
||||
{
|
||||
private const int CountdownSeconds = 3;
|
||||
|
||||
private readonly AppSettings _settings;
|
||||
private readonly Dispatcher _dispatcher;
|
||||
private readonly List<RegionSelectorWindow> _selectors = new();
|
||||
private readonly DispatcherTimer _countdownTimer = new() { Interval = TimeSpan.FromSeconds(1) };
|
||||
private RegionFrameWindow? _frame;
|
||||
private RecordingToolbar? _toolbar;
|
||||
private RecordingSession? _session;
|
||||
private RecordingRegion? _region;
|
||||
private int _countdown;
|
||||
private bool _discard;
|
||||
private bool _retriedWithoutAudio;
|
||||
|
||||
public RecordingController(AppSettings settings, Dispatcher dispatcher)
|
||||
{
|
||||
_settings = settings;
|
||||
_dispatcher = dispatcher;
|
||||
_countdownTimer.Tick += (_, _) => CountdownTick();
|
||||
}
|
||||
|
||||
public RecordingStage Stage { get; private set; }
|
||||
|
||||
/// <summary>Formaat dat de nabewerking voorstelt; null = het laatst gebruikte.</summary>
|
||||
private ScreencastFormat? _format;
|
||||
|
||||
public event Action<RecordingStage>? StageChanged;
|
||||
|
||||
/// <summary>Pad van een geslaagde opname (tijdelijk MP4-bestand) en het voorgestelde formaat.</summary>
|
||||
public event Action<string, ScreencastFormat?>? RecordingFinished;
|
||||
|
||||
public event Action<string, string>? Notify;
|
||||
|
||||
/// <summary>Sneltoets: kiezen → starten → stoppen. Het formaat telt alleen bij het begin.</summary>
|
||||
public void Toggle(ScreencastFormat format)
|
||||
{
|
||||
if (Stage == RecordingStage.Idle)
|
||||
{
|
||||
BeginSelection(format);
|
||||
}
|
||||
else
|
||||
{
|
||||
Toggle();
|
||||
}
|
||||
}
|
||||
|
||||
public void Toggle()
|
||||
{
|
||||
switch (Stage)
|
||||
{
|
||||
case RecordingStage.Idle:
|
||||
BeginSelection(null);
|
||||
break;
|
||||
case RecordingStage.Selecting:
|
||||
OnRegionChosen(null);
|
||||
break;
|
||||
case RecordingStage.Ready:
|
||||
StartCountdown();
|
||||
break;
|
||||
case RecordingStage.Countdown:
|
||||
_countdownTimer.Stop();
|
||||
StartRecording(CurrentOptions());
|
||||
break;
|
||||
case RecordingStage.Recording:
|
||||
case RecordingStage.Paused:
|
||||
Stop();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Systeemvak: starten, of een lopende opname stoppen, of de voorbereiding annuleren.</summary>
|
||||
public void TrayAction()
|
||||
{
|
||||
switch (Stage)
|
||||
{
|
||||
case RecordingStage.Idle:
|
||||
BeginSelection(null);
|
||||
break;
|
||||
case RecordingStage.Recording:
|
||||
case RecordingStage.Paused:
|
||||
Stop();
|
||||
break;
|
||||
case RecordingStage.Finishing:
|
||||
break;
|
||||
default:
|
||||
Cancel();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void SetStage(RecordingStage stage)
|
||||
{
|
||||
Stage = stage;
|
||||
StageChanged?.Invoke(stage);
|
||||
}
|
||||
|
||||
private void BeginSelection(ScreencastFormat? format)
|
||||
{
|
||||
_format = format;
|
||||
SetStage(RecordingStage.Selecting);
|
||||
List<System.Drawing.Rectangle> windows = PhysicalWindow.SnapshotWindows();
|
||||
NativeMethods.GetCursorPos(out NativeMethods.POINT cursor);
|
||||
var cursorPoint = new System.Drawing.Point(cursor.X, cursor.Y);
|
||||
|
||||
foreach (Screen screen in Screen.AllScreens)
|
||||
{
|
||||
var selector = new RegionSelectorWindow(screen, windows);
|
||||
selector.Finished += OnRegionChosen;
|
||||
_selectors.Add(selector);
|
||||
selector.Show();
|
||||
}
|
||||
|
||||
RegionSelectorWindow? active = _selectors.FirstOrDefault(s => s.ContainsCursor(cursorPoint)) ?? _selectors.FirstOrDefault();
|
||||
active?.Activate();
|
||||
active?.Focus();
|
||||
}
|
||||
|
||||
private void OnRegionChosen(RecordingRegion? region)
|
||||
{
|
||||
foreach (RegionSelectorWindow selector in _selectors)
|
||||
{
|
||||
selector.Finished -= OnRegionChosen;
|
||||
selector.Close();
|
||||
}
|
||||
|
||||
_selectors.Clear();
|
||||
if (region == null)
|
||||
{
|
||||
SetStage(RecordingStage.Idle);
|
||||
return;
|
||||
}
|
||||
|
||||
_region = region;
|
||||
Screen screen = Screen.AllScreens.FirstOrDefault(s => s.DeviceName == region.DeviceName) ?? Screen.PrimaryScreen!;
|
||||
|
||||
_frame = new RegionFrameWindow(region.Bounds);
|
||||
_frame.Show();
|
||||
|
||||
_toolbar = new RecordingToolbar(region.Bounds, screen.WorkingArea, _settings.RecordSystemAudio, _settings.RecordMicrophone);
|
||||
_toolbar.RecordRequested += Toggle;
|
||||
_toolbar.PauseRequested += TogglePause;
|
||||
_toolbar.StopRequested += Stop;
|
||||
_toolbar.CancelRequested += Cancel;
|
||||
_toolbar.SetState(ToolbarState.Ready);
|
||||
_toolbar.Show();
|
||||
_toolbar.Activate();
|
||||
SetStage(RecordingStage.Ready);
|
||||
}
|
||||
|
||||
private RecordingOptions CurrentOptions()
|
||||
{
|
||||
if (_toolbar != null)
|
||||
{
|
||||
_settings.RecordSystemAudio = _toolbar.SystemAudio;
|
||||
_settings.RecordMicrophone = _toolbar.Microphone;
|
||||
}
|
||||
|
||||
return new RecordingOptions(_settings.RecordSystemAudio, _settings.RecordMicrophone,
|
||||
_settings.RecordShowCursor, _settings.RecordHighlightClicks, Math.Clamp(_settings.RecordFps, 5, 60));
|
||||
}
|
||||
|
||||
private void StartCountdown()
|
||||
{
|
||||
if (Stage != RecordingStage.Ready || _toolbar == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_countdown = CountdownSeconds;
|
||||
_toolbar.SetState(ToolbarState.Countdown);
|
||||
_toolbar.SetCountdown(_countdown);
|
||||
SetStage(RecordingStage.Countdown);
|
||||
_countdownTimer.Start();
|
||||
}
|
||||
|
||||
private void CountdownTick()
|
||||
{
|
||||
_countdown--;
|
||||
if (_countdown > 0)
|
||||
{
|
||||
_toolbar?.SetCountdown(_countdown);
|
||||
return;
|
||||
}
|
||||
|
||||
_countdownTimer.Stop();
|
||||
StartRecording(CurrentOptions());
|
||||
}
|
||||
|
||||
private void StartRecording(RecordingOptions options)
|
||||
{
|
||||
if (_region == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_session = new RecordingSession(_region, options, _dispatcher);
|
||||
_session.Completed += OnCompleted;
|
||||
_session.Failed += OnFailed;
|
||||
_toolbar?.SetState(ToolbarState.Recording);
|
||||
_frame?.SetRecording(true);
|
||||
SetStage(RecordingStage.Recording);
|
||||
|
||||
try
|
||||
{
|
||||
_session.Start();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
OnFailed(ex.Message, false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_session.MissingAudio != null)
|
||||
{
|
||||
Notify?.Invoke("Opname zonder " + _session.MissingAudio,
|
||||
$"Er is geen {_session.MissingAudio} gevonden op deze pc; de opname gaat daarzonder verder.");
|
||||
}
|
||||
}
|
||||
|
||||
private void TogglePause()
|
||||
{
|
||||
if (_session == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (Stage == RecordingStage.Paused)
|
||||
{
|
||||
_session.Resume();
|
||||
_toolbar?.SetState(ToolbarState.Recording);
|
||||
SetStage(RecordingStage.Recording);
|
||||
}
|
||||
else if (Stage == RecordingStage.Recording)
|
||||
{
|
||||
_session.Pause();
|
||||
_toolbar?.SetState(ToolbarState.Paused);
|
||||
SetStage(RecordingStage.Paused);
|
||||
}
|
||||
}
|
||||
|
||||
private void Stop()
|
||||
{
|
||||
if (_session == null || Stage is not (RecordingStage.Recording or RecordingStage.Paused))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_toolbar?.SetState(ToolbarState.Finishing);
|
||||
SetStage(RecordingStage.Finishing);
|
||||
_session.Stop();
|
||||
}
|
||||
|
||||
private void Cancel()
|
||||
{
|
||||
_countdownTimer.Stop();
|
||||
if (_session != null && Stage is RecordingStage.Recording or RecordingStage.Paused)
|
||||
{
|
||||
_discard = true;
|
||||
Stop();
|
||||
return;
|
||||
}
|
||||
|
||||
if (Stage == RecordingStage.Selecting)
|
||||
{
|
||||
OnRegionChosen(null);
|
||||
return;
|
||||
}
|
||||
|
||||
EndAll();
|
||||
}
|
||||
|
||||
private void OnCompleted(string path)
|
||||
{
|
||||
bool discard = _discard;
|
||||
ScreencastFormat? format = _format;
|
||||
EndAll();
|
||||
if (discard)
|
||||
{
|
||||
TryDelete(path);
|
||||
return;
|
||||
}
|
||||
|
||||
RecordingFinished?.Invoke(path, format);
|
||||
}
|
||||
|
||||
private void OnFailed(string error, bool started)
|
||||
{
|
||||
RecordingOptions? options = _session?.Options;
|
||||
string? path = _session?.OutputPath;
|
||||
DisposeSession();
|
||||
|
||||
if (!started && options is { HasAudio: true } && !_retriedWithoutAudio && !_discard)
|
||||
{
|
||||
_retriedWithoutAudio = true;
|
||||
Notify?.Invoke("Opname zonder geluid", "Opnemen met geluid lukte niet; kniip neemt nu op zonder geluid.");
|
||||
StartRecording(options with { SystemAudio = false, Microphone = false });
|
||||
return;
|
||||
}
|
||||
|
||||
bool discard = _discard;
|
||||
EndAll();
|
||||
if (path != null)
|
||||
{
|
||||
TryDelete(path);
|
||||
}
|
||||
|
||||
if (!discard)
|
||||
{
|
||||
MessageBox.Show($"De opname is mislukt.\n\n{error}", "kniip", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
private void DisposeSession()
|
||||
{
|
||||
if (_session == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_session.Completed -= OnCompleted;
|
||||
_session.Failed -= OnFailed;
|
||||
RecordingSession session = _session;
|
||||
_session = null;
|
||||
// Disposen vanuit een callback van de recorder kan vastlopen; daarom even later.
|
||||
_dispatcher.BeginInvoke(session.Dispose, DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
private void EndAll()
|
||||
{
|
||||
_countdownTimer.Stop();
|
||||
DisposeSession();
|
||||
_toolbar?.Close();
|
||||
_toolbar = null;
|
||||
_frame?.Close();
|
||||
_frame = null;
|
||||
_region = null;
|
||||
_format = null;
|
||||
_discard = false;
|
||||
_retriedWithoutAudio = false;
|
||||
SetStage(RecordingStage.Idle);
|
||||
}
|
||||
|
||||
private static void TryDelete(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Tijdelijk bestand; Windows ruimt het later op.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using System;
|
||||
using System.Drawing;
|
||||
|
||||
namespace Kniip.Recording;
|
||||
|
||||
/// <summary>Opnamegebied in fysieke schermpixels (bureaubladcoördinaten) binnen één beeldscherm.</summary>
|
||||
internal sealed record RecordingRegion(string DeviceName, Rectangle Bounds, Rectangle MonitorBounds)
|
||||
{
|
||||
/// <summary>H.264 vereist even afmetingen; het gebied wordt daarom zo nodig 1 pixel kleiner.</summary>
|
||||
public static RecordingRegion Create(string deviceName, Rectangle bounds, Rectangle monitorBounds)
|
||||
{
|
||||
Rectangle clipped = Rectangle.Intersect(bounds, monitorBounds);
|
||||
int width = Math.Max(2, clipped.Width & ~1);
|
||||
int height = Math.Max(2, clipped.Height & ~1);
|
||||
return new RecordingRegion(deviceName, new Rectangle(clipped.X, clipped.Y, width, height), monitorBounds);
|
||||
}
|
||||
|
||||
/// <summary>Gebied ten opzichte van de linkerbovenhoek van het beeldscherm.</summary>
|
||||
public Rectangle MonitorRelative => new(Bounds.X - MonitorBounds.X, Bounds.Y - MonitorBounds.Y, Bounds.Width, Bounds.Height);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Windows.Threading;
|
||||
using ScreenRecorderLib;
|
||||
|
||||
namespace Kniip.Recording;
|
||||
|
||||
internal sealed record RecordingOptions(bool SystemAudio, bool Microphone, bool ShowCursor, bool HighlightClicks, int Fps)
|
||||
{
|
||||
public bool HasAudio => SystemAudio || Microphone;
|
||||
}
|
||||
|
||||
/// <summary>Eén opname naar een tijdelijk MP4-bestand. Gebeurtenissen komen binnen op de UI-thread.</summary>
|
||||
internal sealed class RecordingSession : IDisposable
|
||||
{
|
||||
public static string TempFolder { get; } = Path.Combine(Path.GetTempPath(), "Kniip");
|
||||
|
||||
private readonly Dispatcher _dispatcher;
|
||||
private readonly RecordingRegion _region;
|
||||
private Recorder? _recorder;
|
||||
private bool _everRecorded;
|
||||
|
||||
public event Action<string>? Completed;
|
||||
|
||||
/// <summary>Foutmelding en of de opname al gestart was (anders is opnieuw proberen zinvol).</summary>
|
||||
public event Action<string, bool>? Failed;
|
||||
|
||||
public RecordingSession(RecordingRegion region, RecordingOptions options, Dispatcher dispatcher)
|
||||
{
|
||||
_region = region;
|
||||
Options = options;
|
||||
_dispatcher = dispatcher;
|
||||
OutputPath = Path.Combine(TempFolder, $"opname-{DateTime.Now:yyyyMMdd-HHmmss}-{Guid.NewGuid():N}.mp4");
|
||||
}
|
||||
|
||||
public RecordingOptions Options { get; }
|
||||
|
||||
public string OutputPath { get; }
|
||||
|
||||
/// <summary>Gekozen geluidsbron(nen) die niet bestaan op deze pc.</summary>
|
||||
public string? MissingAudio { get; private set; }
|
||||
|
||||
public bool IsPaused => _recorder?.Status == RecorderStatus.Paused;
|
||||
|
||||
public void Start()
|
||||
{
|
||||
Directory.CreateDirectory(TempFolder);
|
||||
_recorder = Recorder.CreateRecorder(BuildOptions());
|
||||
_recorder.OnStatusChanged += (_, e) =>
|
||||
{
|
||||
if (e.Status == RecorderStatus.Recording)
|
||||
{
|
||||
_everRecorded = true;
|
||||
}
|
||||
};
|
||||
_recorder.OnRecordingComplete += (_, e) => _dispatcher.BeginInvoke(() => Completed?.Invoke(e.FilePath));
|
||||
_recorder.OnRecordingFailed += (_, e) =>
|
||||
{
|
||||
bool started = _everRecorded;
|
||||
_dispatcher.BeginInvoke(() => Failed?.Invoke(e.Error, started));
|
||||
};
|
||||
_recorder.Record(OutputPath);
|
||||
}
|
||||
|
||||
/// <summary>Verwijdert achtergebleven opnames en klembordkopieën van meer dan een dag oud.</summary>
|
||||
public static void CleanupTempFolder()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(TempFolder))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
DateTime limit = DateTime.Now.AddDays(-1);
|
||||
foreach (string file in Directory.EnumerateFiles(TempFolder, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
if (File.GetLastWriteTime(file) < limit)
|
||||
{
|
||||
File.Delete(file);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (string folder in Directory.EnumerateDirectories(TempFolder, "*", SearchOption.AllDirectories).Reverse())
|
||||
{
|
||||
if (!Directory.EnumerateFileSystemEntries(folder).Any())
|
||||
{
|
||||
Directory.Delete(folder);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Opruimen is best effort.
|
||||
}
|
||||
}
|
||||
|
||||
public void Pause() => _recorder?.Pause();
|
||||
|
||||
public void Resume() => _recorder?.Resume();
|
||||
|
||||
public void Stop() => _recorder?.Stop();
|
||||
|
||||
private RecorderOptions BuildOptions()
|
||||
{
|
||||
System.Drawing.Rectangle rect = _region.MonitorRelative;
|
||||
var source = new DisplayRecordingSource(_region.DeviceName)
|
||||
{
|
||||
SourceRect = new ScreenRect(rect.X, rect.Y, rect.Width, rect.Height),
|
||||
IsCursorCaptureEnabled = Options.ShowCursor
|
||||
};
|
||||
|
||||
var audioSources = new List<AudioSourceBase>();
|
||||
var missing = new List<string>();
|
||||
if (Options.SystemAudio)
|
||||
{
|
||||
AddAudio(audioSources, missing, AudioDevices.DefaultPlaybackId, id => new LoopbackAudioSource(id), "systeemgeluid");
|
||||
}
|
||||
|
||||
if (Options.Microphone)
|
||||
{
|
||||
AddAudio(audioSources, missing, AudioDevices.DefaultMicrophoneId, id => new CaptureAudioSource(id), "microfoon");
|
||||
}
|
||||
|
||||
MissingAudio = missing.Count == 0 ? null : string.Join(" en ", missing);
|
||||
|
||||
return new RecorderOptions
|
||||
{
|
||||
SourceOptions = new SourceOptions { RecordingSources = new List<RecordingSourceBase> { source } },
|
||||
OutputOptions = new OutputOptions
|
||||
{
|
||||
RecorderMode = RecorderMode.Video,
|
||||
OutputFrameSize = new ScreenSize(rect.Width, rect.Height),
|
||||
Stretch = StretchMode.Uniform
|
||||
},
|
||||
AudioOptions = new AudioOptions
|
||||
{
|
||||
IsAudioEnabled = audioSources.Count > 0,
|
||||
AudioSources = audioSources
|
||||
},
|
||||
MouseOptions = new MouseOptions
|
||||
{
|
||||
IsMousePointerEnabled = Options.ShowCursor,
|
||||
IsMouseClicksDetected = Options.HighlightClicks,
|
||||
MouseClickDetectionMode = MouseDetectionMode.Hook,
|
||||
MouseLeftClickDetectionColor = "#FFD400",
|
||||
MouseRightClickDetectionColor = "#00B4FF",
|
||||
MouseClickDetectionRadius = 22,
|
||||
MouseClickDetectionDuration = 200
|
||||
},
|
||||
VideoEncoderOptions = new VideoEncoderOptions
|
||||
{
|
||||
Encoder = new H264VideoEncoder
|
||||
{
|
||||
BitrateMode = H264BitrateControlMode.Quality,
|
||||
EncoderProfile = H264Profile.High
|
||||
},
|
||||
Quality = 80,
|
||||
Framerate = Options.Fps,
|
||||
IsHardwareEncodingEnabled = true,
|
||||
IsMp4FastStartEnabled = true
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static void AddAudio(List<AudioSourceBase> sources, List<string> missing, string? deviceId,
|
||||
Func<string, AudioSourceBase> create, string label)
|
||||
{
|
||||
if (deviceId == null)
|
||||
{
|
||||
missing.Add(label);
|
||||
return;
|
||||
}
|
||||
|
||||
sources.Add(create(deviceId));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_recorder?.Dispose();
|
||||
_recorder = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<Window x:Class="Kniip.Recording.RecordingToolbar"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="kniip opname" WindowStyle="None" AllowsTransparency="True" Background="Transparent"
|
||||
ResizeMode="NoResize" SizeToContent="WidthAndHeight" ShowInTaskbar="False" Topmost="True"
|
||||
FontFamily="Segoe UI" FontSize="13">
|
||||
<Window.Resources>
|
||||
<Style x:Key="RecordStyle" TargetType="Button" BasedOn="{StaticResource AccentButton}">
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border x:Name="Bd" Background="#E81123" CornerRadius="4" Padding="{TemplateBinding Padding}">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="#F1394A" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsPressed" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="#C50F1F" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
</Window.Resources>
|
||||
<Border Margin="10" Background="White" BorderBrush="#D6D6D6" BorderThickness="1" CornerRadius="8" Padding="6"
|
||||
MouseLeftButtonDown="Background_MouseLeftButtonDown">
|
||||
<Border.Effect>
|
||||
<DropShadowEffect BlurRadius="14" ShadowDepth="2" Opacity="0.25" />
|
||||
</Border.Effect>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<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" />
|
||||
<TextBlock x:Name="TimeText" Text="0:00" FontWeight="SemiBold" MinWidth="40" Foreground="{StaticResource InkBrush}" />
|
||||
</StackPanel>
|
||||
|
||||
<ToggleButton x:Name="SystemAudioToggle" Style="{StaticResource MiniToggle}" Height="34" MinWidth="34"
|
||||
ToolTip="Systeemgeluid opnemen">
|
||||
<TextBlock Text="" Style="{StaticResource IconText}" />
|
||||
</ToggleButton>
|
||||
<ToggleButton x:Name="MicrophoneToggle" Style="{StaticResource MiniToggle}" Height="34" MinWidth="34"
|
||||
ToolTip="Microfoon opnemen">
|
||||
<TextBlock Text="" Style="{StaticResource IconText}" />
|
||||
</ToggleButton>
|
||||
|
||||
<Button x:Name="RecordButton" Style="{StaticResource RecordStyle}" Click="Record_Click"
|
||||
ToolTip="Opname starten" MinWidth="104">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Ellipse x:Name="RecordButtonDot" Width="10" Height="10" Fill="White" Margin="0,0,7,0" VerticalAlignment="Center" />
|
||||
<TextBlock x:Name="RecordButtonText" Text="Opnemen" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Button x:Name="PauseButton" Style="{StaticResource FlatButton}" Click="Pause_Click" Visibility="Collapsed"
|
||||
ToolTip="Pauzeren">
|
||||
<TextBlock x:Name="PauseIcon" Text="" Style="{StaticResource IconText}" />
|
||||
</Button>
|
||||
<Button x:Name="StopButton" Style="{StaticResource RecordStyle}" Click="Stop_Click" Visibility="Collapsed"
|
||||
ToolTip="Opname stoppen">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Rectangle Width="10" Height="10" Fill="White" RadiusX="1.5" RadiusY="1.5" Margin="0,0,7,0" VerticalAlignment="Center" />
|
||||
<TextBlock Text="Stoppen" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<TextBlock x:Name="BusyText" Text="Opname afronden..." VerticalAlignment="Center" Margin="8,0" Visibility="Collapsed"
|
||||
Foreground="{StaticResource SubtleTextBrush}" />
|
||||
|
||||
<Button x:Name="CancelButton" Style="{StaticResource FlatButton}" Click="Cancel_Click" ToolTip="Annuleren (opname weggooien)">
|
||||
<TextBlock Text="" Style="{StaticResource IconText}" FontSize="14" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Window>
|
||||
@@ -0,0 +1,143 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Windows;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Threading;
|
||||
using Rectangle = System.Drawing.Rectangle;
|
||||
|
||||
namespace Kniip.Recording;
|
||||
|
||||
internal enum ToolbarState
|
||||
{
|
||||
Ready,
|
||||
Countdown,
|
||||
Recording,
|
||||
Paused,
|
||||
Finishing
|
||||
}
|
||||
|
||||
/// <summary>Knoppenbalk naast het opnamegebied; hij is zelf onzichtbaar in de opname.</summary>
|
||||
public partial class RecordingToolbar : Window
|
||||
{
|
||||
private readonly Rectangle _region;
|
||||
private readonly Rectangle _workArea;
|
||||
private readonly Stopwatch _elapsed = new();
|
||||
private readonly DispatcherTimer _clock = new() { Interval = TimeSpan.FromMilliseconds(250) };
|
||||
|
||||
public event Action? RecordRequested;
|
||||
public event Action? PauseRequested;
|
||||
public event Action? StopRequested;
|
||||
public event Action? CancelRequested;
|
||||
|
||||
internal RecordingToolbar(Rectangle region, Rectangle workArea, bool systemAudio, bool microphone)
|
||||
{
|
||||
InitializeComponent();
|
||||
_region = region;
|
||||
_workArea = workArea;
|
||||
SystemAudioToggle.IsChecked = systemAudio;
|
||||
MicrophoneToggle.IsChecked = microphone;
|
||||
|
||||
_clock.Tick += (_, _) => UpdateClock();
|
||||
SourceInitialized += (_, _) =>
|
||||
{
|
||||
PhysicalWindow.ExcludeFromCapture(this);
|
||||
PhysicalWindow.Place(this, new Rectangle(region.X, region.Y, 0, 0), resize: false);
|
||||
};
|
||||
ContentRendered += (_, _) => PlaceNearRegion();
|
||||
Closed += (_, _) => _clock.Stop();
|
||||
}
|
||||
|
||||
public bool SystemAudio => SystemAudioToggle.IsChecked == true;
|
||||
|
||||
public bool Microphone => MicrophoneToggle.IsChecked == true;
|
||||
|
||||
internal void SetState(ToolbarState state)
|
||||
{
|
||||
bool active = state is ToolbarState.Recording or ToolbarState.Paused;
|
||||
SystemAudioToggle.Visibility = Show(state is ToolbarState.Ready or ToolbarState.Countdown);
|
||||
MicrophoneToggle.Visibility = SystemAudioToggle.Visibility;
|
||||
RecordButton.Visibility = Show(state is ToolbarState.Ready or ToolbarState.Countdown);
|
||||
RecordButton.ToolTip = state == ToolbarState.Countdown ? "Nu meteen starten" : "Opname starten";
|
||||
PauseButton.Visibility = Show(active);
|
||||
StopButton.Visibility = Show(active);
|
||||
TimePanel.Visibility = Show(active || state == ToolbarState.Finishing);
|
||||
BusyText.Visibility = Show(state == ToolbarState.Finishing);
|
||||
CancelButton.Visibility = Show(state != ToolbarState.Finishing);
|
||||
|
||||
PauseIcon.Text = state == ToolbarState.Paused ? "\uE768" : "\uE769";
|
||||
PauseButton.ToolTip = state == ToolbarState.Paused ? "Hervatten" : "Pauzeren";
|
||||
RecordDot.Opacity = state == ToolbarState.Recording ? 1 : 0.35;
|
||||
|
||||
if (state == ToolbarState.Ready)
|
||||
{
|
||||
RecordButtonDot.Visibility = Visibility.Visible;
|
||||
RecordButtonText.Text = "Opnemen";
|
||||
}
|
||||
|
||||
if (state == ToolbarState.Recording)
|
||||
{
|
||||
_elapsed.Start();
|
||||
_clock.Start();
|
||||
}
|
||||
else
|
||||
{
|
||||
_elapsed.Stop();
|
||||
}
|
||||
|
||||
UpdateClock();
|
||||
}
|
||||
|
||||
internal void SetCountdown(int seconds)
|
||||
{
|
||||
RecordButtonDot.Visibility = Visibility.Collapsed;
|
||||
RecordButtonText.Text = $"Start over {seconds}...";
|
||||
}
|
||||
|
||||
private static Visibility Show(bool visible) => visible ? Visibility.Visible : Visibility.Collapsed;
|
||||
|
||||
private void UpdateClock()
|
||||
{
|
||||
TimeSpan time = _elapsed.Elapsed;
|
||||
TimeText.Text = time.TotalHours >= 1 ? time.ToString(@"h\:mm\:ss") : time.ToString(@"m\:ss");
|
||||
}
|
||||
|
||||
/// <summary>Onder het gebied als daar plaats is, anders erboven, anders binnen de onderrand.</summary>
|
||||
private void PlaceNearRegion()
|
||||
{
|
||||
Rectangle own = PhysicalWindow.GetBounds(this);
|
||||
int x = _region.X + (_region.Width - own.Width) / 2;
|
||||
x = Math.Clamp(x, _workArea.Left, Math.Max(_workArea.Left, _workArea.Right - own.Width));
|
||||
|
||||
int y;
|
||||
if (_region.Bottom + own.Height <= _workArea.Bottom)
|
||||
{
|
||||
y = _region.Bottom;
|
||||
}
|
||||
else if (_region.Top - own.Height >= _workArea.Top)
|
||||
{
|
||||
y = _region.Top - own.Height;
|
||||
}
|
||||
else
|
||||
{
|
||||
y = Math.Max(_workArea.Top, _region.Bottom - own.Height);
|
||||
}
|
||||
|
||||
PhysicalWindow.Place(this, new Rectangle(x, y, own.Width, own.Height), resize: false);
|
||||
}
|
||||
|
||||
private void Background_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (e.ButtonState == MouseButtonState.Pressed)
|
||||
{
|
||||
DragMove();
|
||||
}
|
||||
}
|
||||
|
||||
private void Record_Click(object sender, RoutedEventArgs e) => RecordRequested?.Invoke();
|
||||
|
||||
private void Pause_Click(object sender, RoutedEventArgs e) => PauseRequested?.Invoke();
|
||||
|
||||
private void Stop_Click(object sender, RoutedEventArgs e) => StopRequested?.Invoke();
|
||||
|
||||
private void Cancel_Click(object sender, RoutedEventArgs e) => CancelRequested?.Invoke();
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using System;
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
using Kniip.Interop;
|
||||
using Rectangle = System.Drawing.Rectangle;
|
||||
using Shape = System.Windows.Shapes.Rectangle;
|
||||
|
||||
namespace Kniip.Recording;
|
||||
|
||||
/// <summary>Rand net buiten het opnamegebied; klikken gaan erdoorheen en hij komt niet in de opname.</summary>
|
||||
internal sealed class RegionFrameWindow : Window
|
||||
{
|
||||
private const double ThicknessDip = 2;
|
||||
|
||||
private static readonly Brush ReadyBrush = Freeze(new SolidColorBrush(Color.FromRgb(0x00, 0x67, 0xC0)));
|
||||
private static readonly Brush RecordingBrush = Freeze(new SolidColorBrush(Color.FromRgb(0xE8, 0x11, 0x23)));
|
||||
|
||||
private readonly Rectangle _region;
|
||||
private readonly Shape _frame = new() { StrokeDashArray = new DoubleCollection { 4, 2 } };
|
||||
|
||||
public RegionFrameWindow(Rectangle region)
|
||||
{
|
||||
_region = region;
|
||||
WindowStyle = WindowStyle.None;
|
||||
AllowsTransparency = true;
|
||||
Background = Brushes.Transparent;
|
||||
ResizeMode = ResizeMode.NoResize;
|
||||
ShowInTaskbar = false;
|
||||
ShowActivated = false;
|
||||
Topmost = true;
|
||||
Content = _frame;
|
||||
SetRecording(false);
|
||||
|
||||
SourceInitialized += (_, _) =>
|
||||
{
|
||||
IntPtr hwnd = PhysicalWindow.Handle(this);
|
||||
NativeMethods.AddExtendedStyle(hwnd,
|
||||
NativeMethods.WS_EX_TRANSPARENT | NativeMethods.WS_EX_TOOLWINDOW | NativeMethods.WS_EX_NOACTIVATE);
|
||||
PhysicalWindow.ExcludeFromCapture(this);
|
||||
UpdatePlacement();
|
||||
};
|
||||
DpiChanged += (_, _) => UpdatePlacement();
|
||||
}
|
||||
|
||||
public void SetRecording(bool recording) => _frame.Stroke = recording ? RecordingBrush : ReadyBrush;
|
||||
|
||||
private void UpdatePlacement()
|
||||
{
|
||||
double scale = PhysicalWindow.Scale(this);
|
||||
int border = (int)Math.Ceiling(ThicknessDip * scale);
|
||||
_frame.StrokeThickness = border / scale;
|
||||
Rectangle outer = _region;
|
||||
outer.Inflate(border, border);
|
||||
PhysicalWindow.Place(this, outer);
|
||||
}
|
||||
|
||||
private static Brush Freeze(Brush brush)
|
||||
{
|
||||
brush.Freeze();
|
||||
return brush;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<Window x:Class="Kniip.Recording.RegionSelectorWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="kniip opnamegebied kiezen" WindowStyle="None" AllowsTransparency="True" ResizeMode="NoResize"
|
||||
ShowInTaskbar="False" Topmost="True" Cursor="Cross" FontFamily="Segoe UI" FontSize="13"
|
||||
Background="#01000000">
|
||||
<Grid>
|
||||
<Path x:Name="Dim" Fill="#73000000" />
|
||||
<Rectangle x:Name="SelectionRect" Stroke="{StaticResource AccentBrush}" StrokeThickness="2"
|
||||
HorizontalAlignment="Left" VerticalAlignment="Top" Visibility="Collapsed" IsHitTestVisible="False" />
|
||||
<Border x:Name="SizeBadge" HorizontalAlignment="Left" VerticalAlignment="Top" Background="#E6202020"
|
||||
CornerRadius="4" Padding="6,2" Visibility="Collapsed" IsHitTestVisible="False">
|
||||
<TextBlock x:Name="SizeText" Foreground="White" FontSize="12" />
|
||||
</Border>
|
||||
<Border x:Name="Hint" HorizontalAlignment="Center" VerticalAlignment="Top" Margin="0,28,0,0" Background="#F2FFFFFF"
|
||||
CornerRadius="6" Padding="16,9" IsHitTestVisible="False">
|
||||
<Border.Effect>
|
||||
<DropShadowEffect BlurRadius="12" ShadowDepth="2" Opacity="0.25" />
|
||||
</Border.Effect>
|
||||
<StackPanel>
|
||||
<TextBlock Text="Screencast: kies wat je wilt opnemen" FontWeight="SemiBold" Foreground="{StaticResource InkBrush}" />
|
||||
<TextBlock Foreground="{StaticResource SubtleTextBrush}" FontSize="12" Margin="0,2,0,0"
|
||||
Text="Sleep een gebied · klik op een venster · Enter = heel scherm · Esc = annuleren" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,187 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Windows;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using Rectangle = System.Drawing.Rectangle;
|
||||
using Screen = System.Windows.Forms.Screen;
|
||||
|
||||
namespace Kniip.Recording;
|
||||
|
||||
/// <summary>
|
||||
/// Overlay over één beeldscherm om het opnamegebied te kiezen. Er staat er één per scherm;
|
||||
/// een gebied ligt daardoor altijd binnen één scherm.
|
||||
/// </summary>
|
||||
public partial class RegionSelectorWindow : Window
|
||||
{
|
||||
private const double DragThreshold = 4;
|
||||
private const int MinimumSize = 16;
|
||||
|
||||
private readonly Screen _screen;
|
||||
private readonly IReadOnlyList<Rectangle> _windows;
|
||||
private Point? _dragStart;
|
||||
private bool _dragging;
|
||||
private Rectangle? _hover;
|
||||
|
||||
/// <summary>Gekozen gebied, of null bij annuleren.</summary>
|
||||
internal event Action<RecordingRegion?>? Finished;
|
||||
|
||||
internal RegionSelectorWindow(Screen screen, IReadOnlyList<Rectangle> windows)
|
||||
{
|
||||
InitializeComponent();
|
||||
_screen = screen;
|
||||
_windows = windows;
|
||||
SourceInitialized += (_, _) => PhysicalWindow.Place(this, screen.Bounds);
|
||||
Loaded += (_, _) => ShowSelection(null);
|
||||
MouseLeave += (_, _) =>
|
||||
{
|
||||
if (!_dragging)
|
||||
{
|
||||
_hover = null;
|
||||
ShowSelection(null);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public bool ContainsCursor(System.Drawing.Point cursor) => _screen.Bounds.Contains(cursor);
|
||||
|
||||
protected override void OnMouseMove(MouseEventArgs e)
|
||||
{
|
||||
base.OnMouseMove(e);
|
||||
Point position = e.GetPosition(this);
|
||||
if (_dragStart is Point start && e.LeftButton == MouseButtonState.Pressed)
|
||||
{
|
||||
if (!_dragging && (position - start).Length >= DragThreshold)
|
||||
{
|
||||
_dragging = true;
|
||||
}
|
||||
|
||||
if (_dragging)
|
||||
{
|
||||
ShowSelection(ToPhysical(new Rect(start, position)));
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
_hover = FindWindowAt(ToPhysical(position));
|
||||
ShowSelection(_hover);
|
||||
}
|
||||
|
||||
protected override void OnMouseLeftButtonDown(MouseButtonEventArgs e)
|
||||
{
|
||||
base.OnMouseLeftButtonDown(e);
|
||||
_dragStart = e.GetPosition(this);
|
||||
_dragging = false;
|
||||
CaptureMouse();
|
||||
}
|
||||
|
||||
protected override void OnMouseLeftButtonUp(MouseButtonEventArgs e)
|
||||
{
|
||||
base.OnMouseLeftButtonUp(e);
|
||||
ReleaseMouseCapture();
|
||||
if (_dragStart is not Point start)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_dragStart = null;
|
||||
if (_dragging)
|
||||
{
|
||||
_dragging = false;
|
||||
Rectangle area = ToPhysical(new Rect(start, e.GetPosition(this)));
|
||||
if (area.Width >= MinimumSize && area.Height >= MinimumSize)
|
||||
{
|
||||
Finish(area);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Finish(_hover ?? _screen.Bounds);
|
||||
}
|
||||
|
||||
protected override void OnMouseRightButtonUp(MouseButtonEventArgs e)
|
||||
{
|
||||
base.OnMouseRightButtonUp(e);
|
||||
Finished?.Invoke(null);
|
||||
}
|
||||
|
||||
protected override void OnKeyDown(KeyEventArgs e)
|
||||
{
|
||||
base.OnKeyDown(e);
|
||||
if (e.Key == Key.Escape)
|
||||
{
|
||||
Finished?.Invoke(null);
|
||||
}
|
||||
else if (e.Key == Key.Enter)
|
||||
{
|
||||
Finish(_screen.Bounds);
|
||||
}
|
||||
}
|
||||
|
||||
private void Finish(Rectangle area) =>
|
||||
Finished?.Invoke(RecordingRegion.Create(_screen.DeviceName, area, _screen.Bounds));
|
||||
|
||||
private Rectangle? FindWindowAt(System.Drawing.Point point)
|
||||
{
|
||||
foreach (Rectangle window in _windows)
|
||||
{
|
||||
if (window.Contains(point))
|
||||
{
|
||||
Rectangle clipped = Rectangle.Intersect(window, _screen.Bounds);
|
||||
return clipped.IsEmpty ? null : clipped;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private double ScaleFactor => PhysicalWindow.Scale(this);
|
||||
|
||||
private System.Drawing.Point ToPhysical(Point dip) =>
|
||||
new(_screen.Bounds.X + (int)Math.Round(dip.X * ScaleFactor), _screen.Bounds.Y + (int)Math.Round(dip.Y * ScaleFactor));
|
||||
|
||||
private Rectangle ToPhysical(Rect dip)
|
||||
{
|
||||
System.Drawing.Point topLeft = ToPhysical(dip.TopLeft);
|
||||
System.Drawing.Point bottomRight = ToPhysical(dip.BottomRight);
|
||||
return Rectangle.FromLTRB(topLeft.X, topLeft.Y, bottomRight.X, bottomRight.Y);
|
||||
}
|
||||
|
||||
private Rect ToDip(Rectangle physical)
|
||||
{
|
||||
double scale = ScaleFactor;
|
||||
return new Rect((physical.X - _screen.Bounds.X) / scale, (physical.Y - _screen.Bounds.Y) / scale,
|
||||
physical.Width / scale, physical.Height / scale);
|
||||
}
|
||||
|
||||
private void ShowSelection(Rectangle? physical)
|
||||
{
|
||||
var full = new Rect(0, 0, ActualWidth, ActualHeight);
|
||||
if (physical is not Rectangle area || area.IsEmpty)
|
||||
{
|
||||
Dim.Data = new RectangleGeometry(full);
|
||||
SelectionRect.Visibility = Visibility.Collapsed;
|
||||
SizeBadge.Visibility = Visibility.Collapsed;
|
||||
return;
|
||||
}
|
||||
|
||||
Rect rect = ToDip(area);
|
||||
var geometry = new GeometryGroup { FillRule = FillRule.EvenOdd };
|
||||
geometry.Children.Add(new RectangleGeometry(full));
|
||||
geometry.Children.Add(new RectangleGeometry(rect));
|
||||
Dim.Data = geometry;
|
||||
|
||||
SelectionRect.Margin = new Thickness(rect.X, rect.Y, 0, 0);
|
||||
SelectionRect.Width = rect.Width;
|
||||
SelectionRect.Height = rect.Height;
|
||||
SelectionRect.Visibility = Visibility.Visible;
|
||||
|
||||
SizeText.Text = $"{area.Width & ~1} × {area.Height & ~1}";
|
||||
SizeBadge.Visibility = Visibility.Visible;
|
||||
SizeBadge.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
|
||||
double badgeTop = rect.Y - SizeBadge.DesiredSize.Height - 4;
|
||||
SizeBadge.Margin = new Thickness(rect.X, badgeTop < 0 ? rect.Y + 4 : badgeTop, 0, 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
<Window x:Class="Kniip.Recording.ScreencastWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="kniip screencast" Width="980" Height="720" MinWidth="680" MinHeight="500"
|
||||
WindowStartupLocation="CenterScreen" Background="{StaticResource WindowBrush}"
|
||||
FontFamily="Segoe UI" FontSize="13" Icon="pack://application:,,,/AppIcon.ico">
|
||||
<Window.Resources>
|
||||
<Style x:Key="Handle" TargetType="Border">
|
||||
<Setter Property="Width" Value="12" />
|
||||
<Setter Property="Background" Value="{StaticResource AccentBrush}" />
|
||||
<Setter Property="Cursor" Value="SizeWE" />
|
||||
</Style>
|
||||
<Style x:Key="Grip" TargetType="Rectangle">
|
||||
<Setter Property="Width" Value="2" />
|
||||
<Setter Property="Height" Value="14" />
|
||||
<Setter Property="Fill" Value="White" />
|
||||
<Setter Property="RadiusX" Value="1" />
|
||||
<Setter Property="RadiusY" Value="1" />
|
||||
<Setter Property="HorizontalAlignment" Value="Center" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
</Window.Resources>
|
||||
|
||||
<Grid Margin="16">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<Border Background="#1E1E1E" CornerRadius="6" ClipToBounds="True" MouseLeftButtonDown="Video_MouseLeftButtonDown">
|
||||
<Grid>
|
||||
<MediaElement x:Name="Player" LoadedBehavior="Manual" UnloadedBehavior="Manual" ScrubbingEnabled="True"
|
||||
Stretch="Uniform" MediaOpened="Player_MediaOpened" MediaEnded="Player_MediaEnded"
|
||||
MediaFailed="Player_MediaFailed" />
|
||||
<TextBlock x:Name="PlayerMessage" Text="Opname laden..." Foreground="#CCCCCC" HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" TextWrapping="Wrap" TextAlignment="Center" Margin="20" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Grid x:Name="TrimArea" Grid.Row="1" Height="44" Margin="0,12,0,0" Background="Transparent" Cursor="Hand"
|
||||
MouseLeftButtonDown="TrimArea_MouseLeftButtonDown" MouseMove="TrimArea_MouseMove"
|
||||
MouseLeftButtonUp="TrimArea_MouseLeftButtonUp" SizeChanged="TrimArea_SizeChanged">
|
||||
<Canvas x:Name="TrimCanvas">
|
||||
<Border x:Name="TrackBorder" Background="#DADADA" CornerRadius="5" Height="44" />
|
||||
<Border x:Name="SelectionBorder" Background="#330067C0" BorderBrush="{StaticResource AccentBrush}"
|
||||
BorderThickness="0,2" Height="44" />
|
||||
<Rectangle x:Name="Playhead" Width="2" Height="52" Fill="{StaticResource InkBrush}" Canvas.Top="-4" />
|
||||
<Border x:Name="StartHandle" Style="{StaticResource Handle}" CornerRadius="5,0,0,5" Height="44"
|
||||
ToolTip="Begin van de selectie">
|
||||
<Rectangle Style="{StaticResource Grip}" />
|
||||
</Border>
|
||||
<Border x:Name="EndHandle" Style="{StaticResource Handle}" CornerRadius="0,5,5,0" Height="44"
|
||||
ToolTip="Einde van de selectie">
|
||||
<Rectangle Style="{StaticResource Grip}" />
|
||||
</Border>
|
||||
</Canvas>
|
||||
</Grid>
|
||||
|
||||
<DockPanel Grid.Row="2" Margin="0,8,0,0" LastChildFill="False">
|
||||
<Button x:Name="PlayButton" DockPanel.Dock="Left" Style="{StaticResource FlatButton}" Click="Play_Click"
|
||||
ToolTip="Afspelen (spatiebalk)">
|
||||
<TextBlock x:Name="PlayIcon" Text="" Style="{StaticResource IconText}" />
|
||||
</Button>
|
||||
<TextBlock x:Name="PositionText" DockPanel.Dock="Left" VerticalAlignment="Center" Margin="6,0,0,0"
|
||||
Foreground="{StaticResource InkBrush}" Text="0:00.0 / 0:00.0" />
|
||||
|
||||
<Button DockPanel.Dock="Right" Style="{StaticResource FlatButton}" Click="SetEnd_Click"
|
||||
ToolTip="Einde van de selectie op de huidige positie zetten">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="Einde hier" VerticalAlignment="Center" />
|
||||
<TextBlock Text="" Style="{StaticResource IconText}" FontSize="12" Margin="6,1,0,0" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button DockPanel.Dock="Right" Style="{StaticResource FlatButton}" Click="SetStart_Click"
|
||||
ToolTip="Begin van de selectie op de huidige positie zetten">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="" Style="{StaticResource IconText}" FontSize="12" Margin="0,1,6,0" />
|
||||
<TextBlock Text="Begin hier" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<TextBlock x:Name="SelectionText" DockPanel.Dock="Right" VerticalAlignment="Center" Margin="0,0,10,0"
|
||||
Foreground="{StaticResource SubtleTextBrush}" />
|
||||
</DockPanel>
|
||||
|
||||
<Border Grid.Row="3" Margin="0,12,0,0" Background="White" BorderBrush="#E5E5E5" BorderThickness="1"
|
||||
CornerRadius="6" Padding="14,10">
|
||||
<DockPanel LastChildFill="True">
|
||||
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<StackPanel x:Name="BusyPanel" Orientation="Horizontal" Visibility="Collapsed" Margin="0,0,8,0">
|
||||
<ProgressBar x:Name="Progress" Width="140" Height="6" Minimum="0" Maximum="100" VerticalAlignment="Center" />
|
||||
<Button Style="{StaticResource FlatButton}" Content="Annuleren" Click="CancelExport_Click" Margin="6,0,0,0" />
|
||||
</StackPanel>
|
||||
<Button x:Name="CopyButton" Style="{StaticResource SecondaryButton}" Click="Copy_Click"
|
||||
ToolTip="Als bestand naar het klembord kopiëren (plakken in Verkenner, Teams, Outlook, ...)">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="" Style="{StaticResource IconText}" FontSize="14" Margin="0,0,6,0" />
|
||||
<TextBlock Text="Kopiëren" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button x:Name="UploadButton" Style="{StaticResource SecondaryButton}" Click="Upload_Click" Visibility="Collapsed"
|
||||
ToolTip="Uploaden naar Zipline en de link naar het klembord kopiëren">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="" Style="{StaticResource IconText}" FontSize="14" Margin="0,0,6,0" />
|
||||
<TextBlock Text="Uploaden" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button x:Name="SaveButton" Style="{StaticResource AccentButton}" Click="Save_Click" IsDefault="True"
|
||||
ToolTip="Shift+klik = altijd vragen waar je opslaat">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="" Style="{StaticResource IconText}" FontSize="14" Margin="0,0,6,0" />
|
||||
<TextBlock x:Name="SaveText" Text="Opslaan als MP4..." VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel VerticalAlignment="Center">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="Formaat" Style="{StaticResource PropLabel}" />
|
||||
<ToggleButton x:Name="Mp4Toggle" Style="{StaticResource MiniToggle}" Content="MP4" MinWidth="54"
|
||||
Click="Format_Click" ToolTip="Video met geluid; klein bestand, beste kwaliteit" />
|
||||
<ToggleButton x:Name="GifToggle" Style="{StaticResource MiniToggle}" Content="GIF" MinWidth="54"
|
||||
Click="Format_Click" ToolTip="Bewegend plaatje zonder geluid; speelt overal af (ook in e-mail en wiki's)" />
|
||||
|
||||
<StackPanel x:Name="GifOptions" Orientation="Horizontal" Margin="14,0,0,0">
|
||||
<TextBlock Text="Beelden/s" Style="{StaticResource PropLabel}" />
|
||||
<ComboBox x:Name="FpsCombo" Style="{StaticResource PropCombo}" MinWidth="60" />
|
||||
<TextBlock Text="Breedte" Style="{StaticResource PropLabel}" Margin="12,0,6,0" />
|
||||
<ComboBox x:Name="WidthCombo" Style="{StaticResource PropCombo}" MinWidth="100" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
<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">
|
||||
<Run x:Name="SavedRun" Foreground="{StaticResource InkBrush}" />
|
||||
<Hyperlink Click="OpenFolder_Click"><Run x:Name="OpenRun" Text="Map openen" /></Hyperlink>
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,678 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Threading;
|
||||
using Kniip.Editor;
|
||||
using Kniip.Settings;
|
||||
using Kniip.Upload;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace Kniip.Recording;
|
||||
|
||||
/// <summary>Nabewerking van een screencast: bekijken, begin en einde inkorten en opslaan als MP4 of GIF.</summary>
|
||||
public partial class ScreencastWindow : Window
|
||||
{
|
||||
private static readonly TimeSpan MinimumLength = TimeSpan.FromMilliseconds(200);
|
||||
private static readonly int[] FpsChoices = { 5, 10, 15, 20, 25 };
|
||||
private static readonly int[] WidthChoices = { 0, 1920, 1280, 960, 800, 640, 480 };
|
||||
|
||||
private enum DragTarget
|
||||
{
|
||||
None,
|
||||
Start,
|
||||
End,
|
||||
Playhead
|
||||
}
|
||||
|
||||
private readonly string _path;
|
||||
private readonly AppSettings _settings;
|
||||
private readonly DispatcherTimer _playTimer = new() { Interval = TimeSpan.FromMilliseconds(40) };
|
||||
private TimeSpan _duration;
|
||||
private TimeSpan _start;
|
||||
private TimeSpan _end;
|
||||
private bool _playing;
|
||||
private bool _loaded;
|
||||
private DragTarget _drag;
|
||||
private CancellationTokenSource? _export;
|
||||
private bool _kept;
|
||||
private string? _lastSaved;
|
||||
|
||||
internal ScreencastWindow(string path, AppSettings settings, ScreencastFormat? format = null)
|
||||
{
|
||||
InitializeComponent();
|
||||
_path = path;
|
||||
_settings = settings;
|
||||
|
||||
int[] fpsChoices = FpsChoices.Contains(settings.GifFps) ? FpsChoices : FpsChoices.Append(settings.GifFps).Order().ToArray();
|
||||
foreach (int fps in fpsChoices)
|
||||
{
|
||||
AddChoice(FpsCombo, fps.ToString(CultureInfo.CurrentCulture), fps, fps == settings.GifFps);
|
||||
}
|
||||
|
||||
int[] widths = WidthChoices.Contains(settings.GifMaxWidth)
|
||||
? WidthChoices
|
||||
: WidthChoices.Append(settings.GifMaxWidth).OrderByDescending(w => w == 0 ? int.MaxValue : w).ToArray();
|
||||
foreach (int width in widths)
|
||||
{
|
||||
AddChoice(WidthCombo, width == 0 ? "Origineel" : $"{width} px", width, width == settings.GifMaxWidth);
|
||||
}
|
||||
|
||||
FpsCombo.SelectionChanged += (_, _) => UpdateInfo();
|
||||
WidthCombo.SelectionChanged += (_, _) => UpdateInfo();
|
||||
SetFormat(format ?? settings.LastScreencastFormat);
|
||||
|
||||
_playTimer.Tick += (_, _) => OnPlayTick();
|
||||
Player.Source = new Uri(path);
|
||||
Player.Play();
|
||||
}
|
||||
|
||||
private static void AddChoice(ComboBox combo, string text, int value, bool selected)
|
||||
{
|
||||
var item = new ComboBoxItem { Content = text, Tag = value };
|
||||
combo.Items.Add(item);
|
||||
if (selected)
|
||||
{
|
||||
combo.SelectedItem = item;
|
||||
}
|
||||
}
|
||||
|
||||
private ScreencastFormat Format => GifToggle.IsChecked == true ? ScreencastFormat.Gif : ScreencastFormat.Mp4;
|
||||
|
||||
private int GifFps => FpsCombo.SelectedItem is ComboBoxItem { Tag: int fps } ? fps : 10;
|
||||
|
||||
private int GifWidth => WidthCombo.SelectedItem is ComboBoxItem { Tag: int width } ? width : 800;
|
||||
|
||||
private bool IsBusy => _export != null;
|
||||
|
||||
// ---------- Afspelen ----------
|
||||
|
||||
private void Player_MediaOpened(object sender, RoutedEventArgs e)
|
||||
{
|
||||
Player.Pause();
|
||||
_duration = Player.NaturalDuration.HasTimeSpan ? Player.NaturalDuration.TimeSpan : TimeSpan.Zero;
|
||||
_start = TimeSpan.Zero;
|
||||
_end = _duration;
|
||||
_loaded = true;
|
||||
Player.Position = TimeSpan.Zero;
|
||||
PlayerMessage.Visibility = Visibility.Collapsed;
|
||||
UpdateTrim();
|
||||
UpdateInfo();
|
||||
}
|
||||
|
||||
private void Player_MediaFailed(object? sender, ExceptionRoutedEventArgs e)
|
||||
{
|
||||
PlayerMessage.Text = "Het voorbeeld kan niet worden afgespeeld (" + e.ErrorException.Message + ").\n"
|
||||
+ "Opslaan werkt wel; inkorten is dan niet mogelijk.";
|
||||
PlayerMessage.Visibility = Visibility.Visible;
|
||||
_loaded = false;
|
||||
using var reader = TryOpenReader();
|
||||
_duration = reader?.Duration ?? TimeSpan.Zero;
|
||||
_start = TimeSpan.Zero;
|
||||
_end = _duration;
|
||||
UpdateTrim();
|
||||
UpdateInfo();
|
||||
}
|
||||
|
||||
private VideoFrameReader? TryOpenReader()
|
||||
{
|
||||
try
|
||||
{
|
||||
return new VideoFrameReader(_path);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void Player_MediaEnded(object sender, RoutedEventArgs e) => SetPlaying(false);
|
||||
|
||||
private void Play_Click(object sender, RoutedEventArgs e) => TogglePlay();
|
||||
|
||||
private void Video_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) => TogglePlay();
|
||||
|
||||
protected override void OnPreviewKeyDown(KeyEventArgs e)
|
||||
{
|
||||
base.OnPreviewKeyDown(e);
|
||||
if (e.Key == Key.Space && Keyboard.FocusedElement is not ComboBox)
|
||||
{
|
||||
TogglePlay();
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void TogglePlay()
|
||||
{
|
||||
if (!_loaded)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_playing)
|
||||
{
|
||||
SetPlaying(false);
|
||||
return;
|
||||
}
|
||||
|
||||
TimeSpan position = Player.Position;
|
||||
if (position < _start || position >= _end - TimeSpan.FromMilliseconds(50))
|
||||
{
|
||||
Player.Position = _start;
|
||||
}
|
||||
|
||||
SetPlaying(true);
|
||||
}
|
||||
|
||||
private void SetPlaying(bool playing)
|
||||
{
|
||||
_playing = playing;
|
||||
if (playing)
|
||||
{
|
||||
Player.Play();
|
||||
_playTimer.Start();
|
||||
}
|
||||
else
|
||||
{
|
||||
Player.Pause();
|
||||
_playTimer.Stop();
|
||||
}
|
||||
|
||||
PlayIcon.Text = playing ? "\uE769" : "\uE768";
|
||||
UpdatePlayhead();
|
||||
}
|
||||
|
||||
private void OnPlayTick()
|
||||
{
|
||||
if (Player.Position >= _end)
|
||||
{
|
||||
SetPlaying(false);
|
||||
Player.Position = _end;
|
||||
}
|
||||
|
||||
UpdatePlayhead();
|
||||
}
|
||||
|
||||
private void Seek(TimeSpan time)
|
||||
{
|
||||
Player.Position = Clamp(time, TimeSpan.Zero, _duration);
|
||||
UpdatePlayhead();
|
||||
}
|
||||
|
||||
// ---------- Inkortbalk ----------
|
||||
|
||||
private double HandleWidth => StartHandle.Width;
|
||||
|
||||
private double TrackWidth => Math.Max(1, TrimArea.ActualWidth - 2 * HandleWidth);
|
||||
|
||||
private double ToX(TimeSpan time) =>
|
||||
HandleWidth + (_duration.Ticks == 0 ? 0 : TrackWidth * time.Ticks / _duration.Ticks);
|
||||
|
||||
private TimeSpan ToTime(double x) =>
|
||||
_duration.Ticks == 0 ? TimeSpan.Zero : TimeSpan.FromTicks((long)(Math.Clamp((x - HandleWidth) / TrackWidth, 0, 1) * _duration.Ticks));
|
||||
|
||||
private void TrimArea_SizeChanged(object sender, SizeChangedEventArgs e) => UpdateTrim();
|
||||
|
||||
private void TrimArea_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (_duration == TimeSpan.Zero || IsBusy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
double x = e.GetPosition(TrimArea).X;
|
||||
double startX = ToX(_start) - HandleWidth / 2;
|
||||
double endX = ToX(_end) + HandleWidth / 2;
|
||||
if (Math.Abs(x - startX) <= HandleWidth)
|
||||
{
|
||||
_drag = DragTarget.Start;
|
||||
}
|
||||
else if (Math.Abs(x - endX) <= HandleWidth)
|
||||
{
|
||||
_drag = DragTarget.End;
|
||||
}
|
||||
else
|
||||
{
|
||||
_drag = DragTarget.Playhead;
|
||||
}
|
||||
|
||||
if (_drag != DragTarget.Playhead)
|
||||
{
|
||||
SetPlaying(false);
|
||||
}
|
||||
|
||||
TrimArea.CaptureMouse();
|
||||
ApplyDrag(x);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void TrimArea_MouseMove(object sender, MouseEventArgs e)
|
||||
{
|
||||
if (_drag != DragTarget.None)
|
||||
{
|
||||
ApplyDrag(e.GetPosition(TrimArea).X);
|
||||
}
|
||||
}
|
||||
|
||||
private void TrimArea_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
_drag = DragTarget.None;
|
||||
TrimArea.ReleaseMouseCapture();
|
||||
}
|
||||
|
||||
private void ApplyDrag(double x)
|
||||
{
|
||||
switch (_drag)
|
||||
{
|
||||
case DragTarget.Start:
|
||||
_start = Clamp(ToTime(x + HandleWidth / 2), TimeSpan.Zero, _end - MinimumLength);
|
||||
Seek(_start);
|
||||
break;
|
||||
case DragTarget.End:
|
||||
_end = Clamp(ToTime(x - HandleWidth / 2), _start + MinimumLength, _duration);
|
||||
Seek(_end);
|
||||
break;
|
||||
case DragTarget.Playhead:
|
||||
Seek(ToTime(x));
|
||||
break;
|
||||
}
|
||||
|
||||
UpdateTrim();
|
||||
}
|
||||
|
||||
private void SetStart_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
_start = Clamp(Player.Position, TimeSpan.Zero, _end - MinimumLength);
|
||||
UpdateTrim();
|
||||
}
|
||||
|
||||
private void SetEnd_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
_end = Clamp(Player.Position, _start + MinimumLength, _duration);
|
||||
UpdateTrim();
|
||||
}
|
||||
|
||||
private static TimeSpan Clamp(TimeSpan value, TimeSpan min, TimeSpan max) =>
|
||||
max < min ? min : value < min ? min : value > max ? max : value;
|
||||
|
||||
private void UpdateTrim()
|
||||
{
|
||||
double height = TrimArea.ActualHeight;
|
||||
TrackBorder.Width = Math.Max(0, TrimArea.ActualWidth);
|
||||
TrackBorder.Height = height;
|
||||
|
||||
double startX = ToX(_start);
|
||||
double endX = ToX(_end);
|
||||
Canvas.SetLeft(StartHandle, startX - HandleWidth);
|
||||
Canvas.SetLeft(EndHandle, endX);
|
||||
Canvas.SetLeft(SelectionBorder, startX);
|
||||
SelectionBorder.Width = Math.Max(0, endX - startX);
|
||||
|
||||
SelectionText.Text = $"Selectie {FormatTime(_start)} – {FormatTime(_end)} · {(_end - _start).TotalSeconds:0.0} s";
|
||||
UpdatePlayhead();
|
||||
UpdateInfo();
|
||||
}
|
||||
|
||||
private void UpdatePlayhead()
|
||||
{
|
||||
TimeSpan position = _loaded ? Player.Position : TimeSpan.Zero;
|
||||
Canvas.SetLeft(Playhead, ToX(position) - Playhead.Width / 2);
|
||||
PositionText.Text = $"{FormatTime(position)} / {FormatTime(_duration)}";
|
||||
}
|
||||
|
||||
private static string FormatTime(TimeSpan time) =>
|
||||
time.TotalHours >= 1
|
||||
? time.ToString(@"h\:mm\:ss\.f", CultureInfo.InvariantCulture)
|
||||
: time.ToString(@"m\:ss\.f", CultureInfo.InvariantCulture);
|
||||
|
||||
// ---------- Formaat en opslaan ----------
|
||||
|
||||
private void Format_Click(object sender, RoutedEventArgs e) =>
|
||||
SetFormat(ReferenceEquals(sender, GifToggle) ? ScreencastFormat.Gif : ScreencastFormat.Mp4);
|
||||
|
||||
private void SetFormat(ScreencastFormat format)
|
||||
{
|
||||
Mp4Toggle.IsChecked = format == ScreencastFormat.Mp4;
|
||||
GifToggle.IsChecked = format == ScreencastFormat.Gif;
|
||||
GifOptions.Visibility = format == ScreencastFormat.Gif ? Visibility.Visible : Visibility.Collapsed;
|
||||
bool copy = _settings.SaveButton == SaveButtonChoice.SaveAndCopy;
|
||||
string ellipsis = string.IsNullOrEmpty(_settings.VideoSaveFolder) ? "..." : string.Empty;
|
||||
string action = copy ? "Opslaan en kopiëren als " : "Opslaan als ";
|
||||
SaveText.Text = action + (format == ScreencastFormat.Gif ? "GIF" : "MP4") + ellipsis;
|
||||
SaveButton.ToolTip = copy
|
||||
? "Slaat op en kopieert het bestand naar het klembord. Shift+klik = altijd vragen waar je opslaat."
|
||||
: "Shift+klik = altijd vragen waar je opslaat";
|
||||
UploadButton.Visibility = _settings.Uploader is { IsConfigured: true } ? Visibility.Visible : Visibility.Collapsed;
|
||||
UpdateInfo();
|
||||
}
|
||||
|
||||
protected override void OnActivated(EventArgs e)
|
||||
{
|
||||
base.OnActivated(e);
|
||||
SetFormat(Format);
|
||||
}
|
||||
|
||||
private void UpdateInfo()
|
||||
{
|
||||
int width = _loaded ? Player.NaturalVideoWidth : 0;
|
||||
int height = _loaded ? Player.NaturalVideoHeight : 0;
|
||||
string size = width > 0 ? $"{width} × {height} px" : string.Empty;
|
||||
if (Format == ScreencastFormat.Gif)
|
||||
{
|
||||
int gifWidth = GifWidth;
|
||||
if (width > 0 && gifWidth > 0 && gifWidth < width)
|
||||
{
|
||||
size = $"{gifWidth} × {(int)Math.Round(height * (double)gifWidth / width)} px";
|
||||
}
|
||||
|
||||
string warning = (_end - _start).TotalSeconds > 30 ? " · lange GIF's worden groot; overweeg MP4" : string.Empty;
|
||||
InfoText.Text = $"{size} · GIF bevat geen geluid{warning}";
|
||||
}
|
||||
else
|
||||
{
|
||||
string audio = _loaded ? (Player.HasAudio ? "met geluid" : "zonder geluid") : string.Empty;
|
||||
InfoText.Text = string.Join(" · ", new[] { size, audio }.Where(s => s.Length > 0));
|
||||
}
|
||||
}
|
||||
|
||||
private string DefaultFileName =>
|
||||
$"Screencast {DateTime.Now:yyyy-MM-dd HHmmss}{(Format == ScreencastFormat.Gif ? ".gif" : ".mp4")}";
|
||||
|
||||
private static string DefaultFolder =>
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyVideos), "kniip");
|
||||
|
||||
private async void Save_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (IsBusy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SetPlaying(false);
|
||||
bool ask = Keyboard.Modifiers.HasFlag(ModifierKeys.Shift);
|
||||
string? target = ask ? null : FixedTarget();
|
||||
if (target is null)
|
||||
{
|
||||
target = AskTarget();
|
||||
if (target is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_settings.LastVideoFolder = Path.GetDirectoryName(target);
|
||||
}
|
||||
|
||||
if (await ExportAsync(target))
|
||||
{
|
||||
_kept = true;
|
||||
RememberChoices();
|
||||
bool copy = _settings.SaveButton == SaveButtonChoice.SaveAndCopy;
|
||||
bool copied = !copy || ClipboardWriter.SetFile(target);
|
||||
App.Instance.ShowBalloon(copy && copied ? "Screencast opgeslagen en gekopieerd" : "Screencast opgeslagen", target);
|
||||
if (!copied)
|
||||
{
|
||||
MessageBox.Show(this, "Het bestand is opgeslagen, maar kopiëren naar het klembord is mislukt. Het klembord is bezet.",
|
||||
"kniip", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
}
|
||||
|
||||
Close();
|
||||
}
|
||||
}
|
||||
|
||||
private string? FixedTarget()
|
||||
{
|
||||
if (_settings.VideoSaveFolder is not { Length: > 0 } folder)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(folder);
|
||||
return SaveLocation.UniquePath(folder, DefaultFileName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show(this, $"De opslagmap uit de instellingen is niet bruikbaar:\n{folder}\n\n{ex.Message}\n\nKies zelf waar je de screencast opslaat.",
|
||||
"kniip", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private string? AskTarget()
|
||||
{
|
||||
string folder = _settings.LastVideoFolder is { } last && Directory.Exists(last) ? last : DefaultFolder;
|
||||
Directory.CreateDirectory(folder);
|
||||
bool gif = Format == ScreencastFormat.Gif;
|
||||
var dialog = new SaveFileDialog
|
||||
{
|
||||
Title = "Screencast opslaan",
|
||||
Filter = gif ? "GIF-animatie (*.gif)|*.gif" : "MP4-video (*.mp4)|*.mp4",
|
||||
FileName = DefaultFileName,
|
||||
InitialDirectory = folder,
|
||||
AddExtension = true
|
||||
};
|
||||
return dialog.ShowDialog(this) == true ? dialog.FileName : null;
|
||||
}
|
||||
|
||||
private async void Copy_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (IsBusy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SetPlaying(false);
|
||||
string folder = Path.Combine(RecordingSession.TempFolder, "Kopieën", Guid.NewGuid().ToString("N")[..8]);
|
||||
string target = Path.Combine(folder, DefaultFileName);
|
||||
if (await ExportAsync(target))
|
||||
{
|
||||
if (!ClipboardWriter.SetFile(target))
|
||||
{
|
||||
MessageBox.Show(this, "Kopiëren is mislukt: het klembord is bezet. Probeer het opnieuw.",
|
||||
"kniip", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
_kept = true;
|
||||
RememberChoices();
|
||||
SavedRun.Text = $"{Path.GetFileName(target)} staat op het klembord; plak het waar je wilt. ";
|
||||
OpenRun.Text = "Map openen";
|
||||
_lastSaved = target;
|
||||
SavedText.Visibility = Visibility.Visible;
|
||||
}
|
||||
}
|
||||
|
||||
private async void Upload_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (IsBusy || _settings.Uploader is not { IsConfigured: true } config)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SetPlaying(false);
|
||||
string folder = Path.Combine(RecordingSession.TempFolder, "Uploads", Guid.NewGuid().ToString("N")[..8]);
|
||||
string target = Path.Combine(folder, DefaultFileName);
|
||||
if (!await ExportAsync(target))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_export = new CancellationTokenSource();
|
||||
SetBusy(true);
|
||||
var progress = new Progress<double>(value => Progress.Value = value * 100);
|
||||
try
|
||||
{
|
||||
string url;
|
||||
await using (FileStream stream = File.OpenRead(target))
|
||||
{
|
||||
string contentType = Format == ScreencastFormat.Gif ? "image/gif" : "video/mp4";
|
||||
url = await ZiplineUploader.UploadAsync(config, stream, Path.GetFileName(target), contentType, progress, _export.Token);
|
||||
}
|
||||
|
||||
_kept = true;
|
||||
RememberChoices();
|
||||
bool copied = ClipboardWriter.SetText(url);
|
||||
SavedRun.Text = copied ? $"Geüpload; de link staat op het klembord: {url} " : $"Geüpload: {url} ";
|
||||
OpenRun.Text = "Link openen";
|
||||
_lastSaved = url;
|
||||
SavedText.Visibility = Visibility.Visible;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show(this, $"Uploaden is mislukt.\n\n{ex.Message}", "kniip", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_export.Dispose();
|
||||
_export = null;
|
||||
SetBusy(false);
|
||||
try
|
||||
{
|
||||
Directory.Delete(folder, recursive: true);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Blijft in de tijdelijke map staan; die wordt bij de volgende start opgeruimd.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void RememberChoices()
|
||||
{
|
||||
_settings.LastScreencastFormat = Format;
|
||||
_settings.GifFps = GifFps;
|
||||
_settings.GifMaxWidth = GifWidth;
|
||||
SettingsStore.Save(_settings);
|
||||
}
|
||||
|
||||
private async Task<bool> ExportAsync(string target)
|
||||
{
|
||||
_export = new CancellationTokenSource();
|
||||
SetBusy(true);
|
||||
var progress = new Progress<double>(value => Progress.Value = value * 100);
|
||||
try
|
||||
{
|
||||
if (Format == ScreencastFormat.Gif)
|
||||
{
|
||||
await GifExporter.ExportAsync(_path, target, _start, _end, GifFps, GifWidth, progress, _export.Token);
|
||||
}
|
||||
else
|
||||
{
|
||||
await VideoExporter.TrimMp4Async(_path, target, _start, _end, _duration, progress, _export.Token);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show(this, $"Opslaan is mislukt.\n\n{ex.Message}", "kniip", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_export.Dispose();
|
||||
_export = null;
|
||||
SetBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
private void SetBusy(bool busy)
|
||||
{
|
||||
BusyPanel.Visibility = busy ? Visibility.Visible : Visibility.Collapsed;
|
||||
Progress.Value = 0;
|
||||
SaveButton.IsEnabled = !busy;
|
||||
CopyButton.IsEnabled = !busy;
|
||||
UploadButton.IsEnabled = !busy;
|
||||
Mp4Toggle.IsEnabled = !busy;
|
||||
GifToggle.IsEnabled = !busy;
|
||||
GifOptions.IsEnabled = !busy;
|
||||
Cursor = busy ? Cursors.AppStarting : null;
|
||||
}
|
||||
|
||||
private void CancelExport_Click(object sender, RoutedEventArgs e) => _export?.Cancel();
|
||||
|
||||
private void OpenFolder_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_lastSaved == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
ProcessStartInfo info = Uri.TryCreate(_lastSaved, UriKind.Absolute, out Uri? uri) && uri.Scheme.StartsWith("http", StringComparison.Ordinal)
|
||||
? new ProcessStartInfo(uri.AbsoluteUri) { UseShellExecute = true }
|
||||
: new ProcessStartInfo("explorer.exe", $"/select,\"{_lastSaved}\"") { UseShellExecute = true };
|
||||
Process.Start(info);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Verkenner openen is een extraatje.
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Sluiten ----------
|
||||
|
||||
protected override void OnClosing(CancelEventArgs e)
|
||||
{
|
||||
base.OnClosing(e);
|
||||
if (IsBusy)
|
||||
{
|
||||
_export?.Cancel();
|
||||
e.Cancel = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_kept && MessageBox.Show(this, "Deze opname is nog niet opgeslagen of gekopieerd. Weggooien?", "kniip",
|
||||
MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No) != MessageBoxResult.Yes)
|
||||
{
|
||||
e.Cancel = true;
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnClosed(EventArgs e)
|
||||
{
|
||||
base.OnClosed(e);
|
||||
_playTimer.Stop();
|
||||
Player.Close();
|
||||
DeleteLater(_path, attempts: 5);
|
||||
}
|
||||
|
||||
/// <summary>De mediaspeler laat het bestand soms nog even vast; daarom een paar keer proberen.</summary>
|
||||
private static void DeleteLater(string path, int attempts)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
catch (IOException) when (attempts > 1)
|
||||
{
|
||||
var timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
|
||||
timer.Tick += (_, _) =>
|
||||
{
|
||||
timer.Stop();
|
||||
DeleteLater(path, attempts - 1);
|
||||
};
|
||||
timer.Start();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Blijft in de tijdelijke map staan; die wordt bij de volgende start opgeruimd.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Windows.Media.Editing;
|
||||
using Windows.Media.MediaProperties;
|
||||
using Windows.Media.Transcoding;
|
||||
using Windows.Storage;
|
||||
|
||||
namespace Kniip.Recording;
|
||||
|
||||
/// <summary>MP4 inkorten via Windows.Media.Editing (zelfde resolutie, codec en geluid als de opname).</summary>
|
||||
internal static class VideoExporter
|
||||
{
|
||||
public static async Task TrimMp4Async(string source, string target, TimeSpan start, TimeSpan end, TimeSpan duration,
|
||||
IProgress<double>? progress, CancellationToken cancellationToken)
|
||||
{
|
||||
string folderPath = Path.GetDirectoryName(Path.GetFullPath(target))!;
|
||||
Directory.CreateDirectory(folderPath);
|
||||
|
||||
bool fullRange = start <= TimeSpan.FromMilliseconds(50) && end >= duration - TimeSpan.FromMilliseconds(50);
|
||||
if (fullRange)
|
||||
{
|
||||
await Task.Run(() => File.Copy(source, target, overwrite: true), cancellationToken);
|
||||
progress?.Report(1);
|
||||
return;
|
||||
}
|
||||
|
||||
StorageFile input = await StorageFile.GetFileFromPathAsync(source);
|
||||
MediaClip clip = await MediaClip.CreateFromFileAsync(input);
|
||||
clip.TrimTimeFromStart = start;
|
||||
clip.TrimTimeFromEnd = clip.OriginalDuration > end ? clip.OriginalDuration - end : TimeSpan.Zero;
|
||||
var composition = new MediaComposition();
|
||||
composition.Clips.Add(clip);
|
||||
|
||||
MediaEncodingProfile profile = await MediaEncodingProfile.CreateFromFileAsync(input);
|
||||
if (profile.Video is { } video)
|
||||
{
|
||||
// Een opname van een stilstaand scherm heeft een lage gemiddelde bitrate; die zou beweging wazig maken.
|
||||
uint minimum = (uint)Math.Min(20_000_000, video.Width * video.Height * 3L);
|
||||
video.Bitrate = Math.Max(video.Bitrate, minimum);
|
||||
}
|
||||
|
||||
StorageFolder folder = await StorageFolder.GetFolderFromPathAsync(folderPath);
|
||||
StorageFile output = await folder.CreateFileAsync(Path.GetFileName(target), CreationCollisionOption.ReplaceExisting);
|
||||
var percent = new Progress<double>(value => progress?.Report(value / 100));
|
||||
try
|
||||
{
|
||||
TranscodeFailureReason result = await composition
|
||||
.RenderToFileAsync(output, MediaTrimmingPreference.Precise, profile)
|
||||
.AsTask(cancellationToken, percent);
|
||||
if (result != TranscodeFailureReason.None)
|
||||
{
|
||||
throw new InvalidOperationException($"Windows kon de video niet opslaan ({result}).");
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(target);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Half bestand laten staan is beter dan de oorspronkelijke fout verbergen.
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
|
||||
namespace Kniip.Recording;
|
||||
|
||||
/// <summary>Een gedecodeerd videoframe in BGRA (32 bits per pixel, van boven naar onder).</summary>
|
||||
internal sealed record VideoFrame(byte[] Pixels, int Width, int Height, TimeSpan Time);
|
||||
|
||||
/// <summary>
|
||||
/// Leest frames verliesvrij uit een video via de Media Foundation Source Reader
|
||||
/// (met de ingebouwde omzetting naar RGB32).
|
||||
/// </summary>
|
||||
internal sealed class VideoFrameReader : IDisposable
|
||||
{
|
||||
private const uint MfVersion = 0x00020070;
|
||||
private const int FirstVideoStream = unchecked((int)0xFFFFFFFC);
|
||||
private const int AllStreams = unchecked((int)0xFFFFFFFE);
|
||||
private const int MediaSource = unchecked((int)0xFFFFFFFF);
|
||||
private const int EndOfStream = 0x2;
|
||||
private const ushort VtI8 = 20;
|
||||
|
||||
private static readonly Guid MfSourceReaderEnableAdvancedVideoProcessing = new("0f81da2c-b537-4672-a8b2-a681b17307a3");
|
||||
private static readonly Guid MfMtMajorType = new("48eba18e-f8c9-4687-bf11-0a74c9f96a8f");
|
||||
private static readonly Guid MfMtSubtype = new("f7e34c9a-42e8-4714-b74b-cb29d72c35e5");
|
||||
private static readonly Guid MfMtFrameSize = new("1652c33d-d6b2-4012-b834-72030849a37d");
|
||||
private static readonly Guid MfMtDefaultStride = new("644b4e48-1e02-4516-b0eb-c01ca9d49ac6");
|
||||
private static readonly Guid MfMtMinimumDisplayAperture = new("d7388766-18fe-48c6-a177-ee894867c8c4");
|
||||
private static readonly Guid MfPdDuration = new("6c990d33-bb8e-477a-8598-0d5d96fcd88a");
|
||||
private static readonly Guid MfMediaTypeVideo = new("73646976-0000-0010-8000-00AA00389B71");
|
||||
private static readonly Guid MfVideoFormatRgb32 = new("00000016-0000-0010-8000-00AA00389B71");
|
||||
|
||||
private readonly IMFSourceReader _reader;
|
||||
private readonly int _bufferWidth;
|
||||
private readonly int _bufferHeight;
|
||||
private readonly int _stride;
|
||||
private readonly int _cropX;
|
||||
private readonly int _cropY;
|
||||
|
||||
public VideoFrameReader(string path)
|
||||
{
|
||||
Check(MFStartup(MfVersion, 0));
|
||||
Check(MFCreateAttributes(out IMFAttributes attributes, 1));
|
||||
try
|
||||
{
|
||||
Guid key = MfSourceReaderEnableAdvancedVideoProcessing;
|
||||
attributes.SetUINT32(ref key, 1);
|
||||
Check(MFCreateSourceReaderFromURL(path, attributes, out _reader));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.ReleaseComObject(attributes);
|
||||
}
|
||||
|
||||
_reader.SetStreamSelection(AllStreams, false);
|
||||
_reader.SetStreamSelection(FirstVideoStream, true);
|
||||
|
||||
_reader.GetNativeMediaType(FirstVideoStream, 0, out IMFMediaType native);
|
||||
(int apertureX, int apertureY, int apertureWidth, int apertureHeight)? aperture = ReadAperture(native);
|
||||
Marshal.ReleaseComObject(native);
|
||||
|
||||
Check(MFCreateMediaType(out IMFMediaType requested));
|
||||
try
|
||||
{
|
||||
Guid major = MfMtMajorType, video = MfMediaTypeVideo, subtype = MfMtSubtype, rgb32 = MfVideoFormatRgb32;
|
||||
requested.SetGUID(ref major, ref video);
|
||||
requested.SetGUID(ref subtype, ref rgb32);
|
||||
_reader.SetCurrentMediaType(FirstVideoStream, IntPtr.Zero, requested);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.ReleaseComObject(requested);
|
||||
}
|
||||
|
||||
_reader.GetCurrentMediaType(FirstVideoStream, out IMFMediaType current);
|
||||
try
|
||||
{
|
||||
Guid frameSize = MfMtFrameSize;
|
||||
current.GetUINT64(ref frameSize, out long packed);
|
||||
_bufferWidth = (int)(packed >> 32);
|
||||
_bufferHeight = (int)(packed & 0xFFFFFFFF);
|
||||
|
||||
Guid strideKey = MfMtDefaultStride;
|
||||
_stride = current.GetUINT32(ref strideKey, out int stride) == 0 ? stride : _bufferWidth * 4;
|
||||
aperture ??= ReadAperture(current);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.ReleaseComObject(current);
|
||||
}
|
||||
|
||||
Width = _bufferWidth;
|
||||
Height = _bufferHeight;
|
||||
if (aperture is var (x, y, w, h) && w > 0 && h > 0 && x + w <= _bufferWidth && y + h <= _bufferHeight)
|
||||
{
|
||||
_cropX = x;
|
||||
_cropY = y;
|
||||
Width = w;
|
||||
Height = h;
|
||||
}
|
||||
|
||||
var key2 = MfPdDuration;
|
||||
if (_reader.GetPresentationAttribute(MediaSource, ref key2, out PropVariant duration) == 0)
|
||||
{
|
||||
Duration = TimeSpan.FromTicks(duration.Value);
|
||||
}
|
||||
}
|
||||
|
||||
public int Width { get; }
|
||||
|
||||
public int Height { get; }
|
||||
|
||||
public TimeSpan Duration { get; }
|
||||
|
||||
/// <summary>Alle frames vanaf (ongeveer) <paramref name="from"/>; het eerste frame kan iets eerder liggen.</summary>
|
||||
public IEnumerable<VideoFrame> ReadFrames(TimeSpan from, CancellationToken cancellationToken)
|
||||
{
|
||||
var position = new PropVariant { Type = VtI8, Value = Math.Max(0, from.Ticks) };
|
||||
Guid timeFormat = Guid.Empty;
|
||||
_reader.SetCurrentPosition(ref timeFormat, ref position);
|
||||
|
||||
while (true)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
_reader.ReadSample(FirstVideoStream, 0, out _, out int flags, out long timestamp, out IMFSample? sample);
|
||||
if (sample == null)
|
||||
{
|
||||
if ((flags & EndOfStream) != 0)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
byte[] pixels;
|
||||
try
|
||||
{
|
||||
pixels = CopyPixels(sample);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.ReleaseComObject(sample);
|
||||
}
|
||||
|
||||
yield return new VideoFrame(pixels, Width, Height, TimeSpan.FromTicks(timestamp));
|
||||
if ((flags & EndOfStream) != 0)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] CopyPixels(IMFSample sample)
|
||||
{
|
||||
sample.ConvertToContiguousBuffer(out IMFMediaBuffer buffer);
|
||||
try
|
||||
{
|
||||
buffer.Lock(out IntPtr data, out _, out int length);
|
||||
try
|
||||
{
|
||||
int stride = Math.Abs(_stride);
|
||||
bool bottomUp = _stride < 0;
|
||||
int rowBytes = Width * 4;
|
||||
var pixels = new byte[rowBytes * Height];
|
||||
for (int row = 0; row < Height; row++)
|
||||
{
|
||||
int sourceRow = _cropY + row;
|
||||
if (bottomUp)
|
||||
{
|
||||
sourceRow = _bufferHeight - 1 - sourceRow;
|
||||
}
|
||||
|
||||
long offset = (long)sourceRow * stride + _cropX * 4;
|
||||
if (offset + rowBytes > length)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
Marshal.Copy(data + (int)offset, pixels, row * rowBytes, rowBytes);
|
||||
}
|
||||
|
||||
return pixels;
|
||||
}
|
||||
finally
|
||||
{
|
||||
buffer.Unlock();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.ReleaseComObject(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
private static (int, int, int, int)? ReadAperture(IMFMediaType type)
|
||||
{
|
||||
Guid key = MfMtMinimumDisplayAperture;
|
||||
var blob = new byte[16];
|
||||
if (type.GetBlob(ref key, blob, blob.Length, out int size) != 0 || size < 16)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// MFVideoArea: twee MFOffset's (fract ushort + value short) en een SIZE.
|
||||
int x = BitConverter.ToInt16(blob, 2);
|
||||
int y = BitConverter.ToInt16(blob, 6);
|
||||
int width = BitConverter.ToInt32(blob, 8);
|
||||
int height = BitConverter.ToInt32(blob, 12);
|
||||
return (x, y, width, height);
|
||||
}
|
||||
|
||||
private static void Check(int hr)
|
||||
{
|
||||
if (hr < 0)
|
||||
{
|
||||
Marshal.ThrowExceptionForHR(hr);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Marshal.ReleaseComObject(_reader);
|
||||
MFShutdown();
|
||||
}
|
||||
|
||||
[DllImport("mfplat.dll")]
|
||||
private static extern int MFStartup(uint version, uint flags);
|
||||
|
||||
[DllImport("mfplat.dll")]
|
||||
private static extern int MFShutdown();
|
||||
|
||||
[DllImport("mfplat.dll")]
|
||||
private static extern int MFCreateAttributes(out IMFAttributes attributes, uint initialSize);
|
||||
|
||||
[DllImport("mfplat.dll")]
|
||||
private static extern int MFCreateMediaType(out IMFMediaType mediaType);
|
||||
|
||||
[DllImport("mfreadwrite.dll")]
|
||||
private static extern int MFCreateSourceReaderFromURL([MarshalAs(UnmanagedType.LPWStr)] string url,
|
||||
IMFAttributes attributes, out IMFSourceReader reader);
|
||||
|
||||
[StructLayout(LayoutKind.Explicit, Size = 24)]
|
||||
private struct PropVariant
|
||||
{
|
||||
[FieldOffset(0)] public ushort Type;
|
||||
[FieldOffset(8)] public long Value;
|
||||
}
|
||||
|
||||
// De COM-interfaces hieronder volgen de vtable-volgorde van de Windows SDK; methoden die kniip niet
|
||||
// gebruikt staan er als lege plaatshouder in zodat de volgorde klopt.
|
||||
|
||||
[ComImport, Guid("2cd2d921-c447-44a7-a13c-4adabfc247e3"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
private interface IMFAttributes
|
||||
{
|
||||
void GetItem(); void GetItemType(); void CompareItem(); void Compare();
|
||||
[PreserveSig] int GetUINT32(ref Guid key, out int value);
|
||||
[PreserveSig] int GetUINT64(ref Guid key, out long value);
|
||||
void GetDouble(); void GetGUID(); void GetStringLength(); void GetString(); void GetAllocatedString();
|
||||
void GetBlobSize();
|
||||
[PreserveSig] int GetBlob(ref Guid key, [Out] byte[] buffer, int bufferSize, out int blobSize);
|
||||
void GetAllocatedBlob(); void GetUnknown(); void SetItem(); void DeleteItem(); void DeleteAllItems();
|
||||
void SetUINT32(ref Guid key, int value);
|
||||
void SetUINT64(); void SetDouble();
|
||||
void SetGUID(ref Guid key, ref Guid value);
|
||||
void SetString(); void SetBlob(); void SetUnknown(); void LockStore(); void UnlockStore();
|
||||
void GetCount(); void GetItemByIndex(); void CopyAllItems();
|
||||
}
|
||||
|
||||
[ComImport, Guid("44ae0fa8-ea31-4109-8d2e-4cae4997c555"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
private interface IMFMediaType
|
||||
{
|
||||
void GetItem(); void GetItemType(); void CompareItem(); void Compare();
|
||||
[PreserveSig] int GetUINT32(ref Guid key, out int value);
|
||||
void GetUINT64(ref Guid key, out long value);
|
||||
void GetDouble(); void GetGUID(); void GetStringLength(); void GetString(); void GetAllocatedString();
|
||||
void GetBlobSize();
|
||||
[PreserveSig] int GetBlob(ref Guid key, [Out] byte[] buffer, int bufferSize, out int blobSize);
|
||||
void GetAllocatedBlob(); void GetUnknown(); void SetItem(); void DeleteItem(); void DeleteAllItems();
|
||||
void SetUINT32(ref Guid key, int value);
|
||||
void SetUINT64(); void SetDouble();
|
||||
void SetGUID(ref Guid key, ref Guid value);
|
||||
void SetString(); void SetBlob(); void SetUnknown(); void LockStore(); void UnlockStore();
|
||||
void GetCount(); void GetItemByIndex(); void CopyAllItems();
|
||||
void GetMajorType(); void IsCompressedFormat(); void IsEqual(); void GetRepresentation(); void FreeRepresentation();
|
||||
}
|
||||
|
||||
[ComImport, Guid("c40a00f2-b93a-4d80-ae8c-5a1c634f58e4"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
private interface IMFSample
|
||||
{
|
||||
void GetItem(); void GetItemType(); void CompareItem(); void Compare(); void GetUINT32(); void GetUINT64();
|
||||
void GetDouble(); void GetGUID(); void GetStringLength(); void GetString(); void GetAllocatedString();
|
||||
void GetBlobSize(); void GetBlob(); void GetAllocatedBlob(); void GetUnknown(); void SetItem(); void DeleteItem();
|
||||
void DeleteAllItems(); void SetUINT32(); void SetUINT64(); void SetDouble(); void SetGUID(); void SetString();
|
||||
void SetBlob(); void SetUnknown(); void LockStore(); void UnlockStore(); void GetCount(); void GetItemByIndex();
|
||||
void CopyAllItems();
|
||||
void GetSampleFlags(); void SetSampleFlags(); void GetSampleTime(); void SetSampleTime(); void GetSampleDuration();
|
||||
void SetSampleDuration(); void GetBufferCount(); void GetBufferByIndex();
|
||||
void ConvertToContiguousBuffer(out IMFMediaBuffer buffer);
|
||||
}
|
||||
|
||||
[ComImport, Guid("045FA593-8799-42b8-BC8D-8968C6453507"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
private interface IMFMediaBuffer
|
||||
{
|
||||
void Lock(out IntPtr buffer, out int maxLength, out int currentLength);
|
||||
void Unlock();
|
||||
}
|
||||
|
||||
[ComImport, Guid("70ae66f2-c809-4e4f-8915-bdcb406b7993"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
private interface IMFSourceReader
|
||||
{
|
||||
void GetStreamSelection();
|
||||
void SetStreamSelection(int streamIndex, [MarshalAs(UnmanagedType.Bool)] bool selected);
|
||||
void GetNativeMediaType(int streamIndex, int mediaTypeIndex, out IMFMediaType mediaType);
|
||||
void GetCurrentMediaType(int streamIndex, out IMFMediaType mediaType);
|
||||
void SetCurrentMediaType(int streamIndex, IntPtr reserved, IMFMediaType mediaType);
|
||||
void SetCurrentPosition(ref Guid timeFormat, ref PropVariant position);
|
||||
void ReadSample(int streamIndex, int controlFlags, out int actualStreamIndex, out int streamFlags,
|
||||
out long timestamp, out IMFSample? sample);
|
||||
void Flush();
|
||||
void GetServiceForStream();
|
||||
[PreserveSig] int GetPresentationAttribute(int streamIndex, ref Guid key, out PropVariant value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Windows.Controls;
|
||||
using Kniip.Editor;
|
||||
using Kniip.Upload;
|
||||
|
||||
namespace Kniip.Settings;
|
||||
|
||||
public enum CaptureAction
|
||||
{
|
||||
/// <summary>Melding rechtsonder tonen; klikken opent de editor.</summary>
|
||||
Notify,
|
||||
|
||||
/// <summary>Editor direct openen.</summary>
|
||||
OpenEditor,
|
||||
|
||||
/// <summary>Niets doen (alleen handmatig openen via tray of "Openen met").</summary>
|
||||
Nothing
|
||||
}
|
||||
|
||||
public enum ScreencastFormat
|
||||
{
|
||||
Mp4,
|
||||
Gif
|
||||
}
|
||||
|
||||
public enum SaveButtonChoice
|
||||
{
|
||||
/// <summary>Alleen het bestand opslaan.</summary>
|
||||
SaveOnly,
|
||||
|
||||
/// <summary>Bestand opslaan en naar het klembord kopiëren.</summary>
|
||||
SaveAndCopy
|
||||
}
|
||||
|
||||
public sealed class AppSettings
|
||||
{
|
||||
public bool WatchClipboard { get; set; } = true;
|
||||
public bool WatchScreenshotsFolder { get; set; } = true;
|
||||
public bool OnlyFromSnippingTool { get; set; } = true;
|
||||
public CaptureAction Action { get; set; } = CaptureAction.Notify;
|
||||
public bool StraightHighlight { get; set; } = true;
|
||||
public ToolKind LastTool { get; set; } = ToolKind.Rectangle;
|
||||
public string? LastSaveFolder { get; set; }
|
||||
|
||||
/// <summary>Map waarin knipsels zonder te vragen worden opgeslagen; leeg betekent elke keer vragen.</summary>
|
||||
public string? SnipSaveFolder { get; set; }
|
||||
public Dictionary<ToolKind, StyleSet> ToolStyles { get; set; } = new();
|
||||
|
||||
/// <summary>Recent gekozen emoji (codepunten), nieuwste eerst.</summary>
|
||||
public List<string> RecentEmoji { get; set; } = new();
|
||||
|
||||
/// <summary>Sneltoets voor een eigen rechthoekknipsel (zie HotkeyGesture); leeg betekent geen sneltoets.</summary>
|
||||
public string SnipHotkey { get; set; } = "PrintScreen";
|
||||
|
||||
/// <summary>Sneltoets om een venster aan te klikken en te knippen.</summary>
|
||||
public string WindowHotkey { get; set; } = "Alt+PrintScreen";
|
||||
|
||||
/// <summary>Sneltoets voor een screencast die als MP4 wordt aangeboden.</summary>
|
||||
public string RecordHotkey { get; set; } = "Ctrl+Shift+R";
|
||||
|
||||
/// <summary>Sneltoets voor een screencast die als GIF wordt aangeboden.</summary>
|
||||
public string GifHotkey { get; set; } = "Ctrl+Shift+G";
|
||||
public bool RecordSystemAudio { get; set; } = true;
|
||||
public bool RecordMicrophone { get; set; }
|
||||
public bool RecordShowCursor { get; set; } = true;
|
||||
public bool RecordHighlightClicks { get; set; } = true;
|
||||
public int RecordFps { get; set; } = 30;
|
||||
public int GifFps { get; set; } = 10;
|
||||
public int GifMaxWidth { get; set; } = 800;
|
||||
public ScreencastFormat LastScreencastFormat { get; set; } = ScreencastFormat.Mp4;
|
||||
public string? LastVideoFolder { get; set; }
|
||||
|
||||
/// <summary>Map waarin screencasts zonder te vragen worden opgeslagen; leeg betekent elke keer vragen.</summary>
|
||||
public string? VideoSaveFolder { get; set; }
|
||||
|
||||
/// <summary>Gedrag van de knop Opslaan in de editor en de screencast.</summary>
|
||||
public SaveButtonChoice SaveButton { get; set; } = SaveButtonChoice.SaveOnly;
|
||||
|
||||
/// <summary>Rand van het editorvenster waar de werkbalk staat.</summary>
|
||||
public Dock ToolbarPosition { get; set; } = Dock.Top;
|
||||
|
||||
/// <summary>Vergrendeld: knoppen werken gewoon. Ontgrendeld: knoppen kunnen versleept worden.</summary>
|
||||
public bool ToolbarLocked { get; set; } = true;
|
||||
|
||||
/// <summary>Volgorde van de werkbalkitems (ItemId's); leeg betekent de standaardvolgorde.</summary>
|
||||
public List<string> ToolbarOrder { get; set; } = new();
|
||||
|
||||
public List<string> ToolbarHidden { get; set; } = new();
|
||||
|
||||
/// <summary>Zipline-server waar Uploaden naartoe gaat; leeg betekent geen uploadknop.</summary>
|
||||
public UploaderConfig? Uploader { get; set; }
|
||||
|
||||
public StyleSet StyleFor(ToolKind tool)
|
||||
{
|
||||
if (!ToolStyles.TryGetValue(tool, out StyleSet? style))
|
||||
{
|
||||
style = StyleSet.DefaultFor(tool);
|
||||
ToolStyles[tool] = style;
|
||||
}
|
||||
|
||||
return style;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using System.IO;
|
||||
|
||||
namespace Kniip.Settings;
|
||||
|
||||
public static class SaveLocation
|
||||
{
|
||||
/// <summary>Pad in de map dat nog niet bestaat: "naam.png", anders "naam (2).png", "naam (3).png", ...</summary>
|
||||
public static string UniquePath(string folder, string fileName)
|
||||
{
|
||||
string name = Path.GetFileNameWithoutExtension(fileName);
|
||||
string extension = Path.GetExtension(fileName);
|
||||
string path = Path.Combine(folder, fileName);
|
||||
for (int i = 2; File.Exists(path); i++)
|
||||
{
|
||||
path = Path.Combine(folder, $"{name} ({i}){extension}");
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace Kniip.Settings;
|
||||
|
||||
public static class SettingsStore
|
||||
{
|
||||
public static string Folder { get; } =
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Kniip");
|
||||
|
||||
private static string FilePath => Path.Combine(Folder, "settings.json");
|
||||
|
||||
/// <summary>Instellingen van voor de hernoeming van Knip+ naar kniip.</summary>
|
||||
private static string LegacyFilePath =>
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "KnipPlus", "settings.json");
|
||||
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
Converters = { new JsonStringEnumConverter(), new ColorJsonConverter() }
|
||||
};
|
||||
|
||||
public static AppSettings Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(FilePath) && File.Exists(LegacyFilePath))
|
||||
{
|
||||
Directory.CreateDirectory(Folder);
|
||||
File.Copy(LegacyFilePath, FilePath);
|
||||
}
|
||||
|
||||
if (File.Exists(FilePath))
|
||||
{
|
||||
return JsonSerializer.Deserialize<AppSettings>(File.ReadAllText(FilePath), Options) ?? new AppSettings();
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Beschadigd bestand: met standaardwaarden verder.
|
||||
}
|
||||
|
||||
return new AppSettings();
|
||||
}
|
||||
|
||||
public static void Save(AppSettings settings)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Folder);
|
||||
File.WriteAllText(FilePath, JsonSerializer.Serialize(settings, Options));
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Instellingen opslaan mag de app nooit laten crashen.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Slaat de uploadheaders (met de token) met DPAPI versleuteld op, alleen leesbaar voor deze Windows-gebruiker.
|
||||
/// </summary>
|
||||
public sealed class ProtectedHeadersConverter : JsonConverter<Dictionary<string, string>>
|
||||
{
|
||||
private static readonly byte[] Entropy = Encoding.UTF8.GetBytes("kniip-uploader");
|
||||
|
||||
public override Dictionary<string, string> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (reader.TokenType != JsonTokenType.String)
|
||||
{
|
||||
reader.Skip();
|
||||
return result;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
byte[] plain = ProtectedData.Unprotect(Convert.FromBase64String(reader.GetString() ?? string.Empty), Entropy,
|
||||
DataProtectionScope.CurrentUser);
|
||||
if (JsonSerializer.Deserialize<Dictionary<string, string>>(plain) is { } headers)
|
||||
{
|
||||
foreach ((string name, string value) in headers)
|
||||
{
|
||||
result[name] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Van een andere gebruiker of pc: de uploader moet opnieuw worden geïmporteerd.
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, Dictionary<string, string> value, JsonSerializerOptions options)
|
||||
{
|
||||
byte[] plain = JsonSerializer.SerializeToUtf8Bytes(value);
|
||||
writer.WriteStringValue(Convert.ToBase64String(ProtectedData.Protect(plain, Entropy, DataProtectionScope.CurrentUser)));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ColorJsonConverter : JsonConverter<Color>
|
||||
{
|
||||
public override Color Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
string? text = reader.GetString();
|
||||
if (text is { Length: 9 } && text[0] == '#'
|
||||
&& uint.TryParse(text.AsSpan(1), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out uint argb))
|
||||
{
|
||||
return Color.FromArgb((byte)(argb >> 24), (byte)(argb >> 16), (byte)(argb >> 8), (byte)argb);
|
||||
}
|
||||
|
||||
return Colors.Transparent;
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, Color value, JsonSerializerOptions options) =>
|
||||
writer.WriteStringValue($"#{value.A:X2}{value.R:X2}{value.G:X2}{value.B:X2}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
<Window x:Class="Kniip.Settings.SettingsWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="kniip instellingen" Width="720" Height="540" ResizeMode="NoResize"
|
||||
WindowStartupLocation="CenterScreen" Background="{StaticResource WindowBrush}"
|
||||
FontFamily="Segoe UI" FontSize="13" Icon="pack://application:,,,/AppIcon.ico">
|
||||
<Window.Resources>
|
||||
<Style x:Key="CardTitle" TargetType="TextBlock">
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
<Setter Property="Margin" Value="0,0,0,8" />
|
||||
</Style>
|
||||
<Style TargetType="CheckBox">
|
||||
<Setter Property="Margin" Value="0,4" />
|
||||
</Style>
|
||||
<Style TargetType="RadioButton">
|
||||
<Setter Property="Margin" Value="0,4" />
|
||||
</Style>
|
||||
<Style x:Key="Hint" TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{StaticResource SubtleTextBrush}" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="TextWrapping" Value="Wrap" />
|
||||
<Setter Property="Margin" Value="22,0,0,4" />
|
||||
</Style>
|
||||
<Style x:Key="SettingsTabItem" TargetType="TabItem">
|
||||
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Left" />
|
||||
<Setter Property="Padding" Value="12,9" />
|
||||
<Setter Property="Margin" Value="0,1" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="TabItem">
|
||||
<Border x:Name="Bd" Background="Transparent" CornerRadius="4" Margin="0,1,8,1"
|
||||
Padding="{TemplateBinding Padding}" BorderThickness="3,0,0,0" BorderBrush="Transparent"
|
||||
SnapsToDevicePixels="True">
|
||||
<ContentPresenter ContentSource="Header" HorizontalAlignment="Left" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<MultiTrigger>
|
||||
<MultiTrigger.Conditions>
|
||||
<Condition Property="IsMouseOver" Value="True" />
|
||||
<Condition Property="IsSelected" Value="False" />
|
||||
</MultiTrigger.Conditions>
|
||||
<Setter TargetName="Bd" Property="Background" Value="{StaticResource HoverBrush}" />
|
||||
</MultiTrigger>
|
||||
<Trigger Property="IsSelected" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="White" />
|
||||
<Setter TargetName="Bd" Property="BorderBrush" Value="{StaticResource AccentBrush}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource AccentBrush}" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
<Style x:Key="SettingsTabs" TargetType="TabControl">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="TabStripPlacement" Value="Left" />
|
||||
<Setter Property="ItemContainerStyle" Value="{StaticResource SettingsTabItem}" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="TabControl">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="176" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TabPanel Grid.Column="0" IsItemsHost="True" VerticalAlignment="Top" Background="Transparent" />
|
||||
<Border Grid.Column="1" Background="White" BorderBrush="#E5E5E5" BorderThickness="1"
|
||||
CornerRadius="6" SnapsToDevicePixels="True">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled"
|
||||
Padding="16,14">
|
||||
<ContentPresenter ContentSource="SelectedContent" />
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
</Grid>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
</Window.Resources>
|
||||
|
||||
<DockPanel Margin="20">
|
||||
<DockPanel DockPanel.Dock="Bottom" Margin="0,14,0,0">
|
||||
<Button Style="{StaticResource SecondaryButton}" Content="Standaardinstellingen" Click="ResetDefaults_Click"
|
||||
ToolTip="Instellingen, toolstijlen, werkbalk en laatst gebruikte mappen terugzetten; pas bij Opslaan worden ze bewaard" />
|
||||
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal">
|
||||
<Button Style="{StaticResource SecondaryButton}" Content="Annuleren" IsCancel="True" Click="Cancel_Click" />
|
||||
<Button Style="{StaticResource AccentButton}" Content="Opslaan" IsDefault="True" Click="Save_Click" Margin="4,0,0,0" />
|
||||
</StackPanel>
|
||||
<TextBlock x:Name="ResetStatus" Style="{StaticResource Hint}" Margin="10,0" VerticalAlignment="Center" Visibility="Collapsed"
|
||||
Text="Standaardwaarden ingevuld; klik op Opslaan om ze te bewaren." />
|
||||
</DockPanel>
|
||||
|
||||
<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">
|
||||
<StackPanel>
|
||||
<TextBlock Text="Sneltoetsen" Style="{StaticResource CardTitle}" />
|
||||
<StackPanel Orientation="Horizontal" Margin="0,2,0,4">
|
||||
<TextBlock Text="Rechthoek" VerticalAlignment="Center" Width="150" />
|
||||
<Button x:Name="SnipHotkeyButton" Tag="Snip" Style="{StaticResource SecondaryButton}" Width="170" Margin="0"
|
||||
Click="HotkeyButton_Click" ToolTip="Klik en druk daarna de gewenste toets in, bijvoorbeeld Print Screen" />
|
||||
<Button Tag="Snip" 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,4">
|
||||
<TextBlock Text="Venster" VerticalAlignment="Center" Width="150" />
|
||||
<Button x:Name="WindowHotkeyButton" Tag="Window" Style="{StaticResource SecondaryButton}" Width="170" Margin="0"
|
||||
Click="HotkeyButton_Click" ToolTip="Klik en druk daarna de gewenste toets in, bijvoorbeeld Alt + Print Screen" />
|
||||
<Button Tag="Window" 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,4">
|
||||
<TextBlock Text="Screencast (MP4)" VerticalAlignment="Center" Width="150" />
|
||||
<Button x:Name="RecordHotkeyButton" Tag="Record" Style="{StaticResource SecondaryButton}" Width="170" Margin="0"
|
||||
Click="HotkeyButton_Click" ToolTip="Klik en druk daarna de gewenste toetscombinatie in" />
|
||||
<Button Tag="Record" 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="Screencast (GIF)" VerticalAlignment="Center" Width="150" />
|
||||
<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" />
|
||||
<Button Tag="Gif" 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" />
|
||||
</StackPanel>
|
||||
</TabItem>
|
||||
|
||||
<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>
|
||||
</TabItem>
|
||||
|
||||
<TabItem Header="Opslaan">
|
||||
<StackPanel>
|
||||
<TextBlock Text="Opslaan" Style="{StaticResource CardTitle}" />
|
||||
<DockPanel Margin="0,2,0,4">
|
||||
<TextBlock Text="Knipsels" VerticalAlignment="Center" Width="110" />
|
||||
<Button Tag="Snip" DockPanel.Dock="Right" Style="{StaticResource FlatButton}" Content="Vragen" Click="ClearSaveFolder_Click"
|
||||
Margin="6,0,0,0" ToolTip="Elke keer vragen waar het knipsel wordt opgeslagen" />
|
||||
<Button Tag="Snip" DockPanel.Dock="Right" Style="{StaticResource SecondaryButton}" Content="Bladeren..." Click="BrowseSaveFolder_Click"
|
||||
Margin="6,0,0,0" />
|
||||
<TextBlock x:Name="SnipFolderText" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
|
||||
</DockPanel>
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
<TextBlock Text="Screencasts" VerticalAlignment="Center" Width="110" />
|
||||
<Button Tag="Video" DockPanel.Dock="Right" Style="{StaticResource FlatButton}" Content="Vragen" Click="ClearSaveFolder_Click"
|
||||
Margin="6,0,0,0" ToolTip="Elke keer vragen waar de screencast wordt opgeslagen" />
|
||||
<Button Tag="Video" DockPanel.Dock="Right" Style="{StaticResource SecondaryButton}" Content="Bladeren..." Click="BrowseSaveFolder_Click"
|
||||
Margin="6,0,0,0" />
|
||||
<TextBlock x:Name="VideoFolderText" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
|
||||
</DockPanel>
|
||||
<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." />
|
||||
<RadioButton x:Name="SaveOnlyRadio" GroupName="SaveButton" Content="Opslaan" />
|
||||
<RadioButton x:Name="SaveAndCopyRadio" GroupName="SaveButton" Content="Opslaan en kopiëren" />
|
||||
<TextBlock Style="{StaticResource Hint}"
|
||||
Text="Opslaan en kopiëren zet het bestand ook op het klembord, zodat je het meteen kunt plakken." />
|
||||
</StackPanel>
|
||||
</TabItem>
|
||||
|
||||
<TabItem Header="Uploaden">
|
||||
<StackPanel>
|
||||
<TextBlock Text="Uploaden naar Zipline" Style="{StaticResource CardTitle}" />
|
||||
<DockPanel Margin="0,2,0,4">
|
||||
<TextBlock Text="Server" VerticalAlignment="Center" Width="110" />
|
||||
<Button DockPanel.Dock="Right" Style="{StaticResource FlatButton}" Content="Verwijderen" Click="RemoveUploader_Click"
|
||||
x:Name="RemoveUploaderButton" Margin="6,0,0,0" ToolTip="Geen uploadbestemming meer gebruiken" />
|
||||
<Button DockPanel.Dock="Right" Style="{StaticResource SecondaryButton}" Content="Testen" Click="TestUploader_Click"
|
||||
x:Name="TestUploaderButton" Margin="6,0,0,0" ToolTip="Een klein testplaatje uploaden" />
|
||||
<Button DockPanel.Dock="Right" Style="{StaticResource SecondaryButton}" Content="Importeren..." Click="ImportUploader_Click"
|
||||
Margin="6,0,0,0" ToolTip="ShareX-uploader (.sxcu) of Shell Script (.sh) uit Zipline kiezen" />
|
||||
<TextBlock x:Name="UploaderText" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
|
||||
</DockPanel>
|
||||
<TextBlock x:Name="UploaderStatus" Style="{StaticResource Hint}" Margin="0,4,0,4" Visibility="Collapsed" />
|
||||
<TextBlock Style="{StaticResource Hint}" Margin="0,4,0,4"
|
||||
Text="Maak het bestand in Zipline via Instellingen > Generate Uploaders (ShareX of Shell Script) en importeer het hier. kniip leest alleen het adres en de token uit; het script wordt niet uitgevoerd." />
|
||||
<TextBlock Style="{StaticResource Hint}" Margin="0,0,0,4"
|
||||
Text="Daarna staat er een knop Uploaden in de editor (Ctrl+U) en bij screencasts. De link komt op het klembord. De token wordt versleuteld bewaard voor jouw Windows-account." />
|
||||
</StackPanel>
|
||||
</TabItem>
|
||||
|
||||
<TabItem Header="Screencasts">
|
||||
<StackPanel>
|
||||
<TextBlock Text="Screencasts" Style="{StaticResource CardTitle}" />
|
||||
<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." />
|
||||
<CheckBox x:Name="SystemAudioBox" Content="Systeemgeluid opnemen (standaard)" />
|
||||
<CheckBox x:Name="MicrophoneBox" Content="Microfoon opnemen (standaard)" />
|
||||
<CheckBox x:Name="ShowCursorBox" Content="Muisaanwijzer tonen" />
|
||||
<CheckBox x:Name="HighlightClicksBox" Content="Muisklikken markeren (geel = links, blauw = rechts)" />
|
||||
<StackPanel Orientation="Horizontal" Margin="0,8,0,0">
|
||||
<TextBlock Text="Video" VerticalAlignment="Center" Width="110" />
|
||||
<ComboBox x:Name="VideoFpsCombo" Style="{StaticResource PropCombo}" Width="130" />
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,6,0,0">
|
||||
<TextBlock Text="GIF" VerticalAlignment="Center" Width="110" />
|
||||
<ComboBox x:Name="GifFpsCombo" Style="{StaticResource PropCombo}" Width="130" />
|
||||
<ComboBox x:Name="GifWidthCombo" Style="{StaticResource PropCombo}" Width="150" Margin="6,0,0,0" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</TabItem>
|
||||
|
||||
<TabItem Header="Windows">
|
||||
<StackPanel>
|
||||
<TextBlock Text="Windows-integratie" Style="{StaticResource CardTitle}" />
|
||||
<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)" />
|
||||
<TextBlock Style="{StaticResource Hint}"
|
||||
Text="Alleen voor jouw gebruikersaccount; er is geen beheerder nodig en het Knipprogramma zelf wordt niet aangepast." />
|
||||
</StackPanel>
|
||||
</TabItem>
|
||||
|
||||
<TabItem Header="Editor">
|
||||
<StackPanel>
|
||||
<TextBlock Text="Editor" Style="{StaticResource CardTitle}" />
|
||||
<CheckBox x:Name="StraightHighlightBox" Content="Markeerstift trekt standaard een rechte horizontale streep (Shift wisselt)" />
|
||||
</StackPanel>
|
||||
</TabItem>
|
||||
</TabControl>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@@ -0,0 +1,469 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Documents;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using Kniip.Capture;
|
||||
using Kniip.Editor;
|
||||
using Kniip.Infrastructure;
|
||||
using Kniip.Upload;
|
||||
|
||||
namespace Kniip.Settings;
|
||||
|
||||
public partial class SettingsWindow : Window
|
||||
{
|
||||
private const string HotkeyHintText =
|
||||
"kniip vangt deze toetsen in alle programma's af; bij Print Screen opent het Windows-knipprogramma dan niet óók.";
|
||||
|
||||
private static readonly Dictionary<string, string> HotkeyNames = new()
|
||||
{
|
||||
["Snip"] = "rechthoek knippen",
|
||||
["Window"] = "venster knippen",
|
||||
["Record"] = "screencast (MP4)",
|
||||
["Gif"] = "screencast (GIF)",
|
||||
};
|
||||
|
||||
private readonly AppSettings _settings;
|
||||
private readonly Dictionary<string, string> _hotkeys;
|
||||
private readonly Dictionary<string, Button> _hotkeyButtons;
|
||||
private string? _listeningFor;
|
||||
private string? _snipFolder;
|
||||
private string? _videoFolder;
|
||||
private UploaderConfig? _uploader;
|
||||
private bool _resetEditor;
|
||||
|
||||
public SettingsWindow(AppSettings settings)
|
||||
{
|
||||
InitializeComponent();
|
||||
_settings = settings;
|
||||
|
||||
FolderText.Text = CaptureCoordinator.ScreenshotsFolder ?? "Map Schermafbeeldingen niet gevonden.";
|
||||
AutostartBox.IsChecked = ShellIntegration.IsAutostartEnabled();
|
||||
OpenWithBox.IsChecked = ShellIntegration.IsOpenWithRegistered();
|
||||
|
||||
WatchClipboardBox.Checked += (_, _) => OnlySnippingToolBox.IsEnabled = true;
|
||||
WatchClipboardBox.Unchecked += (_, _) => OnlySnippingToolBox.IsEnabled = false;
|
||||
|
||||
_uploader = settings.Uploader;
|
||||
ShowUploader();
|
||||
|
||||
_hotkeys = new Dictionary<string, string>();
|
||||
_hotkeyButtons = new Dictionary<string, Button>
|
||||
{
|
||||
["Snip"] = SnipHotkeyButton,
|
||||
["Window"] = WindowHotkeyButton,
|
||||
["Record"] = RecordHotkeyButton,
|
||||
["Gif"] = GifHotkeyButton,
|
||||
};
|
||||
|
||||
HotkeyHint.Text = App.Instance.HotkeyHook?.IsActive == true
|
||||
? HotkeyHintText
|
||||
: "De toetsenbordhaak kon niet worden gestart; de sneltoetsen werken nu niet.";
|
||||
Deactivated += (_, _) => StopListening();
|
||||
Closed += (_, _) => StopListening();
|
||||
|
||||
LoadFrom(settings);
|
||||
|
||||
MaxHeight = SystemParameters.WorkArea.Height;
|
||||
}
|
||||
|
||||
/// <summary>Vult het formulier; de uploader en de Windows-integratie blijven ongemoeid.</summary>
|
||||
private void LoadFrom(AppSettings settings)
|
||||
{
|
||||
WatchClipboardBox.IsChecked = settings.WatchClipboard;
|
||||
OnlySnippingToolBox.IsChecked = settings.OnlyFromSnippingTool;
|
||||
OnlySnippingToolBox.IsEnabled = settings.WatchClipboard;
|
||||
WatchFolderBox.IsChecked = settings.WatchScreenshotsFolder;
|
||||
ActionNotifyRadio.IsChecked = settings.Action == CaptureAction.Notify;
|
||||
ActionOpenRadio.IsChecked = settings.Action == CaptureAction.OpenEditor;
|
||||
ActionNothingRadio.IsChecked = settings.Action == CaptureAction.Nothing;
|
||||
StraightHighlightBox.IsChecked = settings.StraightHighlight;
|
||||
|
||||
_snipFolder = settings.SnipSaveFolder;
|
||||
_videoFolder = settings.VideoSaveFolder;
|
||||
ShowSaveFolders();
|
||||
SaveAndCopyRadio.IsChecked = settings.SaveButton == SaveButtonChoice.SaveAndCopy;
|
||||
SaveOnlyRadio.IsChecked = settings.SaveButton != SaveButtonChoice.SaveAndCopy;
|
||||
|
||||
_hotkeys["Snip"] = settings.SnipHotkey;
|
||||
_hotkeys["Window"] = settings.WindowHotkey;
|
||||
_hotkeys["Record"] = settings.RecordHotkey;
|
||||
_hotkeys["Gif"] = settings.GifHotkey;
|
||||
foreach (string id in _hotkeys.Keys)
|
||||
{
|
||||
ShowHotkey(id);
|
||||
}
|
||||
|
||||
SystemAudioBox.IsChecked = settings.RecordSystemAudio;
|
||||
MicrophoneBox.IsChecked = settings.RecordMicrophone;
|
||||
ShowCursorBox.IsChecked = settings.RecordShowCursor;
|
||||
HighlightClicksBox.IsChecked = settings.RecordHighlightClicks;
|
||||
FillChoices(VideoFpsCombo, new[] { 15, 30, 60 }, settings.RecordFps, fps => $"{fps} beelden/s");
|
||||
FillChoices(GifFpsCombo, new[] { 5, 10, 15, 20, 25 }, settings.GifFps, fps => $"{fps} beelden/s");
|
||||
FillChoices(GifWidthCombo, new[] { 0, 1920, 1280, 960, 800, 640, 480 }, settings.GifMaxWidth,
|
||||
width => width == 0 ? "Originele breedte" : $"max. {width} px breed");
|
||||
}
|
||||
|
||||
private void ResetDefaults_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
StopListening();
|
||||
LoadFrom(new AppSettings());
|
||||
_resetEditor = true;
|
||||
ResetStatus.Visibility = Visibility.Visible;
|
||||
}
|
||||
|
||||
private static void FillChoices(ComboBox combo, int[] values, int current, Func<int, string> label)
|
||||
{
|
||||
combo.Items.Clear();
|
||||
foreach (int value in values.Contains(current) ? values : values.Append(current).ToArray())
|
||||
{
|
||||
var item = new ComboBoxItem { Content = label(value), Tag = value };
|
||||
combo.Items.Add(item);
|
||||
if (value == current)
|
||||
{
|
||||
combo.SelectedItem = item;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static int SelectedValue(ComboBox combo, int fallback) =>
|
||||
combo.SelectedItem is ComboBoxItem { Tag: int value } ? value : fallback;
|
||||
|
||||
private void ShowHotkey(string id) =>
|
||||
_hotkeyButtons[id].Content = HotkeyGesture.TryParse(_hotkeys[id], out HotkeyGesture gesture) ? gesture.DisplayText : "(geen)";
|
||||
|
||||
private void HotkeyButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
GlobalHotkeyHook? hook = App.Instance.HotkeyHook;
|
||||
if (hook is not { IsActive: true } || sender is not FrameworkElement { Tag: string id })
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
StopListening();
|
||||
// Via de haak, niet via WPF: Print Screen komt nooit als KeyDown bij een venster aan.
|
||||
_listeningFor = id;
|
||||
hook.Capture = OnHotkeyPressed;
|
||||
_hotkeyButtons[id].Content = "Druk op een toets...";
|
||||
HotkeyHint.Text = "Esc = annuleren · Delete = geen sneltoets";
|
||||
}
|
||||
|
||||
private void OnHotkeyPressed(HotkeyGesture pressed)
|
||||
{
|
||||
if (_listeningFor is not string id)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (pressed.Modifiers == ModifierKeys.None && pressed.Key == Key.Escape)
|
||||
{
|
||||
StopListening();
|
||||
return;
|
||||
}
|
||||
|
||||
if (pressed.Modifiers == ModifierKeys.None && pressed.Key is Key.Delete or Key.Back)
|
||||
{
|
||||
_hotkeys[id] = string.Empty;
|
||||
StopListening();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pressed.IsAllowed)
|
||||
{
|
||||
HotkeyHint.Text = $"{pressed.DisplayText} gebruik je om te typen. Kies Print Screen, Pause, een F-toets of een combinatie met Ctrl, Alt of Win.";
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ((string other, string hotkey) in _hotkeys)
|
||||
{
|
||||
if (other != id && HotkeyGesture.TryParse(hotkey, out HotkeyGesture taken) && taken == pressed)
|
||||
{
|
||||
HotkeyHint.Text = $"{pressed.DisplayText} is al de sneltoets voor {HotkeyNames[other]}; kies een andere.";
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_hotkeys[id] = pressed.ToString();
|
||||
StopListening();
|
||||
}
|
||||
|
||||
private void StopListening()
|
||||
{
|
||||
if (_listeningFor is not string id)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_listeningFor = null;
|
||||
if (App.Instance.HotkeyHook is { } hook)
|
||||
{
|
||||
hook.Capture = null;
|
||||
}
|
||||
|
||||
ShowHotkey(id);
|
||||
HotkeyHint.Text = HotkeyHintText;
|
||||
}
|
||||
|
||||
private void ClearHotkey_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is not FrameworkElement { Tag: string id })
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
StopListening();
|
||||
_hotkeys[id] = string.Empty;
|
||||
ShowHotkey(id);
|
||||
}
|
||||
|
||||
private void ShowSaveFolders()
|
||||
{
|
||||
ShowSaveFolder(SnipFolderText, _snipFolder);
|
||||
ShowSaveFolder(VideoFolderText, _videoFolder);
|
||||
}
|
||||
|
||||
private static void ShowSaveFolder(TextBlock text, string? folder)
|
||||
{
|
||||
text.Text = string.IsNullOrEmpty(folder) ? "Elke keer vragen" : folder;
|
||||
text.ToolTip = folder;
|
||||
text.Foreground = string.IsNullOrEmpty(folder) ? (Brush)Application.Current.Resources["SubtleTextBrush"] : SystemColors.ControlTextBrush;
|
||||
}
|
||||
|
||||
private void BrowseSaveFolder_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
bool snip = sender is FrameworkElement { Tag: "Snip" };
|
||||
string? current = snip ? _snipFolder : _videoFolder;
|
||||
var dialog = new Microsoft.Win32.OpenFolderDialog
|
||||
{
|
||||
Title = snip ? "Map voor knipsels kiezen" : "Map voor screencasts kiezen",
|
||||
InitialDirectory = current is { Length: > 0 } && System.IO.Directory.Exists(current)
|
||||
? current
|
||||
: Environment.GetFolderPath(snip ? Environment.SpecialFolder.MyPictures : Environment.SpecialFolder.MyVideos)
|
||||
};
|
||||
if (dialog.ShowDialog(this) != true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (snip)
|
||||
{
|
||||
_snipFolder = dialog.FolderName;
|
||||
}
|
||||
else
|
||||
{
|
||||
_videoFolder = dialog.FolderName;
|
||||
}
|
||||
|
||||
ShowSaveFolders();
|
||||
}
|
||||
|
||||
private void ClearSaveFolder_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is FrameworkElement { Tag: "Snip" })
|
||||
{
|
||||
_snipFolder = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
_videoFolder = null;
|
||||
}
|
||||
|
||||
ShowSaveFolders();
|
||||
}
|
||||
|
||||
private void ShowUploader()
|
||||
{
|
||||
bool configured = _uploader is { IsConfigured: true };
|
||||
UploaderText.Text = configured ? _uploader!.DisplayName : "Niet ingesteld";
|
||||
UploaderText.ToolTip = configured ? _uploader!.RequestUrl : null;
|
||||
UploaderText.Foreground = configured ? SystemColors.ControlTextBrush : (Brush)Application.Current.Resources["SubtleTextBrush"];
|
||||
TestUploaderButton.IsEnabled = configured;
|
||||
RemoveUploaderButton.IsEnabled = configured;
|
||||
}
|
||||
|
||||
private void ShowUploaderStatus(string? message, string? url = null, string? suffix = null)
|
||||
{
|
||||
UploaderStatus.Inlines.Clear();
|
||||
UploaderStatus.Inlines.Add(new Run(message ?? string.Empty));
|
||||
if (url != null)
|
||||
{
|
||||
var link = new Hyperlink(new Run(url)) { ToolTip = "Openen in de browser" };
|
||||
link.Click += (_, _) => OpenUrl(url);
|
||||
UploaderStatus.Inlines.Add(link);
|
||||
UploaderStatus.Inlines.Add(new Run(suffix ?? string.Empty));
|
||||
}
|
||||
|
||||
UploaderStatus.Visibility = string.IsNullOrEmpty(message) && url == null ? Visibility.Collapsed : Visibility.Visible;
|
||||
}
|
||||
|
||||
private static void OpenUrl(string url)
|
||||
{
|
||||
try
|
||||
{
|
||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(url) { UseShellExecute = true });
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// De link staat ook op het klembord.
|
||||
}
|
||||
}
|
||||
|
||||
private void ImportUploader_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var dialog = new Microsoft.Win32.OpenFileDialog
|
||||
{
|
||||
Title = "Zipline-uploader importeren",
|
||||
Filter = "Zipline-uploader (*.sxcu;*.sh)|*.sxcu;*.sh|ShareX-uploader (*.sxcu)|*.sxcu|Shell script (*.sh)|*.sh|Alle bestanden (*.*)|*.*",
|
||||
InitialDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) is { } home
|
||||
&& System.IO.Directory.Exists(System.IO.Path.Combine(home, "Downloads"))
|
||||
? System.IO.Path.Combine(home, "Downloads")
|
||||
: string.Empty
|
||||
};
|
||||
if (dialog.ShowDialog(this) != true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_uploader = UploaderImporter.FromFile(dialog.FileName);
|
||||
ShowUploader();
|
||||
ShowUploaderStatus($"Geïmporteerd: {_uploader.Name}. Klik op Opslaan om dit te bewaren.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show(this, $"Dit bestand kan niet worden gebruikt:\n{dialog.FileName}\n\n{ex.Message}", "kniip",
|
||||
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
private async void TestUploader_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_uploader is not { IsConfigured: true } config)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
TestUploaderButton.IsEnabled = false;
|
||||
ShowUploaderStatus("Testplaatje uploaden...");
|
||||
try
|
||||
{
|
||||
using System.IO.MemoryStream png = ImageExporter.EncodePng(CreateTestImage());
|
||||
string url = await ZiplineUploader.UploadAsync(config, png, "kniip-test.png", "image/png");
|
||||
bool copied = ClipboardWriter.SetText(url);
|
||||
ShowUploaderStatus("Gelukt: ", url, copied ? " (gekopieerd)" : null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ShowUploaderStatus(null);
|
||||
MessageBox.Show(this, $"De test-upload is mislukt.\n\n{ex.Message}", "kniip", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TestUploaderButton.IsEnabled = _uploader is { IsConfigured: true };
|
||||
}
|
||||
}
|
||||
|
||||
private static BitmapSource CreateTestImage()
|
||||
{
|
||||
const int size = 64;
|
||||
Color accent = Application.Current.Resources["AccentBrush"] is SolidColorBrush brush ? brush.Color : Colors.SteelBlue;
|
||||
var pixels = new byte[size * size * 4];
|
||||
for (int i = 0; i < pixels.Length; i += 4)
|
||||
{
|
||||
pixels[i] = accent.B;
|
||||
pixels[i + 1] = accent.G;
|
||||
pixels[i + 2] = accent.R;
|
||||
pixels[i + 3] = 255;
|
||||
}
|
||||
|
||||
return BitmapSource.Create(size, size, 96, 96, PixelFormats.Bgra32, null, pixels, size * 4);
|
||||
}
|
||||
|
||||
private void RemoveUploader_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
_uploader = null;
|
||||
ShowUploader();
|
||||
ShowUploaderStatus(null);
|
||||
}
|
||||
|
||||
private void Save_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
_settings.Uploader = _uploader;
|
||||
_settings.SnipSaveFolder = _snipFolder;
|
||||
_settings.VideoSaveFolder = _videoFolder;
|
||||
_settings.SaveButton = SaveAndCopyRadio.IsChecked == true
|
||||
? SaveButtonChoice.SaveAndCopy
|
||||
: SaveButtonChoice.SaveOnly;
|
||||
_settings.WatchClipboard = WatchClipboardBox.IsChecked == true;
|
||||
_settings.OnlyFromSnippingTool = OnlySnippingToolBox.IsChecked == true;
|
||||
_settings.WatchScreenshotsFolder = WatchFolderBox.IsChecked == true;
|
||||
_settings.Action = ActionOpenRadio.IsChecked == true ? CaptureAction.OpenEditor
|
||||
: ActionNothingRadio.IsChecked == true ? CaptureAction.Nothing
|
||||
: CaptureAction.Notify;
|
||||
_settings.StraightHighlight = StraightHighlightBox.IsChecked == true;
|
||||
StopListening();
|
||||
_settings.SnipHotkey = _hotkeys["Snip"];
|
||||
_settings.WindowHotkey = _hotkeys["Window"];
|
||||
_settings.RecordHotkey = _hotkeys["Record"];
|
||||
_settings.GifHotkey = _hotkeys["Gif"];
|
||||
_settings.RecordSystemAudio = SystemAudioBox.IsChecked == true;
|
||||
_settings.RecordMicrophone = MicrophoneBox.IsChecked == true;
|
||||
_settings.RecordShowCursor = ShowCursorBox.IsChecked == true;
|
||||
_settings.RecordHighlightClicks = HighlightClicksBox.IsChecked == true;
|
||||
_settings.RecordFps = SelectedValue(VideoFpsCombo, 30);
|
||||
_settings.GifFps = SelectedValue(GifFpsCombo, 10);
|
||||
_settings.GifMaxWidth = SelectedValue(GifWidthCombo, 800);
|
||||
|
||||
if (_resetEditor)
|
||||
{
|
||||
var defaults = new AppSettings();
|
||||
_settings.ToolStyles = defaults.ToolStyles;
|
||||
_settings.LastTool = defaults.LastTool;
|
||||
_settings.LastSaveFolder = defaults.LastSaveFolder;
|
||||
_settings.LastVideoFolder = defaults.LastVideoFolder;
|
||||
_settings.ToolbarPosition = defaults.ToolbarPosition;
|
||||
_settings.ToolbarLocked = defaults.ToolbarLocked;
|
||||
_settings.ToolbarOrder = defaults.ToolbarOrder;
|
||||
_settings.ToolbarHidden = defaults.ToolbarHidden;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
ShellIntegration.SetAutostart(AutostartBox.IsChecked == true);
|
||||
|
||||
bool openWith = OpenWithBox.IsChecked == true;
|
||||
if (openWith && !ShellIntegration.IsOpenWithRegistered())
|
||||
{
|
||||
ShellIntegration.RegisterOpenWith();
|
||||
}
|
||||
else if (!openWith && ShellIntegration.IsOpenWithRegistered())
|
||||
{
|
||||
ShellIntegration.UnregisterOpenWith();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show(this, $"Windows-integratie kon niet worden bijgewerkt:\n\n{ex.Message}", "kniip",
|
||||
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
}
|
||||
|
||||
App.Instance.SaveSettings();
|
||||
Close();
|
||||
}
|
||||
|
||||
private void Cancel_Click(object sender, RoutedEventArgs e) => Close();
|
||||
|
||||
private void Tabs_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
// ComboBox-keuzes bubbelen dezelfde gebeurtenis; alleen een echt tabblad telt.
|
||||
if (e.OriginalSource is TabControl)
|
||||
{
|
||||
StopListening();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
using Kniip.Settings;
|
||||
|
||||
namespace Kniip.Upload;
|
||||
|
||||
/// <summary>Bestemming voor uploads, geïmporteerd uit een ShareX- of shellscript-uploader van Zipline.</summary>
|
||||
public sealed class UploaderConfig
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public string RequestUrl { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Bevat de token (authorization); staat daarom versleuteld in settings.json.</summary>
|
||||
[JsonConverter(typeof(SettingsStore.ProtectedHeadersConverter))]
|
||||
public Dictionary<string, string> Headers { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public string FileFormName { get; set; } = "file";
|
||||
|
||||
/// <summary>Pad naar de link in het JSON-antwoord, bijvoorbeeld <c>files[0].url</c>.</summary>
|
||||
public string UrlPath { get; set; } = "files[0].url";
|
||||
|
||||
[JsonIgnore]
|
||||
public bool IsConfigured => Uri.TryCreate(RequestUrl, UriKind.Absolute, out Uri? uri)
|
||||
&& (uri.Scheme == Uri.UriSchemeHttps || uri.Scheme == Uri.UriSchemeHttp);
|
||||
|
||||
[JsonIgnore]
|
||||
public string DisplayName => Uri.TryCreate(RequestUrl, UriKind.Absolute, out Uri? uri) ? uri.Authority : RequestUrl;
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Kniip.Upload;
|
||||
|
||||
/// <summary>
|
||||
/// Leest de uploaders die Zipline genereert (ShareX .sxcu en Shell Script .sh) in.
|
||||
/// Het script wordt nooit uitgevoerd; alleen de gegevens uit het curl-commando worden overgenomen.
|
||||
/// </summary>
|
||||
public static class UploaderImporter
|
||||
{
|
||||
public static UploaderConfig FromFile(string path)
|
||||
{
|
||||
string text = File.ReadAllText(path);
|
||||
return Path.GetExtension(path).Equals(".sxcu", StringComparison.OrdinalIgnoreCase) || text.TrimStart().StartsWith('{')
|
||||
? FromSxcu(text)
|
||||
: FromShellScript(text);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- ShareX
|
||||
|
||||
public static UploaderConfig FromSxcu(string json)
|
||||
{
|
||||
JsonDocument document;
|
||||
try
|
||||
{
|
||||
document = JsonDocument.Parse(json, new JsonDocumentOptions { AllowTrailingCommas = true, CommentHandling = JsonCommentHandling.Skip });
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new InvalidDataException($"Dit is geen geldig ShareX-uploaderbestand.\n\n{ex.Message}");
|
||||
}
|
||||
|
||||
using (document)
|
||||
{
|
||||
JsonElement root = document.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
throw new InvalidDataException("Dit is geen geldig ShareX-uploaderbestand.");
|
||||
}
|
||||
|
||||
string? body = GetString(root, "Body");
|
||||
if (body != null && !body.Equals("MultipartFormData", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
"Deze uploader verstuurt geen bestanden (misschien is het de URL-verkorter). Kies in Zipline de uploader voor bestanden.");
|
||||
}
|
||||
|
||||
string? method = GetString(root, "RequestMethod");
|
||||
if (method != null && !method.Equals("POST", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidDataException($"Deze uploader gebruikt {method}; kniip ondersteunt alleen POST.");
|
||||
}
|
||||
|
||||
var config = new UploaderConfig
|
||||
{
|
||||
RequestUrl = GetString(root, "RequestURL")?.Trim() ?? string.Empty,
|
||||
FileFormName = GetString(root, "FileFormName") is { Length: > 0 } field ? field : "file",
|
||||
UrlPath = ExtractJsonPath(GetString(root, "URL")) ?? "files[0].url"
|
||||
};
|
||||
|
||||
if (TryGetProperty(root, "Headers", out JsonElement headers) && headers.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (JsonProperty header in headers.EnumerateObject())
|
||||
{
|
||||
config.Headers[header.Name] = header.Value.ValueKind == JsonValueKind.String
|
||||
? header.Value.GetString() ?? string.Empty
|
||||
: header.Value.GetRawText();
|
||||
}
|
||||
}
|
||||
|
||||
config.Name = GetString(root, "Name") is { Length: > 0 } name ? name : config.DisplayName;
|
||||
return Validate(config);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Zet <c>{json:files[0].url}</c> (of het oudere <c>$json:files[0].url$</c>) om naar <c>files[0].url</c>.</summary>
|
||||
private static string? ExtractJsonPath(string? url)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Match match = Regex.Match(url, @"\{json:([^}]+)\}|\$json:([^$]+)\$");
|
||||
if (!match.Success)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string path = match.Groups[1].Success ? match.Groups[1].Value : match.Groups[2].Value;
|
||||
return path.Trim().TrimStart('.');
|
||||
}
|
||||
|
||||
private static string? GetString(JsonElement obj, string name) =>
|
||||
TryGetProperty(obj, name, out JsonElement value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null;
|
||||
|
||||
private static bool TryGetProperty(JsonElement obj, string name, out JsonElement value)
|
||||
{
|
||||
foreach (JsonProperty property in obj.EnumerateObject())
|
||||
{
|
||||
if (property.Name.Equals(name, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
value = property.Value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Shellscript
|
||||
|
||||
public static UploaderConfig FromShellScript(string script)
|
||||
{
|
||||
string text = Regex.Replace(script, @"\\\r?\n", " ");
|
||||
string[] lines = text.Split('\n').Select(l => l.TrimEnd('\r')).ToArray();
|
||||
|
||||
var variables = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (string line in lines)
|
||||
{
|
||||
Match assignment = Regex.Match(line, @"^\s*(?:export\s+|local\s+|readonly\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$");
|
||||
if (assignment.Success && !assignment.Groups[2].Value.TrimStart().StartsWith("$("))
|
||||
{
|
||||
List<string> value = Tokenize(assignment.Groups[2].Value, variables);
|
||||
variables[assignment.Groups[1].Value] = value.FirstOrDefault() ?? string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
string? curlLine = lines.FirstOrDefault(l => Regex.IsMatch(l, @"(^|[\s(`|;])curl\s") && !l.TrimStart().StartsWith('#'));
|
||||
if (curlLine == null)
|
||||
{
|
||||
throw new InvalidDataException("In dit script staat geen curl-commando. Kies in Zipline de Shell Script-uploader voor bestanden.");
|
||||
}
|
||||
|
||||
Match curl = Regex.Match(curlLine, @"(^|[\s(`|;])curl\s");
|
||||
List<string> tokens = Tokenize(curlLine[(curl.Index + curl.Length)..], variables);
|
||||
|
||||
var config = new UploaderConfig { FileFormName = string.Empty };
|
||||
for (int i = 0; i < tokens.Count; i++)
|
||||
{
|
||||
string token = tokens[i];
|
||||
string? next = i + 1 < tokens.Count ? tokens[i + 1] : null;
|
||||
switch (token)
|
||||
{
|
||||
case "-H" or "--header" when next != null:
|
||||
AddHeader(config, next);
|
||||
i++;
|
||||
break;
|
||||
case "-F" or "--form" when next != null:
|
||||
AddFormField(config, next);
|
||||
i++;
|
||||
break;
|
||||
case "--url" when next != null:
|
||||
config.RequestUrl = next;
|
||||
i++;
|
||||
break;
|
||||
default:
|
||||
if (token.StartsWith("-H", StringComparison.Ordinal) && token.Length > 2)
|
||||
{
|
||||
AddHeader(config, token[2..]);
|
||||
}
|
||||
else if (token.StartsWith("-F", StringComparison.Ordinal) && token.Length > 2)
|
||||
{
|
||||
AddFormField(config, token[2..]);
|
||||
}
|
||||
else if (config.RequestUrl.Length == 0
|
||||
&& (token.StartsWith("https://", StringComparison.OrdinalIgnoreCase)
|
||||
|| token.StartsWith("http://", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
config.RequestUrl = token;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (config.FileFormName.Length == 0)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
"Dit script uploadt geen bestand (misschien is het de URL-verkorter). Kies in Zipline de Shell Script-uploader voor bestanden.");
|
||||
}
|
||||
|
||||
Match jq = Regex.Match(text, @"\bjq\s+(?:-\w+\s+)*['""]?\.?([A-Za-z0-9_\[\]\.]+)['""]?");
|
||||
if (jq.Success && jq.Groups[1].Value.Length > 0)
|
||||
{
|
||||
config.UrlPath = jq.Groups[1].Value;
|
||||
}
|
||||
|
||||
config.Name = config.DisplayName;
|
||||
return Validate(config);
|
||||
}
|
||||
|
||||
private static void AddHeader(UploaderConfig config, string header)
|
||||
{
|
||||
int colon = header.IndexOf(':');
|
||||
if (colon <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string name = header[..colon].Trim();
|
||||
if (name.Equals("content-type", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
config.Headers[name] = header[(colon + 1)..].Trim();
|
||||
}
|
||||
|
||||
private static void AddFormField(UploaderConfig config, string field)
|
||||
{
|
||||
int equals = field.IndexOf("=@", StringComparison.Ordinal);
|
||||
if (equals > 0)
|
||||
{
|
||||
config.FileFormName = field[..equals].Trim();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Splitst een shellregel in woorden zoals bash dat doet (aanhalingstekens, escapes en bekende variabelen).
|
||||
/// Stopt bij een pipe, puntkomma, sluithaakje of omleiding buiten aanhalingstekens.
|
||||
/// </summary>
|
||||
private static List<string> Tokenize(string line, IReadOnlyDictionary<string, string> variables)
|
||||
{
|
||||
var tokens = new List<string>();
|
||||
var current = new StringBuilder();
|
||||
bool inToken = false;
|
||||
char quote = '\0';
|
||||
|
||||
for (int i = 0; i < line.Length; i++)
|
||||
{
|
||||
char c = line[i];
|
||||
if (quote == '\'')
|
||||
{
|
||||
if (c == '\'')
|
||||
{
|
||||
quote = '\0';
|
||||
}
|
||||
else
|
||||
{
|
||||
current.Append(c);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c == '\\' && i + 1 < line.Length)
|
||||
{
|
||||
char escaped = line[++i];
|
||||
if (quote == '"' && escaped is not ('"' or '\\' or '$' or '`'))
|
||||
{
|
||||
current.Append('\\');
|
||||
}
|
||||
|
||||
current.Append(escaped);
|
||||
inToken = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c == '$' && i + 1 < line.Length)
|
||||
{
|
||||
Match variable = Regex.Match(line[(i + 1)..], @"^(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))");
|
||||
if (variable.Success)
|
||||
{
|
||||
string name = variable.Groups[1].Success ? variable.Groups[1].Value : variable.Groups[2].Value;
|
||||
current.Append(variables.TryGetValue(name, out string? value) ? value : "$" + variable.Value);
|
||||
i += variable.Length;
|
||||
inToken = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (quote == '"')
|
||||
{
|
||||
if (c == '"')
|
||||
{
|
||||
quote = '\0';
|
||||
}
|
||||
else
|
||||
{
|
||||
current.Append(c);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c is '\'' or '"')
|
||||
{
|
||||
quote = c;
|
||||
inToken = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c is '|' or ';' or ')' or '>' or '<' or '&' or '`' or '#' && !(c == '#' && inToken))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (char.IsWhiteSpace(c))
|
||||
{
|
||||
if (inToken)
|
||||
{
|
||||
tokens.Add(current.ToString());
|
||||
current.Clear();
|
||||
inToken = false;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
current.Append(c);
|
||||
inToken = true;
|
||||
}
|
||||
|
||||
if (inToken)
|
||||
{
|
||||
tokens.Add(current.ToString());
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
private static UploaderConfig Validate(UploaderConfig config)
|
||||
{
|
||||
if (!config.IsConfigured)
|
||||
{
|
||||
throw new InvalidDataException(config.RequestUrl.Length == 0
|
||||
? "In dit bestand staat geen server-adres."
|
||||
: $"Het server-adres is niet bruikbaar:\n{config.RequestUrl}");
|
||||
}
|
||||
|
||||
if (!config.Headers.Keys.Any(k => k.Equals("authorization", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
throw new InvalidDataException("In dit bestand staat geen token (authorization-header).");
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Kniip.Upload;
|
||||
|
||||
public sealed class UploadException : Exception
|
||||
{
|
||||
public UploadException(string message, Exception? inner = null) : base(message, inner)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Uploadt een bestand naar de ingestelde Zipline-server en geeft de link terug.</summary>
|
||||
public static class ZiplineUploader
|
||||
{
|
||||
private static readonly HttpClient Client = new() { Timeout = TimeSpan.FromMinutes(10) };
|
||||
|
||||
static ZiplineUploader()
|
||||
{
|
||||
Client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("kniip", "1.0"));
|
||||
}
|
||||
|
||||
public static async Task<string> UploadAsync(UploaderConfig config, Stream data, string fileName, string contentType,
|
||||
IProgress<double>? progress = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!config.IsConfigured)
|
||||
{
|
||||
throw new UploadException("Er is nog geen uploadbestemming ingesteld (Instellingen > Uploaden).");
|
||||
}
|
||||
|
||||
var file = new ProgressContent(data, progress);
|
||||
file.Headers.ContentType = new MediaTypeHeaderValue(contentType);
|
||||
using var form = new MultipartFormDataContent { { file, config.FileFormName, fileName } };
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, config.RequestUrl) { Content = form };
|
||||
foreach ((string name, string value) in config.Headers)
|
||||
{
|
||||
if (!request.Headers.TryAddWithoutValidation(name, value))
|
||||
{
|
||||
form.Headers.TryAddWithoutValidation(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
response = await Client.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw new UploadException("De server reageert niet (time-out).", ex);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
throw new UploadException($"Kan {config.DisplayName} niet bereiken.\n\n{ex.Message}", ex);
|
||||
}
|
||||
|
||||
using (response)
|
||||
{
|
||||
string body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
string detail = ReadError(body) ?? response.ReasonPhrase ?? string.Empty;
|
||||
string hint = response.StatusCode == HttpStatusCode.Unauthorized ? "\n\nDe token is ongeldig of verlopen; importeer de uploader opnieuw." : string.Empty;
|
||||
throw new UploadException($"De server weigerde de upload ({(int)response.StatusCode}): {detail}{hint}");
|
||||
}
|
||||
|
||||
return ReadUrl(body, config.UrlPath)
|
||||
?? throw new UploadException($"De upload is gelukt, maar er staat geen link in het antwoord op '{config.UrlPath}'.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ReadError(string body)
|
||||
{
|
||||
try
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(body);
|
||||
if (document.RootElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (string name in new[] { "error", "message" })
|
||||
{
|
||||
if (document.RootElement.TryGetProperty(name, out JsonElement value) && value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return value.GetString();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
}
|
||||
|
||||
return body.Length is > 0 and < 200 ? body.Trim() : null;
|
||||
}
|
||||
|
||||
/// <summary>Leest een pad als <c>files[0].url</c> uit een JSON-antwoord.</summary>
|
||||
internal static string? ReadUrl(string body, string path)
|
||||
{
|
||||
JsonDocument document;
|
||||
try
|
||||
{
|
||||
document = JsonDocument.Parse(body);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using (document)
|
||||
{
|
||||
JsonElement current = document.RootElement;
|
||||
foreach (Match part in Regex.Matches(path, @"([^.\[\]]+)|\[(\d+)\]"))
|
||||
{
|
||||
if (part.Groups[2].Success)
|
||||
{
|
||||
int index = int.Parse(part.Groups[2].Value);
|
||||
if (current.ValueKind != JsonValueKind.Array || index >= current.GetArrayLength())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
current = current[index];
|
||||
}
|
||||
else if (current.ValueKind != JsonValueKind.Object || !current.TryGetProperty(part.Groups[1].Value, out current))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return current.ValueKind == JsonValueKind.String ? current.GetString() : null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Stuurt een stream in blokken mee en meldt hoeveel er al verzonden is.</summary>
|
||||
private sealed class ProgressContent : HttpContent
|
||||
{
|
||||
private readonly Stream _source;
|
||||
private readonly IProgress<double>? _progress;
|
||||
|
||||
public ProgressContent(Stream source, IProgress<double>? progress)
|
||||
{
|
||||
_source = source;
|
||||
_progress = progress;
|
||||
}
|
||||
|
||||
protected override async Task SerializeToStreamAsync(Stream stream, TransportContext? context) =>
|
||||
await SerializeToStreamAsync(stream, context, CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
protected override async Task SerializeToStreamAsync(Stream stream, TransportContext? context, CancellationToken cancellationToken)
|
||||
{
|
||||
var buffer = new byte[81920];
|
||||
long total = _source.CanSeek ? _source.Length : -1;
|
||||
long sent = 0;
|
||||
int read;
|
||||
while ((read = await _source.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) > 0)
|
||||
{
|
||||
await stream.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false);
|
||||
sent += read;
|
||||
if (total > 0)
|
||||
{
|
||||
_progress?.Report((double)sent / total);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected override bool TryComputeLength(out long length)
|
||||
{
|
||||
length = _source.CanSeek ? _source.Length - _source.Position : -1;
|
||||
return _source.CanSeek;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="1.0.0.0" name="Kniip.app" />
|
||||
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<!-- Windows 10 / 11 -->
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||
</application>
|
||||
</compatibility>
|
||||
|
||||
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<windowsSettings>
|
||||
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
|
||||
</windowsSettings>
|
||||
</application>
|
||||
|
||||
<dependency>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0"
|
||||
processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*" />
|
||||
</dependentAssembly>
|
||||
</dependency>
|
||||
</assembly>
|
||||
@@ -0,0 +1,104 @@
|
||||
# Haalt de Twemoji-set (emoji-datasource-twitter) en de CLDR-trefwoorden op en schrijft
|
||||
# een compacte catalogus naar src/Kniip/Assets/Emoji. Alleen opnieuw draaien bij een update.
|
||||
#
|
||||
# Twemoji-afbeeldingen: (c) Twitter, Inc en andere bijdragers, CC-BY 4.0.
|
||||
# Emoji-data: emoji-datasource (MIT). Trefwoorden: Unicode CLDR (Unicode License).
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
|
||||
$datasourceVersion = '15.1.2'
|
||||
$cldrVersion = '46.0.0'
|
||||
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$outDir = Join-Path $root 'src\Kniip\Assets\Emoji'
|
||||
New-Item -ItemType Directory -Force -Path $outDir | Out-Null
|
||||
|
||||
$datasource = "https://unpkg.com/emoji-datasource-twitter@$datasourceVersion"
|
||||
$cldr = "https://raw.githubusercontent.com/unicode-org/cldr-json/$cldrVersion/cldr-json"
|
||||
|
||||
function Get-Json([string]$url) {
|
||||
$bytes = (Invoke-WebRequest -Uri $url -UseBasicParsing).RawContentStream.ToArray()
|
||||
[Text.Encoding]::UTF8.GetString($bytes) | ConvertFrom-Json
|
||||
}
|
||||
|
||||
function Get-Annotations([string]$lang) {
|
||||
$map = @{}
|
||||
foreach ($kind in 'cldr-annotations-full/annotations', 'cldr-annotations-derived-full/annotationsDerived') {
|
||||
$json = Get-Json "$cldr/$kind/$lang/annotations.json"
|
||||
$set = if ($json.annotations) { $json.annotations.annotations } else { $json.annotationsDerived.annotations }
|
||||
foreach ($p in $set.PSObject.Properties) {
|
||||
$key = $p.Name.Replace([string][char]0xFE0F, '')
|
||||
$map[$key] = $p.Value
|
||||
}
|
||||
}
|
||||
$map
|
||||
}
|
||||
|
||||
Write-Host 'Spritesheet downloaden...'
|
||||
Invoke-WebRequest -Uri "$datasource/img/twitter/sheets/64.png" -OutFile (Join-Path $outDir 'twemoji-64.png') -UseBasicParsing
|
||||
|
||||
Write-Host 'emoji.json downloaden...'
|
||||
$emoji = Get-Json "$datasource/emoji.json"
|
||||
|
||||
Write-Host 'CLDR-trefwoorden downloaden...'
|
||||
$nl = Get-Annotations 'nl'
|
||||
$en = Get-Annotations 'en'
|
||||
|
||||
function Get-Char([string]$unified) {
|
||||
($unified -split '-' | ForEach-Object { [char]::ConvertFromUtf32([Convert]::ToInt32($_, 16)) }) -join ''
|
||||
}
|
||||
|
||||
$tones = @{ '1F3FB' = 1; '1F3FC' = 2; '1F3FD' = 3; '1F3FE' = 4; '1F3FF' = 5 }
|
||||
|
||||
$items = foreach ($e in $emoji) {
|
||||
if (-not $e.has_img_twitter -or $e.obsoleted_by -or $e.category -eq 'Component') { continue }
|
||||
|
||||
$key = (Get-Char $e.unified).Replace([string][char]0xFE0F, '')
|
||||
$nlEntry = $nl[$key]
|
||||
$enEntry = $en[$key]
|
||||
|
||||
$words = New-Object System.Collections.Generic.List[string]
|
||||
$words.AddRange([string[]]$e.short_names)
|
||||
if ($e.name) { $words.Add($e.name) }
|
||||
foreach ($entry in $nlEntry, $enEntry) {
|
||||
if ($entry) {
|
||||
if ($entry.tts) { $words.AddRange([string[]]$entry.tts) }
|
||||
if ($entry.default) { $words.AddRange([string[]]$entry.default) }
|
||||
}
|
||||
}
|
||||
|
||||
$skins = $null
|
||||
if ($e.skin_variations) {
|
||||
$skins = [ordered]@{}
|
||||
foreach ($p in $e.skin_variations.PSObject.Properties) {
|
||||
$parts = $p.Name -split '-'
|
||||
if (($parts | Select-Object -Unique).Count -ne 1 -or -not $tones.ContainsKey($parts[0])) { continue }
|
||||
if (-not $p.Value.has_img_twitter) { continue }
|
||||
$skins[[string]$tones[$parts[0]]] = @($p.Value.unified.ToLowerInvariant(), $p.Value.sheet_x, $p.Value.sheet_y)
|
||||
}
|
||||
if ($skins.Count -eq 0) { $skins = $null }
|
||||
}
|
||||
|
||||
$nameNl = if ($nlEntry -and $nlEntry.tts) { $nlEntry.tts[0] } else { $null }
|
||||
$nameEn = if ($enEntry -and $enEntry.tts) { $enEntry.tts[0] } elseif ($e.name) { $e.name.ToLowerInvariant() } else { $e.short_name }
|
||||
|
||||
$item = [ordered]@{
|
||||
u = $e.unified.ToLowerInvariant()
|
||||
c = $e.category
|
||||
o = $e.sort_order
|
||||
x = $e.sheet_x
|
||||
y = $e.sheet_y
|
||||
s = $e.short_name
|
||||
n = if ($nameNl) { $nameNl } else { $nameEn }
|
||||
k = (($words | ForEach-Object { $_.ToLowerInvariant().Replace('_', ' ') } | Select-Object -Unique) -join '|')
|
||||
}
|
||||
if ($skins) { $item['t'] = $skins }
|
||||
[pscustomobject]$item
|
||||
}
|
||||
|
||||
$items = $items | Sort-Object o
|
||||
$json = $items | ConvertTo-Json -Depth 5 -Compress
|
||||
[IO.File]::WriteAllText((Join-Path $outDir 'emoji.json'), $json, (New-Object Text.UTF8Encoding $false))
|
||||
|
||||
Write-Host "Klaar: $(@($items).Count) emoji naar $outDir"
|
||||
@@ -0,0 +1,108 @@
|
||||
# Genereert src/Kniip/AppIcon.ico (16-256 px) met System.Drawing.
|
||||
# Kleine formaten als 32-bit DIB, 256 px als PNG (standaard ICO-layout).
|
||||
param(
|
||||
[string]$Output = (Join-Path $PSScriptRoot '..\src\Kniip\AppIcon.ico')
|
||||
)
|
||||
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
|
||||
function New-IconBitmap([int]$size) {
|
||||
$bmp = New-Object System.Drawing.Bitmap($size, $size, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
|
||||
$g = [System.Drawing.Graphics]::FromImage($bmp)
|
||||
$g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
|
||||
$g.TextRenderingHint = [System.Drawing.Text.TextRenderingHint]::AntiAliasGridFit
|
||||
$g.Clear([System.Drawing.Color]::Transparent)
|
||||
|
||||
$pad = [Math]::Max(1, [int]($size * 0.04))
|
||||
$rect = New-Object System.Drawing.RectangleF($pad, $pad, ($size - 2 * $pad), ($size - 2 * $pad))
|
||||
$radius = $size * 0.22
|
||||
$path = New-Object System.Drawing.Drawing2D.GraphicsPath
|
||||
$d = $radius * 2
|
||||
$path.AddArc($rect.X, $rect.Y, $d, $d, 180, 90)
|
||||
$path.AddArc($rect.Right - $d, $rect.Y, $d, $d, 270, 90)
|
||||
$path.AddArc($rect.Right - $d, $rect.Bottom - $d, $d, $d, 0, 90)
|
||||
$path.AddArc($rect.X, $rect.Bottom - $d, $d, $d, 90, 90)
|
||||
$path.CloseFigure()
|
||||
|
||||
$brush = New-Object System.Drawing.Drawing2D.LinearGradientBrush($rect,
|
||||
[System.Drawing.Color]::FromArgb(255, 0, 120, 212),
|
||||
[System.Drawing.Color]::FromArgb(255, 0, 84, 166), 90.0)
|
||||
$g.FillPath($brush, $path)
|
||||
|
||||
# Gestippeld selectiekader
|
||||
$inset = $size * 0.2
|
||||
$penW = [Math]::Max(1.0, $size / 16.0)
|
||||
$pen = New-Object System.Drawing.Pen([System.Drawing.Color]::White, $penW)
|
||||
if ($size -ge 32) { $pen.DashStyle = [System.Drawing.Drawing2D.DashStyle]::Dash }
|
||||
$g.DrawRectangle($pen, $inset, $inset, $size - 2 * $inset, $size * 0.5)
|
||||
|
||||
# Geel markeerstreepje
|
||||
$hl = New-Object System.Drawing.SolidBrush([System.Drawing.Color]::FromArgb(255, 255, 214, 0))
|
||||
$g.FillRectangle($hl, $inset, $size * 0.74, $size * 0.36, [Math]::Max(1.0, $size * 0.09))
|
||||
|
||||
# Plus-badge
|
||||
$badge = $size * 0.36
|
||||
$bx = $size - $badge - $pad * 0.5
|
||||
$by = $size - $badge - $pad * 0.5
|
||||
$g.FillEllipse((New-Object System.Drawing.SolidBrush([System.Drawing.Color]::FromArgb(255, 232, 17, 35))), $bx, $by, $badge, $badge)
|
||||
$pw = [Math]::Max(1.0, $badge * 0.16)
|
||||
$plusPen = New-Object System.Drawing.Pen([System.Drawing.Color]::White, $pw)
|
||||
$c = $badge / 2
|
||||
$l = $badge * 0.27
|
||||
$g.DrawLine($plusPen, $bx + $c - $l, $by + $c, $bx + $c + $l, $by + $c)
|
||||
$g.DrawLine($plusPen, $bx + $c, $by + $c - $l, $bx + $c, $by + $c + $l)
|
||||
|
||||
$g.Dispose()
|
||||
return $bmp
|
||||
}
|
||||
|
||||
function Get-DibBytes([System.Drawing.Bitmap]$bmp) {
|
||||
$w = $bmp.Width; $h = $bmp.Height
|
||||
$ms = New-Object System.IO.MemoryStream
|
||||
$bw = New-Object System.IO.BinaryWriter($ms)
|
||||
$bw.Write([int]40); $bw.Write([int]$w); $bw.Write([int]($h * 2))
|
||||
$bw.Write([int16]1); $bw.Write([int16]32); $bw.Write([int]0)
|
||||
$bw.Write([int]0); $bw.Write([int]0); $bw.Write([int]0); $bw.Write([int]0); $bw.Write([int]0)
|
||||
for ($y = $h - 1; $y -ge 0; $y--) {
|
||||
for ($x = 0; $x -lt $w; $x++) {
|
||||
$px = $bmp.GetPixel($x, $y)
|
||||
$bw.Write([byte]$px.B); $bw.Write([byte]$px.G); $bw.Write([byte]$px.R); $bw.Write([byte]$px.A)
|
||||
}
|
||||
}
|
||||
$maskStride = [int]([Math]::Ceiling($w / 32.0) * 4)
|
||||
$bw.Write([byte[]]::new($maskStride * $h))
|
||||
$bw.Flush()
|
||||
return , $ms.ToArray()
|
||||
}
|
||||
|
||||
$sizes = 16, 20, 24, 32, 40, 48, 64, 256
|
||||
$images = @()
|
||||
foreach ($s in $sizes) {
|
||||
$bmp = New-IconBitmap $s
|
||||
if ($s -eq 256) {
|
||||
$ms = New-Object System.IO.MemoryStream
|
||||
$bmp.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
|
||||
[byte[]]$bytes = $ms.ToArray()
|
||||
} else {
|
||||
[byte[]]$bytes = Get-DibBytes $bmp
|
||||
}
|
||||
$images += , $bytes
|
||||
$bmp.Dispose()
|
||||
}
|
||||
|
||||
$out = New-Object System.IO.MemoryStream
|
||||
$w = New-Object System.IO.BinaryWriter($out)
|
||||
$w.Write([int16]0); $w.Write([int16]1); $w.Write([int16]$sizes.Count)
|
||||
$offset = 6 + 16 * $sizes.Count
|
||||
for ($i = 0; $i -lt $sizes.Count; $i++) {
|
||||
$s = $sizes[$i]
|
||||
$dim = if ($s -ge 256) { 0 } else { $s }
|
||||
$w.Write([byte]$dim); $w.Write([byte]$dim); $w.Write([byte]0); $w.Write([byte]0)
|
||||
$w.Write([int16]1); $w.Write([int16]32)
|
||||
$w.Write([int]$images[$i].Length); $w.Write([int]$offset)
|
||||
$offset += $images[$i].Length
|
||||
}
|
||||
foreach ($img in $images) { $w.Write([byte[]]$img) }
|
||||
$w.Flush()
|
||||
[System.IO.File]::WriteAllBytes([System.IO.Path]::GetFullPath($Output), $out.ToArray())
|
||||
Write-Host "Icoon geschreven naar $Output"
|
||||
Reference in new issue
Block a user