stable
This commit is contained in:
commit
d55d8bc32d
93 files changed
+13507
No files matched your search
@@ -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>
|
||||
Reference in new issue
Block a user