diff --git a/.cursor/plans/schermafdruk_vertraging_0a5ac2a4.plan.md b/.cursor/plans/schermafdruk_vertraging_0a5ac2a4.plan.md new file mode 100644 index 0000000..0a038f2 --- /dev/null +++ b/.cursor/plans/schermafdruk_vertraging_0a5ac2a4.plan.md @@ -0,0 +1,72 @@ +--- +name: Schermafdruk vertraging +overview: "Tray-submenu \"Schermafdruk vertraging: Xs\" (0-60 s, slider plus getalveld) dat rechthoek- en vensterknipsels uitstelt, met een klein aftelvenster bij de muis dat met Esc te annuleren is." +todos: + - id: setting + content: CaptureDelaySeconds toevoegen aan AppSettings (0-60, standaard 0) + status: completed + - id: tray + content: Tray-submenu met TrackBar + NumericUpDown, SetCaptureDelay en CaptureDelayChanged in TrayIcon + status: completed + - id: countdown + content: CountdownWindow (niet-activerend, volgt muis, Elapsed/Cancelled) maken + status: completed + - id: app + content: BeginDelayedSnip in App.xaml.cs, Esc-binding tijdelijk, StartNewSnip onvertraagd, instelling opslaan + status: completed + - id: changelog + content: CHANGELOG.md bijwerken + status: completed + - id: release + content: CHANGELOG 1.1.5-item, build.ps1 draaien, commit/tag 1.1.5 en pushen + status: in_progress +isProject: false +--- + +# Schermafdruk vertraging (0-60 s) + +## Gedrag +- In het tray-menu komt direct onder "Venster knippen" een item `Schermafdruk vertraging: 0s` met een submenu. +- In het submenu staan een slider (0-60) en een getalveld (`NumericUpDown`, 0-60) die met elkaar meelopen. De tekst van het hoofditem werkt meteen bij, bijvoorbeeld `Schermafdruk vertraging: 5s`. +- 0 betekent uit: dan werkt alles zoals nu. +- De vertraging geldt voor "Rechthoek knippen" en "Venster knippen", via de tray en via de sneltoetsen. Screencasts en "Nieuw knipsel" na het sluiten van de editor (`StartNewSnip`) gaan zonder vertraging. +- Tijdens het aftellen staat een klein, altijd-bovenop, niet-focusstelend venstertje bij de muis met de resterende seconden. Het scherm wordt pas vastgelegd nadat dat venster weg is, zodat het niet in de schermafdruk komt. Met Esc (of een klik op het venster) annuleer je. + +```mermaid +flowchart LR + trigger["Tray of sneltoets"] --> check{"Delay groter dan 0?"} + check -->|nee| snip["BeginSnip: scherm bevriezen"] + check -->|ja| countdown["CountdownWindow bij muis"] + countdown -->|"Esc of klik"| cancel[Geannuleerd] + countdown -->|"tijd om"| closeWin["Venster sluiten, korte pauze"] --> snip +``` + +## Wijzigingen + +**[src/Kniip/Settings/AppSettings.cs](src/Kniip/Settings/AppSettings.cs)** +- Nieuwe instelling `public int CaptureDelaySeconds { get; set; }` (standaard 0), met een doc-comment in dezelfde stijl. Bij het lezen begrenzen tot 0-60. + +**[src/Kniip/Infrastructure/TrayIcon.cs](src/Kniip/Infrastructure/TrayIcon.cs)** +- `_delayItem` (een `ToolStripMenuItem`) met in de `DropDownItems` een `ToolStripControlHost` om een klein `Panel`: daarin een `TrackBar` (0-60, `TickFrequency` 5) en een `NumericUpDown` met het label "seconden". +- `public void SetCaptureDelay(int seconds)` zet de besturingselementen en de menutekst. +- `public event Action? CaptureDelayChanged`: wordt afgevuurd als de waarde wijzigt. Opslaan gebeurt pas bij `DropDownClosed`, zodat niet elke stap van de slider een schrijfactie wordt. +- Toetsen in het getalveld mogen het menu niet sluiten. Als dat in de praktijk toch gebeurt, krijgt het hostpaneel focus en geven we Enter/Esc expliciet door. + +**[src/Kniip/App.xaml.cs](src/Kniip/App.xaml.cs)** +- Bij het opstarten: `_tray.SetCaptureDelay(Settings.CaptureDelaySeconds)` en `CaptureDelayChanged` koppelen. De koppeling zet `Settings.CaptureDelaySeconds` en roept `SettingsStore.Save` aan. +- `BeginRegionSnip`/`BeginWindowSnip` gaan via een nieuwe `BeginDelayedSnip(bool pickWindow)`: + - Als de vertraging 0 is, of er al een knipsel loopt, of er al wordt afgeteld, gaat het direct naar `BeginSnip`. Een tweede sneltoets tijdens het aftellen wordt genegeerd. + - Anders wordt een `CountdownWindow` gestart. Na afloop sluit dat venster en volgt een `DispatcherTimer` van circa 150 ms (zelfde idee als `StartNewSnip`), daarna `BeginSnip(pickWindow)`. + - Esc tijdens het aftellen: tijdelijk een `Escape`-binding toevoegen aan `HotkeyHook.Bindings` die het aftellen annuleert, en daarna via `ApplyHotkeys()` herstellen. `HotkeyHook.Capture` gebruiken we niet, want dat slokt alle toetsen op, en de gebruiker moet tijdens het aftellen juist zijn scherm kunnen klaarzetten. +- `StartNewSnip` blijft `BeginRegionSnip` direct aanroepen, maar dan via de onvertraagde route (`BeginSnip(false)`). + +**Nieuw: `src/Kniip/Capture/CountdownWindow.xaml(.cs)`** +- Een klein, rond-hoekig WPF-venster zonder rand: `Topmost`, `ShowActivated=False`, `ShowInTaskbar=False`, met `WS_EX_NOACTIVATE`/`WS_EX_TOOLWINDOW` zodat de focus bij het actieve programma blijft. De opmaak is vergelijkbaar met [`SnipToast.xaml`](src/Kniip/Notifications/SnipToast.xaml). +- Toont een groot getal en de tekst "Esc om te annuleren". Er loopt een `DispatcherTimer` van 1 s. Het venster schuift mee met de muis (positie bijwerken op dezelfde tick of op een snellere tick van circa 50 ms). +- Events `Elapsed` en `Cancelled`. Een muisklik op het venster telt als annuleren. + +## Release 1.1.5 +De versienummers in [build.ps1](build.ps1), [installer/Kniip.iss](installer/Kniip.iss), [src/Kniip/Kniip.csproj](src/Kniip/Kniip.csproj) en [README.md](README.md) staan al op 1.1.5. Tag `1.1.5` bestaat nog niet. Deze functie gaat dus mee in dezelfde release als de ShareX-indeling, het vergrootglas en de geschiedenis. +- [CHANGELOG.md](CHANGELOG.md): in de bestaande sectie `## 1.1.5 - 2026-11-09` onder **Nieuw** een item toevoegen in dezelfde stijl, bijvoorbeeld: **Schermafdruk vertraging.** In het systeemvakmenu staat **Schermafdruk vertraging: 0s**. Stel daar met de schuifbalk of het getalveld 0 tot 60 seconden in. Daarna wachten **Rechthoek knippen** en **Venster knippen** (ook via de sneltoets) zo lang voordat het scherm bevriest. Bij de muis telt een venstertje af, en met Esc annuleer je. 0 betekent geen vertraging; screencasts wachten niet. +- `.\build.ps1` draaien en controleren dat de build en de installer slagen. +- Commit "1.1.5" met alle openstaande 1.1.5-wijzigingen, waaronder de nieuwe History- en Magnifier-bestanden. Daarna tag `1.1.5` en pushen, net als bij 1.1.4. De release op git.hibbit.net maak je zelf aan; ik lever de changelogtekst aan. diff --git a/.cursor/plans/sharex-indeling_editor_47da227c.plan.md b/.cursor/plans/sharex-indeling_editor_47da227c.plan.md new file mode 100644 index 0000000..585662d --- /dev/null +++ b/.cursor/plans/sharex-indeling_editor_47da227c.plan.md @@ -0,0 +1,74 @@ +--- +name: ShareX-indeling editor +overview: "Een tweede editor-indeling toevoegen in de stijl van ShareX: bovenaan de bewerkgereedschappen met daaronder de eigenschappenbalk, onderaan een aparte balk met zoom en de opslag-, upload- en kopieerknoppen. De indeling is te kiezen in Instellingen (tab Editor) en via het rechtsklikmenu van de werkbalk. Uitbrengen als versie 1.1.5." +todos: + - id: settings + content: EditorLayout-enum, EditorLayout- en SplitToolbarOrder-instelling toevoegen aan AppSettings + status: completed + - id: xaml + content: ActionBar met ActionItems (ToolbarPanel) toevoegen aan EditorWindow.xaml + status: completed + - id: code + content: "Werkbalkcode ombouwen: items verdelen over twee panelen, labels/vergrendelen/verbergen/volgorde/omwisselen per paneel, menu Indeling, bijsnijden, OnActivated" + status: completed + - id: settingsui + content: Radioknoppen voor de indeling in de Editor-tab van Instellingen plus herstellen naar standaard + status: completed + - id: version + content: Versie 1.1.5 in csproj, build.ps1 en Kniip.iss en een CHANGELOG-regel + status: completed + - id: verify + content: Bouwen en beide indelingen handmatig testen + status: completed +isProject: false +--- + +# ShareX-achtige editor-indeling (versie 1.1.5) + +## Indeling + +- **Eén werkbalk** (standaard, zoals nu): alles in `ToolbarItems`, positie Boven/Onder/Links/Rechts blijft werken. +- **Gesplitst (zoals ShareX)**: + - Boven, gecentreerd: Selecteren, Rechthoek ... Vervagen, Bijsnijden, met daarnaast Wissen, Ongedaan maken, Opnieuw en Verwijderen. Direct daaronder de bestaande eigenschappenbalk `PropsBar` (die al gecentreerd is). + - Onder, boven de statusbalk: links Nieuw en Screencast, in het midden Uitzoomen / 100% / Inzoomen, rechts Instellingen, Opslaan, Uploaden en Kopiëren (met tekstlabels, want daar is ruimte). + +```mermaid +flowchart TB + topBar["Boven: gereedschappen + ongedaan maken"] + props["Eigenschappenbalk: kleur, dikte, lijnstijl"] + canvas["Canvas"] + bottomBar["Onder: Nieuw, Screencast | Zoom | Instellingen, Opslaan, Uploaden, Kopiëren"] + status["Statusbalk"] + topBar --> props --> canvas --> bottomBar --> status +``` + +## Wijzigingen + +**[src/Kniip/Settings/AppSettings.cs](src/Kniip/Settings/AppSettings.cs)** +- Nieuwe enum `EditorLayout { Single, Split }` en `public EditorLayout EditorLayout { get; set; } = EditorLayout.Single;` +- Aparte `List SplitToolbarOrder`, zodat versleepte volgordes van beide indelingen elkaar niet overschrijven. `ToolbarHidden` blijft gedeeld. + +**[src/Kniip/Editor/EditorWindow.xaml](src/Kniip/Editor/EditorWindow.xaml)** +- Een tweede `Border x:Name="ActionBar" DockPanel.Dock="Bottom"` (na de statusbalk gedeclareerd, dus erboven) met `local:ToolbarPanel x:Name="ActionItems"`, dezelfde `ContextMenuOpening`- en `OrderChanged`-handlers en standaard `Collapsed`. +- De knoppen zelf blijven op één plek in de XAML staan; de code verdeelt ze over de panelen. + +**[src/Kniip/Editor/EditorWindow.xaml.cs](src/Kniip/Editor/EditorWindow.xaml.cs)** (rond regels 141-362 voor de werkbalk) +- In `InitializeToolbar` alle items één keer in een dictionary `_toolbarItems` (id naar element) vastleggen, plus een vaste set id's die in de gesplitste indeling onderaan komen: `New, Screencast, Spacer, ZoomOut, Zoom, ZoomIn, Spacer2, Settings, Save, Upload, Copy` (plus de bijbehorende scheidingslijnen). +- `ApplyToolbarOrder` ombouwen tot `ApplyToolbarLayout()`: beide panelen leegmaken en de items volgens de opgeslagen volgorde (`ToolbarOrder` of `SplitToolbarOrder`) verdelen over `ToolbarItems` en `ActionItems`. De logica voor nieuwe items die na hun standaardvoorganger komen, blijft hetzelfde. In de gesplitste indeling krijgt het bovenste paneel `HorizontalAlignment=Center`, `Toolbar` vast `Dock.Top` en wordt `ActionBar` getoond. +- Alle plekken die nu `ToolbarItems.Items` gebruiken, laten werken op beide panelen: `SelectTool` (regel 368), "Knoppen tonen", `ResetToolbar`, de vergrendeling (`IsLocked` op beide panelen, achtergrondkleur op beide balken) en `OrderChanged` (de volgorde van boven + onder samen opslaan in `SplitToolbarOrder`). +- `UpdateToolbarLayout` en `SetActionLabelsVisible` per paneel laten rekenen, zodat de labels op de onderbalk zichtbaar blijven, ook als de bovenbalk vol is. De lus op regel 236 stopt bij het `ToolbarPanel` waarin het label staat, in plaats van alleen bij `ToolbarItems`. +- `SwapToolbarGroups` werkt op het paneel waarop is rechtsgeklikt. +- Rechtsklikmenu: een nieuw submenu "Indeling" met "Eén werkbalk" en "Gesplitst (zoals ShareX)". "Positie" alleen tonen bij Eén werkbalk. +- Op regel 935, waar `Toolbar` tijdens bijsnijden wordt verborgen, ook `ActionBar` verbergen (alleen als de gesplitste indeling aanstaat). +- In `OnActivated` (regel 1508) `ApplyToolbarLayout()` aanroepen als de indeling is veranderd, zodat een wijziging in Instellingen meteen zichtbaar is in een open editor (zoals dat nu ook gebeurt met `ApplySaveButton`). + +**[src/Kniip/Settings/SettingsWindow.xaml](src/Kniip/Settings/SettingsWindow.xaml) en [.xaml.cs](src/Kniip/Settings/SettingsWindow.xaml.cs)** +- In de tab Editor (regel 252) een kopje "Werkbalk" met twee radioknoppen `LayoutSingleRadio` / `LayoutSplitRadio` en een korte uitleg, op dezelfde manier geladen en opgeslagen als `SaveOnlyRadio` / `SaveAndCopyRadio`. +- Bij het herstellen van de standaardinstellingen (regel 466) ook `EditorLayout` en `SplitToolbarOrder` meenemen. + +**Versie 1.1.5** +- `1.1.5` in [src/Kniip/Kniip.csproj](src/Kniip/Kniip.csproj), de standaardwaarde in [build.ps1](build.ps1) (regel 16) en `#define AppVersion` in [installer/Kniip.iss](installer/Kniip.iss). +- In [CHANGELOG.md](CHANGELOG.md) bovenaan `## 1.1.5 - 2026-11-09`, onder `### Nieuw`: **Werkbalkindeling zoals ShareX.** + +## Controle +- Bouwen met `build.ps1`, daarna beide indelingen uitproberen: wisselen via Instellingen en via het rechtsklikmenu, knoppen verslepen en verbergen, bijsnijden, een smal venster (de balken moeten netjes doorlopen naar een nieuwe regel) en "Standaard herstellen". \ No newline at end of file diff --git a/.cursor/plans/vergrootglas_bij_selectie_eaef4448.plan.md b/.cursor/plans/vergrootglas_bij_selectie_eaef4448.plan.md new file mode 100644 index 0000000..8bc2673 --- /dev/null +++ b/.cursor/plans/vergrootglas_bij_selectie_eaef4448.plan.md @@ -0,0 +1,66 @@ +--- +name: Vergrootglas bij selectie +overview: Add a magnifier (circular zoom with crosshair and X/Y coordinates near the cursor) to the snip overlay and the screencast region selector, with a settings checkbox that is on by default. Release as 1.1.5. +todos: + - id: setting + content: ShowMagnifier (default true) toevoegen aan AppSettings + status: completed + - id: control + content: "MagnifierControl maken: ronde zoom, raster, kruisdraad, X/Y-badge, plaatsing bij cursor met randcorrectie" + status: completed + - id: snip + content: Vergrootglas in RegionCaptureWindow + doorgeven vanuit App.BeginSnip + status: completed + - id: screencast + content: Vergrootglas in RegionSelectorWindow met vooraf vastgelegd beeld vanuit RecordingController.BeginSelection + status: completed + - id: settings-ui + content: Checkbox in SettingsWindow (tab Sneltoetsen) laden en opslaan + status: completed + - id: release + content: CHANGELOG 1.1.5, README, versienummers, build.ps1 draaien, commit/tag 1.1.5 en pushen + status: completed +isProject: false +--- + +# Vergrootglas bij het kiezen van een gebied (1.1.5) + +## Starting point +- kniip has no magnifier yet. The snip overlay [src/Kniip/Capture/RegionCaptureWindow.xaml](src/Kniip/Capture/RegionCaptureWindow.xaml) shows a frozen screenshot (`Frozen`), a dim layer, the selection and a `W × H` badge. +- The screencast selector [src/Kniip/Recording/RegionSelectorWindow.xaml](src/Kniip/Recording/RegionSelectorWindow.xaml) is a transparent overlay over the live screen. It has no image to zoom into. + +## Approach + +```mermaid +flowchart LR + Settings["AppSettings.ShowMagnifier"] --> App["App.BeginSnip"] + Settings --> Rec["RecordingController.BeginSelection"] + App -->|"frozen image + flag"| Snip[RegionCaptureWindow] + Rec -->|"capture per screen + flag"| Sel[RegionSelectorWindow] + Snip --> Mag[MagnifierControl] + Sel --> Mag +``` + +1. **Setting**: in [src/Kniip/Settings/AppSettings.cs](src/Kniip/Settings/AppSettings.cs) add + `public bool ShowMagnifier { get; set; } = true;` with a Dutch doc comment. +2. **Shared control**: new `src/Kniip/Capture/MagnifierControl.xaml(.cs)` (UserControl, `IsHitTestVisible="False"`), styled like the screenshot: + - A round clipped area (about 130 DIP) showing an `Image` of the frozen bitmap with `NearestNeighbor` scaling. A `CroppedBitmap` of about 15x15 physical pixels around the cursor is enlarged around 8x. + - A pixel grid, a blue crosshair through the center pixel and a small square on the center pixel. The crosshair uses `AccentBrush`, the ring is white with a shadow. + - Below the circle, a badge in the same style as `SizeBadge` with `X: … Y: …` in physical screen coordinates. + - Method `Update(BitmapSource frozen, System.Drawing.Point physicalRelative, System.Drawing.Point screenPoint)`. Pixels outside the image are left black. + - Placement: bottom-right of the cursor at about 20 DIP offset. It flips left and/or up near the screen edge. +3. **Snip overlay** [src/Kniip/Capture/RegionCaptureWindow.xaml.cs](src/Kniip/Capture/RegionCaptureWindow.xaml.cs): + - Add a constructor parameter `bool showMagnifier` and add `` to the Grid. + - In `OnMouseMove`, update and show the magnifier while it is on (before and during dragging). Not in window-pick mode (`PicksWindow`), because exact pixels don't matter there. Hide it on `MouseLeave`. + - [src/Kniip/App.xaml.cs](src/Kniip/App.xaml.cs) `BeginSnip` passes `Settings.ShowMagnifier`. +4. **Screencast selector** [src/Kniip/Recording/RegionSelectorWindow.xaml.cs](src/Kniip/Recording/RegionSelectorWindow.xaml.cs): + - Add an optional `BitmapSource? frozen` constructor parameter. When it is set, the same magnifier runs in `OnMouseMove` (also while hovering over windows, because you can drag from there). + - [src/Kniip/Recording/RecordingController.cs](src/Kniip/Recording/RecordingController.cs) `BeginSelection`: if `_settings.ShowMagnifier` is on, call `ScreenGrabber.Capture(screen.Bounds)` per screen *before* the selectors are shown, so the overlay is not in the image. Otherwise pass `null`. +5. **Settings screen** [src/Kniip/Settings/SettingsWindow.xaml](src/Kniip/Settings/SettingsWindow.xaml) tab **Sneltoetsen**: below the hotkeys, add a card title "Gebied kiezen" with + ``. + In [SettingsWindow.xaml.cs](src/Kniip/Settings/SettingsWindow.xaml.cs), add a line to `LoadFrom` (around line 100) and to the save step (around line 444). +6. **Release 1.1.5**: + - [CHANGELOG.md](CHANGELOG.md) gets a new section `## 1.1.5 - 2026-11-09` with a **Nieuw** entry for the magnifier (round zoom with crosshair and X/Y, for both snips and screencasts) and how to turn it off. + - [README.md](README.md): the version table and build comment go to 1.1.5. + - Version 1.1.4 becomes 1.1.5 in [build.ps1](build.ps1), [installer/Kniip.iss](installer/Kniip.iss) and [src/Kniip/Kniip.csproj](src/Kniip/Kniip.csproj). + - Run `.\build.ps1`, then commit "1.1.5", tag `1.1.5` and push, the same way as 1.1.4. You create the release on git.hibbit.net yourself; I'll supply the changelog text. \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c3c6ad..ce3e9a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ Alle noemenswaardige wijzigingen in kniip staan in dit bestand. De nieuwste versie staat bovenaan. +## 1.1.5 - 2026-11-09 + +### Nieuw + +- **Werkbalkindeling zoals ShareX.** De editor kan nu ook twee werkbalken tonen: bovenaan de gereedschappen met daaronder de eigenschappen (kleur, dikte, lijnstijl), en onderaan een balk met **Nieuw** en **Screencast** links, de zoomknoppen in het midden en **Instellingen**, **Opslaan**, **Uploaden** en **Kopiëren** rechts. Kies de indeling in de instellingen onder **Editor** > **Werkbalk**, of klik met rechts op een werkbalk en kies **Indeling**. + - In beide werkbalken kun je knoppen verslepen (na ontgrendelen) en verbergen, net als bij één werkbalk. Beide indelingen onthouden hun eigen volgorde. + - De standaardindeling blijft één werkbalk. +- **Vergrootglas bij het kiezen van een gebied.** Bij het knippen van een rechthoek en bij het kiezen van een opnamegebied voor een screencast loopt naast de muisaanwijzer een rond vergrootglas mee. Je ziet de pixels rond de aanwijzer vergroot met een raster en een blauwe kruisdraad, en daaronder de schermcoördinaten (X en Y). Zo zet je een hoek precies op de goede pixel. Bij **Venster knippen** verschijnt het vergrootglas niet. + - Het vergrootglas staat standaard aan. Wil je het niet, zet dan in de instellingen onder **Sneltoetsen** > **Gebied kiezen** het vinkje **Vergrootglas tonen bij het kiezen van een gebied** uit. +- **Schermafdruk vertraging.** In het systeemvakmenu staat **Schermafdruk vertraging: 0s**. Stel daar met de schuifbalk of het getalveld 0 tot 60 seconden in. **Rechthoek knippen** en **Venster knippen** wachten dan zo lang voordat het scherm bevriest, ook via de sneltoets. Zo kun je eerst een menu openklappen of een tooltip laten verschijnen. + - Bij de muis telt een klein venster af. Dat venster komt zelf niet in het knipsel. Druk op Esc of klik op het venster om te annuleren. + - Bij 0 seconden knip je direct, zoals voorheen. Screencasts en de knop **Nieuw** in de editor wachten niet. +- **Geschiedenis.** kniip houdt nu bij wat je opslaat en uploadt: knipsels en screencasts, met het bestand en de link. Knipsels die je alleen kopieert of niet bewaart, komen er niet in. Het systeemvakmenu heeft vier nieuwe items: + - **Map openen**, met de map Schermafbeeldingen, je knipselmap en je screencastmap. + - **Geschiedenis...** opent een lijst met datum, soort, naam, map en link. Je kunt zoeken op naam of link. + - **Afbeeldingsgeschiedenis...** opent hetzelfde venster met miniaturen. Ook een knipsel dat je alleen hebt geüpload heeft een miniatuur. + - **Recente items** toont de laatste tien. Klik op een item om het te openen, of ga naar het submenu voor **Openen in de editor**, **Link kopiëren**, **Afbeelding kopiëren**, **Pad kopiëren** en **Tonen in map**. + + Dubbelklik in het geschiedenisvenster om een item te openen. Klik met rechts voor dezelfde acties en voor **Uit geschiedenis verwijderen**. Met **Geschiedenis wissen** maak je de lijst leeg. Je bestanden en uploads blijven dan gewoon bestaan. kniip bewaart de laatste 500 items. + ## 1.1.4 - 2026-11-09 ### Nieuw diff --git a/README.md b/README.md index ad9a012..089f68e 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ kniip is een annotatie-editor die naast het Windows Knipprogramma werkt. Maak ee | Versie | Installer | | ------ | --------- | -| 1.1.4 | [Kniip-Setup-1.1.4.exe](https://git.hibbit.net/rogier/kniip/releases/download/1.1.4/Kniip-Setup-1.1.4.exe) | +| 1.1.5 | [Kniip-Setup-1.1.5.exe](https://git.hibbit.net/rogier/kniip/releases/download/1.1.5/Kniip-Setup-1.1.5.exe) | Oudere versies vind je bij [alle releases](https://git.hibbit.net/rogier/kniip/releases). @@ -70,7 +70,7 @@ Vereisten: Bouwen: ```powershell -.\build.ps1 # versie 1.1.4 +.\build.ps1 # versie 1.1.5 .\build.ps1 -Version 1.2.0 # andere versie ``` diff --git a/build.ps1 b/build.ps1 index 265c690..bc6b992 100644 --- a/build.ps1 +++ b/build.ps1 @@ -12,7 +12,7 @@ # .\build.ps1 -SkipTest -> zonder testexe param( - [string]$Version = "1.1.4", + [string]$Version = "1.1.5", [string]$Runtime = "win-x64", [switch]$SkipTest ) diff --git a/installer/Kniip.iss b/installer/Kniip.iss index 634afce..452d0ff 100644 --- a/installer/Kniip.iss +++ b/installer/Kniip.iss @@ -5,7 +5,7 @@ #define AppName "kniip" #define AppExe "Kniip.exe" #ifndef AppVersion - #define AppVersion "1.1.4" + #define AppVersion "1.1.5" #endif #ifndef PublishDir #define PublishDir "..\artifacts\publish" diff --git a/src/Kniip/App.xaml.cs b/src/Kniip/App.xaml.cs index 82775de..a4cece5 100644 --- a/src/Kniip/App.xaml.cs +++ b/src/Kniip/App.xaml.cs @@ -7,6 +7,7 @@ using System.Windows.Media.Imaging; using System.Windows.Threading; using Kniip.Capture; using Kniip.Editor; +using Kniip.History; using Kniip.Infrastructure; using Kniip.Interop; using Kniip.Notifications; @@ -23,8 +24,10 @@ public partial class App : Application private SingleInstance? _singleInstance; private TrayIcon? _tray; private SettingsWindow? _settingsWindow; + private HistoryWindow? _historyWindow; private readonly List _snipWindows = new(); private List _snipSnapshot = new(); + private CountdownWindow? _snipCountdown; private uint? _ignoredClipboardSequence; public static App Instance => (App)Current; @@ -90,6 +93,12 @@ public partial class App : Application _tray.OpenFileRequested += OpenFileWithDialog; _tray.SnipRequested += BeginRegionSnip; _tray.WindowSnipRequested += BeginWindowSnip; + _tray.SetCaptureDelay(Settings.CaptureDelaySeconds); + _tray.CaptureDelayChanged += seconds => + { + Settings.CaptureDelaySeconds = seconds; + SettingsStore.Save(Settings); + }; _tray.RecordRequested += () => { if (Recorder.Stage == RecordingStage.Idle) @@ -103,6 +112,8 @@ public partial class App : Application }; _tray.GifRequested += () => Recorder.Toggle(ScreencastFormat.Gif); _tray.SettingsRequested += ShowSettings; + _tray.HistoryRequested += ShowHistory; + _tray.FoldersProvider = TrayFolders; _tray.ExitRequested += ExitApplication; _tray.PauseChanged += _ => UpdateCapturePaused(); @@ -191,20 +202,59 @@ public partial class App : Application timer.Tick += (_, _) => { timer.Stop(); - BeginRegionSnip(); + BeginSnip(pickWindow: false); }; timer.Start(); } - /// Bevriest alle schermen en laat een rechthoek slepen; het resultaat opent in de editor. - public void BeginRegionSnip() => BeginSnip(pickWindow: false); + /// Bevriest alle schermen (na de ingestelde vertraging) en laat een rechthoek slepen; het resultaat opent in de editor. + public void BeginRegionSnip() => BeginDelayedSnip(pickWindow: false); - /// Bevriest alle schermen en laat een venster aanklikken; het resultaat opent in de editor. - public void BeginWindowSnip() => BeginSnip(pickWindow: true); + /// Bevriest alle schermen (na de ingestelde vertraging) en laat een venster aanklikken; het resultaat opent in de editor. + public void BeginWindowSnip() => BeginDelayedSnip(pickWindow: true); + + private void BeginDelayedSnip(bool pickWindow) + { + if (_snipCountdown != null) + { + return; + } + + int seconds = Settings.CaptureDelaySeconds; + if (seconds <= 0 || _snipWindows.Count > 0) + { + BeginSnip(pickWindow); + return; + } + + var countdown = new CountdownWindow(seconds); + countdown.Elapsed += () => + { + EndCountdown(); + // Even wachten tot het aftelvenster echt van het scherm is. + var timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(150) }; + timer.Tick += (_, _) => + { + timer.Stop(); + BeginSnip(pickWindow); + }; + timer.Start(); + }; + countdown.Cancelled += EndCountdown; + _snipCountdown = countdown; + ApplyHotkeys(); + countdown.Start(); + } + + private void EndCountdown() + { + _snipCountdown = null; + ApplyHotkeys(); + } private void BeginSnip(bool pickWindow) { - if (_snipWindows.Count > 0 || Recorder.Stage == RecordingStage.Selecting) + if (_snipWindows.Count > 0 || _snipCountdown != null || Recorder.Stage == RecordingStage.Selecting) { return; } @@ -218,7 +268,7 @@ public partial class App : Application 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); + var window = new RegionCaptureWindow(screen, image, windows, Settings.ShowMagnifier); window.Finished += OnRegionSnipFinished; _snipWindows.Add(window); window.Show(); @@ -303,6 +353,12 @@ public partial class App : Application Bind(Settings.RecordHotkey, () => Recorder.Toggle(ScreencastFormat.Mp4)); Bind(Settings.GifHotkey, () => Recorder.Toggle(ScreencastFormat.Gif)); Bind(Settings.ClipboardHotkey, OpenFromClipboard); + if (_snipCountdown is { } countdown) + { + // Alleen tijdens het aftellen; daarna moet Esc gewoon naar het actieve programma. + bindings[new HotkeyGesture(System.Windows.Input.ModifierKeys.None, System.Windows.Input.Key.Escape)] = countdown.Cancel; + } + HotkeyHook.Bindings = bindings; } @@ -437,6 +493,37 @@ public partial class App : Application BringToFront(_settingsWindow); } + public void ShowHistory(bool images) + { + if (_historyWindow == null) + { + _historyWindow = new HistoryWindow(images); + _historyWindow.Closed += (_, _) => _historyWindow = null; + _historyWindow.Show(); + } + else + { + _historyWindow.ShowImages(images); + } + + BringToFront(_historyWindow); + } + + private IReadOnlyList<(string Label, string? Path)> TrayFolders() + { + static string? Existing(params string?[] folders) => + folders.FirstOrDefault(folder => folder is { Length: > 0 } && Directory.Exists(folder)); + + return new (string, string?)[] + { + ("Schermafbeeldingen", Existing(CaptureCoordinator.ScreenshotsFolder)), + ("Knipsels", Existing(Settings.SnipSaveFolder, Settings.LastSaveFolder, + Environment.GetFolderPath(Environment.SpecialFolder.MyPictures))), + ("Screencasts", Existing(Settings.VideoSaveFolder, Settings.LastVideoFolder, + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyVideos), "kniip"))) + }; + } + public void ShowBalloon(string title, string text) => _tray?.ShowBalloon(title, text); public void SaveSettings() diff --git a/src/Kniip/Capture/CountdownWindow.xaml b/src/Kniip/Capture/CountdownWindow.xaml new file mode 100644 index 0000000..a10d154 --- /dev/null +++ b/src/Kniip/Capture/CountdownWindow.xaml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + diff --git a/src/Kniip/Capture/CountdownWindow.xaml.cs b/src/Kniip/Capture/CountdownWindow.xaml.cs new file mode 100644 index 0000000..387527d --- /dev/null +++ b/src/Kniip/Capture/CountdownWindow.xaml.cs @@ -0,0 +1,116 @@ +using System; +using System.Windows; +using System.Windows.Input; +using System.Windows.Threading; +using Kniip.Interop; +using Kniip.Recording; +using Rectangle = System.Drawing.Rectangle; +using Screen = System.Windows.Forms.Screen; + +namespace Kniip.Capture; + +/// Klein venster bij de muis dat aftelt tot een vertraagd knipsel; neemt nooit de focus over. +public partial class CountdownWindow : Window +{ + private const int CursorOffset = 20; + + private readonly DispatcherTimer _follow = new() { Interval = TimeSpan.FromMilliseconds(40) }; + private readonly DateTime _end; + private bool _finished; + + /// De tijd is om; het venster is dan al gesloten. + public event Action? Elapsed; + + /// Geannuleerd met Esc of een klik; het venster is dan al gesloten. + public event Action? Cancelled; + + public CountdownWindow(int seconds) + { + InitializeComponent(); + Left = Top = -32000; + _end = DateTime.UtcNow.AddSeconds(seconds); + SecondsText.Text = seconds.ToString(); + _follow.Tick += (_, _) => Tick(); + SourceInitialized += (_, _) => + { + NativeMethods.AddExtendedStyle(PhysicalWindow.Handle(this), + NativeMethods.WS_EX_TOOLWINDOW | NativeMethods.WS_EX_NOACTIVATE); + PhysicalWindow.ExcludeFromCapture(this); + }; + ContentRendered += (_, _) => FollowCursor(); + Closed += (_, _) => + { + _follow.Stop(); + if (!_finished) + { + _finished = true; + Cancelled?.Invoke(); + } + }; + } + + public void Start() + { + Show(); + FollowCursor(); + _follow.Start(); + } + + public void Cancel() => Finish(Cancelled); + + private void Tick() + { + TimeSpan left = _end - DateTime.UtcNow; + if (left <= TimeSpan.Zero) + { + Finish(Elapsed); + return; + } + + SecondsText.Text = ((int)Math.Ceiling(left.TotalSeconds)).ToString(); + FollowCursor(); + } + + private void FollowCursor() + { + if (!IsVisible) + { + return; + } + + NativeMethods.GetCursorPos(out NativeMethods.POINT cursor); + Rectangle area = Screen.FromPoint(new System.Drawing.Point(cursor.X, cursor.Y)).WorkingArea; + Rectangle size = PhysicalWindow.GetBounds(this); + int x = cursor.X + CursorOffset, y = cursor.Y + CursorOffset; + if (x + size.Width > area.Right) + { + x = cursor.X - CursorOffset - size.Width; + } + + if (y + size.Height > area.Bottom) + { + y = cursor.Y - CursorOffset - size.Height; + } + + x = Math.Max(area.Left, x); + y = Math.Max(area.Top, y); + if (x != size.X || y != size.Y) + { + PhysicalWindow.Place(this, new Rectangle(x, y, size.Width, size.Height), resize: false); + } + } + + private void Finish(Action? handler) + { + if (_finished) + { + return; + } + + _finished = true; + Close(); + handler?.Invoke(); + } + + private void Border_Click(object sender, MouseButtonEventArgs e) => Cancel(); +} diff --git a/src/Kniip/Capture/MagnifierControl.xaml b/src/Kniip/Capture/MagnifierControl.xaml new file mode 100644 index 0000000..d6c9be4 --- /dev/null +++ b/src/Kniip/Capture/MagnifierControl.xaml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Kniip/Capture/MagnifierControl.xaml.cs b/src/Kniip/Capture/MagnifierControl.xaml.cs new file mode 100644 index 0000000..d3ce183 --- /dev/null +++ b/src/Kniip/Capture/MagnifierControl.xaml.cs @@ -0,0 +1,76 @@ +using System; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using System.Windows.Media.Imaging; + +namespace Kniip.Capture; + +/// Rond vergrootglas met kruisdraad en coördinaten dat naast de muisaanwijzer meeloopt. +public partial class MagnifierControl : UserControl +{ + private const int Pixels = 15; + private const double CursorOffset = 20; + + private readonly WriteableBitmap _zoom = new(Pixels, Pixels, 96, 96, PixelFormats.Bgra32, null); + private readonly byte[] _buffer = new byte[Pixels * Pixels * 4]; + + public MagnifierControl() + { + InitializeComponent(); + Zoom.Source = _zoom; + Visibility = Visibility.Collapsed; + } + + /// Vastgelegd beeld van het scherm. + /// Muispositie in pixels van . + /// Muispositie in fysieke bureaubladcoördinaten, voor de tekst. + /// Muispositie in het venster (DIP). + /// Grootte van het venster (DIP). + public void Show(BitmapSource frozen, System.Drawing.Point relative, System.Drawing.Point screen, Point cursor, Size area) + { + CopyPixels(frozen, relative); + CoordinateText.Text = $"X: {screen.X} Y: {screen.Y}"; + Visibility = Visibility.Visible; + + Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); + Size size = DesiredSize; + double left = cursor.X + CursorOffset; + if (left + size.Width > area.Width) + { + left = cursor.X - CursorOffset - size.Width; + } + + double top = cursor.Y + CursorOffset; + if (top + size.Height > area.Height) + { + top = cursor.Y - CursorOffset - size.Height; + } + + Margin = new Thickness(Math.Max(0, left), Math.Max(0, top), 0, 0); + } + + public void Hide() => Visibility = Visibility.Collapsed; + + private void CopyPixels(BitmapSource frozen, System.Drawing.Point center) + { + Array.Clear(_buffer); + for (int i = 3; i < _buffer.Length; i += 4) + { + _buffer[i] = 255; + } + + int half = Pixels / 2; + int x0 = Math.Max(0, center.X - half); + int y0 = Math.Max(0, center.Y - half); + int x1 = Math.Min(frozen.PixelWidth, center.X + half + 1); + int y1 = Math.Min(frozen.PixelHeight, center.Y + half + 1); + if (x1 > x0 && y1 > y0) + { + int offset = ((y0 - (center.Y - half)) * Pixels + (x0 - (center.X - half))) * 4; + frozen.CopyPixels(new Int32Rect(x0, y0, x1 - x0, y1 - y0), _buffer, Pixels * 4, offset); + } + + _zoom.WritePixels(new Int32Rect(0, 0, Pixels, Pixels), _buffer, Pixels * 4, 0); + } +} diff --git a/src/Kniip/Capture/RegionCaptureWindow.xaml b/src/Kniip/Capture/RegionCaptureWindow.xaml index ac8ffd0..3f96993 100644 --- a/src/Kniip/Capture/RegionCaptureWindow.xaml +++ b/src/Kniip/Capture/RegionCaptureWindow.xaml @@ -1,6 +1,7 @@ @@ -13,6 +14,7 @@ CornerRadius="4" Padding="6,2" Visibility="Collapsed" IsHitTestVisible="False"> + diff --git a/src/Kniip/Capture/RegionCaptureWindow.xaml.cs b/src/Kniip/Capture/RegionCaptureWindow.xaml.cs index 18b9d8b..f5691f5 100644 --- a/src/Kniip/Capture/RegionCaptureWindow.xaml.cs +++ b/src/Kniip/Capture/RegionCaptureWindow.xaml.cs @@ -22,6 +22,7 @@ public partial class RegionCaptureWindow : Window private readonly Screen _screen; private readonly BitmapSource _frozen; private readonly IReadOnlyList? _windows; + private readonly bool _showMagnifier; private Point? _dragStart; private bool _dragging; private Rectangle? _hover; @@ -33,13 +34,16 @@ public partial class RegionCaptureWindow : Window internal Rectangle? SelectedArea { get; private set; } /// Vensterrechthoeken van boven naar onder; gezet = venster aanklikken in plaats van slepen. - internal RegionCaptureWindow(Screen screen, BitmapSource frozen, IReadOnlyList? windows = null) + /// Vergrootglas bij de muisaanwijzer tonen; niet bij venster aanklikken. + internal RegionCaptureWindow(Screen screen, BitmapSource frozen, IReadOnlyList? windows = null, bool showMagnifier = false) { InitializeComponent(); _screen = screen; _frozen = frozen; _windows = windows; + _showMagnifier = showMagnifier && windows == null; Frozen.Source = frozen; + MouseLeave += (_, _) => Magnifier.Hide(); if (windows != null) { Cursor = Cursors.Hand; @@ -78,12 +82,19 @@ public partial class RegionCaptureWindow : Window return; } + Point position = e.GetPosition(this); + if (_showMagnifier) + { + System.Drawing.Point physical = ToPhysical(position); + var relative = new System.Drawing.Point(physical.X - _screen.Bounds.X, physical.Y - _screen.Bounds.Y); + Magnifier.Show(_frozen, relative, physical, position, new Size(ActualWidth, ActualHeight)); + } + if (_dragStart is not Point start || e.LeftButton != MouseButtonState.Pressed) { return; } - Point position = e.GetPosition(this); if (!_dragging && (position - start).Length >= DragThreshold) { _dragging = true; diff --git a/src/Kniip/Editor/EditorWindow.xaml b/src/Kniip/Editor/EditorWindow.xaml index 9a04cb2..6c24577 100644 --- a/src/Kniip/Editor/EditorWindow.xaml +++ b/src/Kniip/Editor/EditorWindow.xaml @@ -188,6 +188,16 @@ + + + + + + + + diff --git a/src/Kniip/Editor/EditorWindow.xaml.cs b/src/Kniip/Editor/EditorWindow.xaml.cs index ce009b0..bf041c3 100644 --- a/src/Kniip/Editor/EditorWindow.xaml.cs +++ b/src/Kniip/Editor/EditorWindow.xaml.cs @@ -35,11 +35,28 @@ public partial class EditorWindow : Window private static readonly CursorKind[] CursorKindValues = { CursorKind.Arrow, CursorKind.Click, CursorKind.Hand }; private static readonly BlurMode[] BlurModeValues = { BlurMode.Pixelate, BlurMode.Blur }; + /// Standaardvolgorde in de gesplitste indeling: eerst de bovenste werkbalk, dan de onderste. + private static readonly string[] DefaultSplitOrder = + { + "Select", "Sep1", "Rectangle", "Ellipse", "Line", "Arrow", "Text", "Step", "Cursor", "Emoji", "Highlight", "Blur", + "Sep2", "Crop", "Sep3", "Undo", "Redo", "Delete", "Sep4", "Clear", + "New", "Screencast", "Spacer", "ZoomOut", "Zoom", "ZoomIn", "Spacer2", "Settings", "Sep5", "Save", "Upload", "Copy" + }; + + /// Items die in de gesplitste indeling op de onderste werkbalk staan. + private static readonly HashSet SplitBottomIds = new() + { + "New", "Screencast", "Spacer", "ZoomOut", "Zoom", "ZoomIn", "Spacer2", "Settings", "Sep5", "Save", "Upload", "Copy" + }; + private readonly EditorDocument _doc; private readonly UndoStack _undo = new(); private readonly AppSettings _settings; private readonly DispatcherTimer _hintResetTimer = new() { Interval = TimeSpan.FromSeconds(3) }; private readonly List _defaultToolbarOrder = new(); + private readonly List _defaultSplitOrder = new(); + private readonly Dictionary _toolbarItems = new(); + private EditorLayout _appliedLayout; private ToolKind _tool; private Annotation? _selected; @@ -140,45 +157,71 @@ public partial class EditorWindow : Window private void InitializeToolbar() { - _defaultToolbarOrder.AddRange(ToolbarItems.Items.Select(ItemId)); - ApplyToolbarOrder(_settings.ToolbarOrder); foreach (UIElement item in ToolbarItems.Items) + { + _toolbarItems[ItemId(item)] = item; + _defaultToolbarOrder.Add(ItemId(item)); + } + + _defaultSplitOrder.AddRange(DefaultSplitOrder.Where(_toolbarItems.ContainsKey)); + _defaultSplitOrder.AddRange(_defaultToolbarOrder.Except(_defaultSplitOrder)); + ApplyToolbarLayout(); + foreach (UIElement item in _toolbarItems.Values) { item.Visibility = _settings.ToolbarHidden.Contains(ItemId(item)) ? Visibility.Collapsed : Visibility.Visible; } ApplyUploadButton(); - ApplyToolbarPosition(); ApplyToolbarLock(); } private static string ItemId(UIElement item) => ToolbarPanel.GetItemId(item) ?? string.Empty; - private void ApplyToolbarOrder(IReadOnlyList saved) + private bool IsSplitLayout => _settings.EditorLayout == EditorLayout.Split; + + private IEnumerable AllToolbarItems => ToolbarItems.Items.Concat(ActionItems.Items); + + /// Items volgens de opgeslagen volgorde van de huidige indeling over de bovenste en onderste werkbalk verdelen. + private void ApplyToolbarLayout() { - Dictionary byId = ToolbarItems.Items.ToDictionary(ItemId); - List order = saved.Where(byId.ContainsKey).Distinct().ToList(); + bool split = IsSplitLayout; + _appliedLayout = _settings.EditorLayout; + List order = split + ? MergeToolbarOrder(_settings.SplitToolbarOrder, _defaultSplitOrder) + : MergeToolbarOrder(_settings.ToolbarOrder, _defaultToolbarOrder); + + ToolbarItems.Children.Clear(); + ActionItems.Children.Clear(); + foreach (string id in order) + { + (split && SplitBottomIds.Contains(id) ? ActionItems : ToolbarItems).Children.Add(_toolbarItems[id]); + } + + ToolbarItems.HorizontalAlignment = split ? HorizontalAlignment.Center : HorizontalAlignment.Stretch; + ActionBar.Visibility = split && !_toolbarsHidden ? Visibility.Visible : Visibility.Collapsed; + ApplyToolbarPosition(); + } + + private List MergeToolbarOrder(IReadOnlyList saved, IReadOnlyList defaults) + { + List order = saved.Where(_toolbarItems.ContainsKey).Distinct().ToList(); // Items die nog niet in de opgeslagen volgorde staan (zoals nieuwe knoppen) komen na hun standaardvoorganger. - for (int i = 0; i < _defaultToolbarOrder.Count; i++) + for (int i = 0; i < defaults.Count; i++) { - string id = _defaultToolbarOrder[i]; + string id = defaults[i]; if (!order.Contains(id)) { - order.Insert(i == 0 ? 0 : order.IndexOf(_defaultToolbarOrder[i - 1]) + 1, id); + order.Insert(i == 0 ? 0 : order.IndexOf(defaults[i - 1]) + 1, id); } } - ToolbarItems.Children.Clear(); - foreach (string id in order) - { - ToolbarItems.Children.Add(byId[id]); - } + return order; } private void ApplyToolbarPosition() { - Dock dock = _settings.ToolbarPosition; + Dock dock = IsSplitLayout ? Dock.Top : _settings.ToolbarPosition; bool vertical = dock is Dock.Left or Dock.Right; DockPanel.SetDock(Toolbar, dock); ToolbarItems.Orientation = vertical ? Orientation.Vertical : Orientation.Horizontal; @@ -196,8 +239,11 @@ public partial class EditorWindow : Window private void ApplyToolbarLock() { bool locked = _settings.ToolbarLocked; + Brush background = locked ? (Brush)FindResource("ToolbarBrush") : new SolidColorBrush(Color.FromRgb(0xEA, 0xF2, 0xFB)); ToolbarItems.IsLocked = locked; - Toolbar.Background = locked ? (Brush)FindResource("ToolbarBrush") : new SolidColorBrush(Color.FromRgb(0xEA, 0xF2, 0xFB)); + ActionItems.IsLocked = locked; + Toolbar.Background = background; + ActionBar.Background = background; UpdateHint(); } @@ -209,73 +255,136 @@ public partial class EditorWindow : Window } } - // Labels alleen tonen als alles op één regel past; staat de werkbalk opzij, dan altijd alleen iconen. private void UpdateToolbarLayout() { - if (ToolbarItems.Orientation == Orientation.Vertical) + UpdateActionLabels(Toolbar, ToolbarItems); + UpdateActionLabels(ActionBar, ActionItems); + } + + // Labels alleen tonen als alles op één regel past; staat de werkbalk opzij, dan altijd alleen iconen. + private void UpdateActionLabels(Border bar, ToolbarPanel panel) + { + List labels = new[] { NewLabel, ClearLabel, ScreencastLabel, SaveLabel, UploadLabel, CopyLabel } + .Where(label => PanelOf(label) == panel).ToList(); + if (labels.Count == 0) { - SetActionLabelsVisible(false); return; } - double available = Toolbar.ActualWidth - Toolbar.Padding.Left - Toolbar.Padding.Right - - Toolbar.BorderThickness.Left - Toolbar.BorderThickness.Right; - SetActionLabelsVisible(true); - ToolbarItems.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); - SetActionLabelsVisible(ToolbarItems.SingleLineLength <= available); + if (panel.Orientation == Orientation.Vertical) + { + SetActionLabelsVisible(panel, labels, false); + return; + } + + double available = bar.ActualWidth - bar.Padding.Left - bar.Padding.Right + - bar.BorderThickness.Left - bar.BorderThickness.Right; + SetActionLabelsVisible(panel, labels, true); + panel.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); + SetActionLabelsVisible(panel, labels, panel.SingleLineLength <= available); } - private void SetActionLabelsVisible(bool visible) + private static ToolbarPanel? PanelOf(DependencyObject element) + { + for (DependencyObject? d = element; d != null; d = VisualTreeHelper.GetParent(d)) + { + if (d is ToolbarPanel panel) + { + return panel; + } + } + + return null; + } + + private static void SetActionLabelsVisible(ToolbarPanel panel, IEnumerable labels, bool visible) { Visibility visibility = visible ? Visibility.Visible : Visibility.Collapsed; - foreach (TextBlock label in new[] { NewLabel, ClearLabel, ScreencastLabel, SaveLabel, UploadLabel, CopyLabel }) + foreach (TextBlock label in labels) { label.Visibility = visibility; // Anders geeft een directe Measure() van het paneel nog de gecachete knopbreedtes terug. - for (DependencyObject? d = label; d != null && d != ToolbarItems; d = VisualTreeHelper.GetParent(d)) + for (DependencyObject? d = label; d != null && d != panel; d = VisualTreeHelper.GetParent(d)) { (d as UIElement)?.InvalidateMeasure(); } } - ToolbarItems.InvalidateMeasure(); + panel.InvalidateMeasure(); } private void ToolbarItems_OrderChanged(object? sender, EventArgs e) { - _settings.ToolbarOrder = ToolbarItems.Items.Select(ItemId).ToList(); + if (IsSplitLayout) + { + _settings.SplitToolbarOrder = AllToolbarItems.Select(ItemId).ToList(); + } + else + { + _settings.ToolbarOrder = ToolbarItems.Items.Select(ItemId).ToList(); + } + UpdateToolbarLayout(); } + private void SetEditorLayout(EditorLayout layout) + { + _settings.EditorLayout = layout; + ApplyToolbarLayout(); + } + private void Toolbar_ContextMenuOpening(object sender, ContextMenuEventArgs e) { - ToolbarMenu.Items.Clear(); + var bar = (Border)sender; + ToolbarPanel panel = bar == ActionBar ? ActionItems : ToolbarItems; + ContextMenu menu = bar.ContextMenu; + menu.Items.Clear(); var lockItem = new MenuItem { Header = "Werkbalk vergrendelen", IsCheckable = true, IsChecked = _settings.ToolbarLocked }; lockItem.Click += (_, _) => SetToolbarLocked(lockItem.IsChecked); - ToolbarMenu.Items.Add(lockItem); + menu.Items.Add(lockItem); - var position = new MenuItem { Header = "Positie" }; - foreach ((Dock dock, string label) in new[] { (Dock.Top, "Boven"), (Dock.Bottom, "Onder"), (Dock.Left, "Links"), (Dock.Right, "Rechts") }) + var layout = new MenuItem { Header = "Indeling" }; + foreach ((EditorLayout value, string label) in new[] + { + (EditorLayout.Single, "Eén werkbalk"), + (EditorLayout.Split, "Gesplitst: gereedschappen boven, opslaan onder (zoals ShareX)") + }) { - var item = new MenuItem { Header = label, IsCheckable = true, IsChecked = _settings.ToolbarPosition == dock }; - item.Click += (_, _) => - { - _settings.ToolbarPosition = dock; - ApplyToolbarPosition(); - }; - position.Items.Add(item); + var item = new MenuItem { Header = label, IsCheckable = true, IsChecked = _settings.EditorLayout == value }; + item.Click += (_, _) => SetEditorLayout(value); + layout.Items.Add(item); } - ToolbarMenu.Items.Add(position); + menu.Items.Add(layout); - var swap = new MenuItem { Header = "Groepen omwisselen" }; - swap.Click += (_, _) => SwapToolbarGroups(); - ToolbarMenu.Items.Add(swap); + if (!IsSplitLayout) + { + var position = new MenuItem { Header = "Positie" }; + foreach ((Dock dock, string label) in new[] { (Dock.Top, "Boven"), (Dock.Bottom, "Onder"), (Dock.Left, "Links"), (Dock.Right, "Rechts") }) + { + var item = new MenuItem { Header = label, IsCheckable = true, IsChecked = _settings.ToolbarPosition == dock }; + item.Click += (_, _) => + { + _settings.ToolbarPosition = dock; + ApplyToolbarPosition(); + }; + position.Items.Add(item); + } + + menu.Items.Add(position); + } + + if (panel.Items.Any(i => i is ToolbarSpacer)) + { + var swap = new MenuItem { Header = "Groepen omwisselen" }; + swap.Click += (_, _) => SwapToolbarGroups(panel); + menu.Items.Add(swap); + } var buttons = new MenuItem { Header = "Knoppen tonen" }; - foreach (UIElement element in ToolbarItems.Items) + foreach (UIElement element in AllToolbarItems) { if (ToolbarPanel.GetTitle(element) is not { } title || (element == UploadButton && !CanUpload)) { @@ -287,12 +396,12 @@ public partial class EditorWindow : Window buttons.Items.Add(item); } - ToolbarMenu.Items.Add(buttons); - ToolbarMenu.Items.Add(new Separator()); + menu.Items.Add(buttons); + menu.Items.Add(new Separator()); var reset = new MenuItem { Header = "Standaard herstellen" }; reset.Click += (_, _) => ResetToolbar(); - ToolbarMenu.Items.Add(reset); + menu.Items.Add(reset); } private void SetToolbarLocked(bool locked) @@ -315,49 +424,50 @@ public partial class EditorWindow : Window } /// De groepen tussen de flexibele ruimtes in omgekeerde volgorde zetten; een middengroep blijft in het midden. - private void SwapToolbarGroups() + private void SwapToolbarGroups(ToolbarPanel panel) { - var groups = new List> { new() }; - var spacers = new List(); - foreach (UIElement item in ToolbarItems.Items) + var groups = new List> { new() }; + var spacers = new List(); + foreach (UIElement item in panel.Items) { if (item is ToolbarSpacer) { - spacers.Add(ItemId(item)); - groups.Add(new List()); + spacers.Add(item); + groups.Add(new List()); } else { - groups[^1].Add(ItemId(item)); + groups[^1].Add(item); } } groups.Reverse(); - var order = new List(groups[0]); + var order = new List(groups[0]); for (int i = 0; i < spacers.Count; i++) { order.Add(spacers[i]); order.AddRange(groups[i + 1]); } - ApplyToolbarOrder(order); + panel.Children.Clear(); + order.ForEach(item => panel.Children.Add(item)); ToolbarItems_OrderChanged(this, EventArgs.Empty); } private void ResetToolbar() { _settings.ToolbarOrder = new List(); + _settings.SplitToolbarOrder = new List(); _settings.ToolbarHidden = new List(); _settings.ToolbarPosition = Dock.Top; _settings.ToolbarLocked = true; - ApplyToolbarOrder(_defaultToolbarOrder); - foreach (UIElement item in ToolbarItems.Items) + ApplyToolbarLayout(); + foreach (UIElement item in _toolbarItems.Values) { item.Visibility = Visibility.Visible; } ApplyUploadButton(); - ApplyToolbarPosition(); ApplyToolbarLock(); } @@ -365,7 +475,7 @@ public partial class EditorWindow : Window private void SelectTool(ToolKind tool) { - foreach (RadioButton button in ToolbarItems.Items.OfType()) + foreach (RadioButton button in _toolbarItems.Values.OfType()) { if ((string)button.Tag == tool.ToString()) { @@ -934,6 +1044,7 @@ public partial class EditorWindow : Window Visibility visibility = _toolbarsHidden ? Visibility.Collapsed : Visibility.Visible; Toolbar.Visibility = visibility; PropsBar.Visibility = visibility; + ActionBar.Visibility = IsSplitLayout ? visibility : Visibility.Collapsed; if (_toolbarsHidden) { ShowTemporaryHint("Werkbalken verborgen. Ctrl+H of rechtsklik op het canvas om ze terug te halen."); @@ -1508,6 +1619,11 @@ public partial class EditorWindow : Window protected override void OnActivated(EventArgs e) { base.OnActivated(e); + if (_appliedLayout != _settings.EditorLayout) + { + ApplyToolbarLayout(); + } + ApplySaveButton(); ApplyUploadButton(); } @@ -1541,6 +1657,7 @@ public partial class EditorWindow : Window { using System.IO.MemoryStream png = ImageExporter.EncodePng(image); string url = await ZiplineUploader.UploadAsync(config, png, $"Knipsel {DateTime.Now:yyyy-MM-dd HHmmss}.png", "image/png"); + Kniip.History.HistoryStore.Add(Kniip.History.HistoryKind.Snip, null, url, image); _dirty = false; _exported = true; ShowTemporaryHint(ClipboardWriter.SetText(url) @@ -1637,6 +1754,7 @@ public partial class EditorWindow : Window App.Instance.Capture.IgnorePath(path); BitmapSource image = DocumentRenderer.Export(_doc); ImageExporter.Save(image, path); + Kniip.History.HistoryStore.Add(Kniip.History.HistoryKind.Snip, path, null, image); _dirty = false; _exported = true; string name = System.IO.Path.GetFileName(path); diff --git a/src/Kniip/History/HistoryActions.cs b/src/Kniip/History/HistoryActions.cs new file mode 100644 index 0000000..30ef2e5 --- /dev/null +++ b/src/Kniip/History/HistoryActions.cs @@ -0,0 +1,102 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Windows; +using Kniip.Editor; +using Kniip.Infrastructure; + +namespace Kniip.History; + +/// Teken uit Segoe Fluent Icons. +public sealed record HistoryCommand(string Label, string Glyph, bool Enabled, Action Run); + +/// Wat je met een item uit de geschiedenis kunt doen, gedeeld door het systeemvakmenu en het geschiedenisvenster. +public static class HistoryActions +{ + /// De acties voor een item; null staat voor een scheidingslijn. + public static IReadOnlyList CommandsFor(HistoryEntry entry) + { + bool file = entry.FileExists; + bool url = entry.Url is { Length: > 0 }; + bool image = file && entry.Kind == HistoryKind.Snip; + return new HistoryCommand?[] + { + new("Openen", "\uE8E5", file || url, () => Open(entry)), + new("Openen in de editor", "\uE70F", image, () => App.Instance.OpenFile(entry.FilePath!)), + new("Link openen", "\uE774", url, () => Launch(entry.Url!)), + null, + new("Link kopiëren", "\uE71B", url, () => Copy(entry.Url!)), + new("Afbeelding kopiëren", "\uE8C8", image, () => CopyImage(entry.FilePath!)), + new("Pad kopiëren", "\uE8C8", file, () => Copy(entry.FilePath!)), + new("Tonen in map", "\uE838", file, () => ShowInFolder(entry.FilePath!)) + }; + } + + /// Bestand in het standaardprogramma, anders de link in de browser. + public static void Open(HistoryEntry entry) + { + if (entry.FileExists) + { + Launch(entry.FilePath!); + } + else if (entry.Url is { Length: > 0 } url) + { + Launch(url); + } + else + { + MessageBox.Show($"Het bestand bestaat niet meer:\n{entry.FilePath}", "kniip", MessageBoxButton.OK, MessageBoxImage.Information); + } + } + + public static void OpenFolder(string folder) + { + Directory.CreateDirectory(folder); + Launch(folder); + } + + private static void ShowInFolder(string path) => + Run(() => Process.Start("explorer.exe", $"/select,\"{path}\"")); + + private static void Launch(string target) => + Run(() => Process.Start(new ProcessStartInfo(target) { UseShellExecute = true })); + + private static void Copy(string text) + { + if (!ClipboardWriter.SetText(text)) + { + ClipboardBusy(); + } + } + + private static void CopyImage(string path) + { + try + { + if (!ClipboardWriter.SetImage(PixelUtil.LoadFile(path), path)) + { + ClipboardBusy(); + } + } + catch (Exception ex) + { + MessageBox.Show($"Kan '{Path.GetFileName(path)}' niet kopiëren.\n\n{ex.Message}", "kniip", MessageBoxButton.OK, MessageBoxImage.Warning); + } + } + + private static void ClipboardBusy() => + MessageBox.Show("Kopiëren is mislukt: het klembord is bezet. Probeer het opnieuw.", "kniip", MessageBoxButton.OK, MessageBoxImage.Warning); + + private static void Run(Action action) + { + try + { + action(); + } + catch (Exception ex) + { + MessageBox.Show($"Openen is mislukt.\n\n{ex.Message}", "kniip", MessageBoxButton.OK, MessageBoxImage.Warning); + } + } +} diff --git a/src/Kniip/History/HistoryEntry.cs b/src/Kniip/History/HistoryEntry.cs new file mode 100644 index 0000000..1caf165 --- /dev/null +++ b/src/Kniip/History/HistoryEntry.cs @@ -0,0 +1,47 @@ +using System; +using System.IO; +using System.Text.Json.Serialization; + +namespace Kniip.History; + +public enum HistoryKind +{ + Snip, + Mp4, + Gif +} + +public sealed class HistoryEntry +{ + public string Id { get; set; } = Guid.NewGuid().ToString("N"); + public DateTime Time { get; set; } = DateTime.Now; + public HistoryKind Kind { get; set; } + + /// Opgeslagen bestand; leeg als het alleen is geüpload. + public string? FilePath { get; set; } + + /// Link na het uploaden; leeg als het alleen is opgeslagen. + public string? Url { get; set; } + + /// Bestandsnaam van de miniatuur in . + public string? Thumbnail { get; set; } + + public int Width { get; set; } + public int Height { get; set; } + + [JsonIgnore] + public string Name => FilePath is { Length: > 0 } + ? Path.GetFileName(FilePath) + : Url is { Length: > 0 } ? Url[(Url.TrimEnd('/').LastIndexOf('/') + 1)..] : ""; + + [JsonIgnore] + public string KindText => Kind switch + { + HistoryKind.Mp4 => "Screencast (MP4)", + HistoryKind.Gif => "Screencast (GIF)", + _ => "Knipsel" + }; + + [JsonIgnore] + public bool FileExists => FilePath is { Length: > 0 } && File.Exists(FilePath); +} diff --git a/src/Kniip/History/HistoryStore.cs b/src/Kniip/History/HistoryStore.cs new file mode 100644 index 0000000..47d781c --- /dev/null +++ b/src/Kniip/History/HistoryStore.cs @@ -0,0 +1,169 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using Kniip.Infrastructure; +using Kniip.Settings; + +namespace Kniip.History; + +/// Wat er in kniip is opgeslagen of geüpload, nieuwste eerst. +public static class HistoryStore +{ + private const int MaxEntries = 500; + private const int ThumbnailSize = 240; + + private static readonly JsonSerializerOptions Options = new() + { + Converters = { new JsonStringEnumConverter() } + }; + + private static List? _entries; + + private static string FilePath => Path.Combine(SettingsStore.Folder, "history.json"); + + public static string ThumbnailFolder => Path.Combine(SettingsStore.Folder, "history"); + + public static event Action? Changed; + + public static IReadOnlyList Entries => Load(); + + public static void Add(HistoryKind kind, string? filePath, string? url, BitmapSource? preview) + { + var entry = new HistoryEntry { Kind = kind, FilePath = filePath, Url = url }; + if (preview != null) + { + entry.Width = preview.PixelWidth; + entry.Height = preview.PixelHeight; + entry.Thumbnail = SaveThumbnail(entry.Id, preview); + } + + List entries = Load(); + entries.Insert(0, entry); + while (entries.Count > MaxEntries) + { + DeleteThumbnail(entries[^1]); + entries.RemoveAt(entries.Count - 1); + } + + Save(); + } + + public static void Remove(HistoryEntry entry) + { + if (Load().Remove(entry)) + { + DeleteThumbnail(entry); + Save(); + } + } + + public static void Clear() + { + foreach (HistoryEntry entry in Load()) + { + DeleteThumbnail(entry); + } + + Load().Clear(); + Save(); + } + + public static string? ThumbnailPath(HistoryEntry entry) => + entry.Thumbnail is { Length: > 0 } name ? Path.Combine(ThumbnailFolder, name) : null; + + /// Eerste beeld van een afbeelding of GIF, of null als dat niet lukt. + public static BitmapSource? PreviewOf(string file) + { + try + { + return PixelUtil.LoadFile(file); + } + catch (Exception) + { + return null; + } + } + + private static List Load() + { + if (_entries != null) + { + return _entries; + } + + try + { + if (File.Exists(FilePath)) + { + _entries = JsonSerializer.Deserialize>(File.ReadAllText(FilePath), Options); + } + } + catch (Exception) + { + // Beschadigd bestand: met een lege geschiedenis verder. + } + + _entries ??= new List(); + _entries.RemoveAll(entry => entry == null!); + return _entries; + } + + private static void Save() + { + try + { + Directory.CreateDirectory(SettingsStore.Folder); + File.WriteAllText(FilePath, JsonSerializer.Serialize(Load(), Options)); + } + catch (Exception) + { + // De geschiedenis bijwerken mag opslaan of uploaden nooit laten mislukken. + } + + Changed?.Invoke(); + } + + private static string? SaveThumbnail(string id, BitmapSource image) + { + try + { + double scale = Math.Min(1.0, (double)ThumbnailSize / Math.Max(image.PixelWidth, image.PixelHeight)); + BitmapSource thumb = scale < 1 ? new TransformedBitmap(image, new ScaleTransform(scale, scale)) : image; + var encoder = new PngBitmapEncoder(); + encoder.Frames.Add(BitmapFrame.Create(thumb)); + Directory.CreateDirectory(ThumbnailFolder); + string name = id + ".png"; + using var stream = new FileStream(Path.Combine(ThumbnailFolder, name), FileMode.Create, FileAccess.Write); + encoder.Save(stream); + return name; + } + catch (Exception) + { + return null; + } + } + + private static void DeleteThumbnail(HistoryEntry entry) + { + if (ThumbnailPath(entry) is not { } path) + { + return; + } + + try + { + File.Delete(path); + } + catch (Exception) + { + // Blijft staan; een losse miniatuur kan geen kwaad. + } + } + + public static IEnumerable Recent(int count) => Load().Take(count); +} diff --git a/src/Kniip/History/HistoryWindow.xaml b/src/Kniip/History/HistoryWindow.xaml new file mode 100644 index 0000000..a9648b1 --- /dev/null +++ b/src/Kniip/History/HistoryWindow.xaml @@ -0,0 +1,135 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +