This commit is contained in:
rogier committed 2026-11-07 12:19:45 +01:00
commit d55d8bc32d
93 files changed
+13507

No files matched your search

+303
View File
@@ -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="&#xE70D;" FontFamily="{StaticResource IconFont}" FontSize="8" Margin="6,1,0,0"
VerticalAlignment="Center" Foreground="#5C5C5C" />
</StackPanel>
<Popup x:Name="PART_Popup" IsOpen="{Binding IsDropDownOpen, RelativeSource={RelativeSource TemplatedParent}}"
Placement="Bottom" AllowsTransparency="True" Focusable="False" PopupAnimation="Fade">
<Border Background="White" BorderBrush="#D6D6D6" BorderThickness="1" CornerRadius="6" Padding="3"
Margin="0,4,10,10" MinWidth="{TemplateBinding ActualWidth}">
<Border.Effect>
<DropShadowEffect BlurRadius="10" ShadowDepth="2" Opacity="0.18" />
</Border.Effect>
<ScrollViewer MaxHeight="360" VerticalScrollBarVisibility="Auto">
<ItemsPresenter KeyboardNavigation.DirectionalNavigation="Contained" />
</ScrollViewer>
</Border>
</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>
+494
View File
@@ -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

+12
View File
@@ -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
+1
View File
@@ -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

+112
View File
@@ -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);
}
+141
View File
@@ -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;
}
}
}
+147
View File
@@ -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();
}
}
+113
View File
@@ -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);
}
}
+52
View File
@@ -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);
}
}
+103
View File
@@ -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;
}
+48
View File
@@ -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="&#xE70D;" 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>
+142
View File
@@ -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();
}
+16
View File
@@ -0,0 +1,16 @@
using System;
using System.Globalization;
using System.Windows.Data;
using Kniip.Editor.Annotations;
namespace Kniip.Editor;
/// <summary>Geeft de vectorvorm van een 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();
}
+40
View File
@@ -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;
}
}
+79
View File
@@ -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);
}
}
+443
View File
@@ -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="&#xE76E;" 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="&#xE7A8;" 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="&#xE722;" 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="&#xE714;" 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="&#xE894;" 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="&#xE7A7;" 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="&#xE7A6;" 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="&#xE74D;" 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="&#xE71F;" 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="&#xE8A3;" 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="&#xE713;" 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="&#xE74E;" 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="&#xE898;" 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="&#xE8C8;" 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
+191
View File
@@ -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);
}
}
+99
View File
@@ -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="&#xE70D;" 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="&#xE721;" 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>
+395
View File
@@ -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;
}
}
}
+148
View File
@@ -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];
}
}
}
}
}
+99
View File
@@ -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;
}
}
+135
View File
@@ -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
};
}
+498
View File
@@ -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);
}
}
+58
View File
@@ -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();
}
}
+112
View File
@@ -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()
};
}
+59
View File
@@ -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();
}
}
}
+110
View File
@@ -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();
}
}
+233
View File
@@ -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);
}
}
}
+39
View File
@@ -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>
+50
View File
@@ -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="&#xE711;" 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="&#xE70F;" 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>
+78
View File
@@ -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();
}
+76
View File
@@ -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);
}
}
+522
View File
@@ -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;
}
}
}
}
+183
View File
@@ -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;
}
}
}
+80
View File
@@ -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;
}
}
+380
View File
@@ -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.
}
}
}
+20
View File
@@ -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);
}
+185
View File
@@ -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;
}
}
+76
View File
@@ -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="&#xE767;" Style="{StaticResource IconText}" />
</ToggleButton>
<ToggleButton x:Name="MicrophoneToggle" Style="{StaticResource MiniToggle}" Height="34" MinWidth="34"
ToolTip="Microfoon opnemen">
<TextBlock Text="&#xE720;" 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="&#xE769;" 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="&#xE711;" 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();
}
+62
View File
@@ -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);
}
}
+142
View File
@@ -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="&#xE768;" 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="&#xE76C;" 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="&#xE76B;" 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="&#xE8C8;" 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="&#xE898;" 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="&#xE74E;" 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.
}
}
}
+71
View File
@@ -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;
}
}
}
+325
View File
@@ -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);
}
}
+103
View File
@@ -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;
}
}
+20
View File
@@ -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;
}
}
+126
View File
@@ -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}");
}
}
+243
View File
@@ -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>
+469
View File
@@ -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();
}
}
}
+30
View File
@@ -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;
}
+346
View File
@@ -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;
}
}
+178
View File
@@ -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;
}
}
}
+25
View File
@@ -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>