diff --git a/AGENTS.md b/AGENTS.md index bfef1ef7..f42d0134 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,10 +93,11 @@ private string searchQuery = string.Empty; ## Localization -1. Add English strings ONLY to `Wino.Core.Domain/Translations/en_US/resources.json` +1. Add English strings ONLY to Wino.Core.Domain/Translations/en_US/resources.json 2. Build project - source generators create Translator properties -3. Use `Translator.{PropertyName}` in code/XAML -4. **NEVER** edit other language files - Crowdin manages translations +3. Use Translator.{PropertyName} in code/XAML +4. NEVER edit any resources.json file outside Wino.Core.Domain/Translations/en_US/resources.json +5. Treat all non-en_US translation files as managed externally and leave them untouched, even when adding new localization keys ## Storage @@ -133,3 +134,4 @@ private string searchQuery = string.Empty; - In `EventDetailsPageViewModel.LoadAttendeesAsync`, never mutate `CurrentEvent.Attendees` outside `ExecuteUIThread(...)`. + diff --git a/Wino.Calendar.ViewModels/CalendarEventComposePageViewModel.cs b/Wino.Calendar.ViewModels/CalendarEventComposePageViewModel.cs index 49ac9760..bad47eec 100644 --- a/Wino.Calendar.ViewModels/CalendarEventComposePageViewModel.cs +++ b/Wino.Calendar.ViewModels/CalendarEventComposePageViewModel.cs @@ -507,55 +507,21 @@ public partial class CalendarEventComposePageViewModel : CalendarBaseViewModel { var effectiveStart = GetEffectiveStartDateTime(); var effectiveEnd = GetEffectiveEndDateTime(); - var timeSummary = IsAllDay - ? Translator.CalendarItemAllDay - : string.Format( - CultureInfo.CurrentCulture, - Translator.CalendarEventCompose_TimeRangeSummary, - effectiveStart.ToString(CurrentSettings.DayHeaderDisplayType == DayHeaderDisplayType.TwentyFourHour ? "HH:mm" : "h:mm tt", CultureInfo.CurrentCulture), - effectiveEnd.ToString(CurrentSettings.DayHeaderDisplayType == DayHeaderDisplayType.TwentyFourHour ? "HH:mm" : "h:mm tt", CultureInfo.CurrentCulture)); - - if (!IsRecurring) - { - RecurrenceSummary = string.Format( - CultureInfo.CurrentCulture, - Translator.CalendarEventCompose_SingleOccurrenceSummary, - effectiveStart.ToString("dddd yyyy-MM-dd", CultureInfo.CurrentCulture), - timeSummary); - return; - } - - var frequencyLabel = SelectedRecurrenceFrequencyOption?.PluralLabel(SelectedRecurrenceInterval) - ?? Translator.CalendarEventCompose_FrequencyWeekPlural; - var selectedDays = WeekdayOptions .Where(option => option.IsSelected) - .Select(option => option.FullDayName) + .Select(option => option.DayOfWeek) .ToList(); - var weekdaySummary = selectedDays.Count == 0 - ? string.Empty - : string.Format( - CultureInfo.CurrentCulture, - Translator.CalendarEventCompose_WeekdaySummary, - string.Join(", ", selectedDays)); - - var untilSummary = RecurrenceEndDate.HasValue - ? string.Format( - CultureInfo.CurrentCulture, - Translator.CalendarEventCompose_UntilSummary, - RecurrenceEndDate.Value.ToString("ddd yyyy-MM-dd", CultureInfo.CurrentCulture)) - : string.Empty; - - RecurrenceSummary = string.Format( - CultureInfo.CurrentCulture, - Translator.CalendarEventCompose_RecurringSummary, + RecurrenceSummary = CalendarRecurrenceSummaryFormatter.BuildSummary( + IsRecurring, + effectiveStart, + effectiveEnd, + IsAllDay, + CurrentSettings, SelectedRecurrenceInterval, - frequencyLabel, - weekdaySummary, - timeSummary, - effectiveStart.ToString("dddd yyyy-MM-dd", CultureInfo.CurrentCulture), - untilSummary).Trim(); + SelectedRecurrenceFrequencyOption?.Frequency ?? CalendarItemRecurrenceFrequency.Weekly, + selectedDays, + RecurrenceEndDate); } private string BuildRecurrenceRule() @@ -707,3 +673,5 @@ public partial class CalendarComposeWeekdayOption : ObservableObject Label = label; } } + + diff --git a/Wino.Core.Domain/CalendarRecurrenceSummaryFormatter.cs b/Wino.Core.Domain/CalendarRecurrenceSummaryFormatter.cs new file mode 100644 index 00000000..7cb50fcb --- /dev/null +++ b/Wino.Core.Domain/CalendarRecurrenceSummaryFormatter.cs @@ -0,0 +1,126 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Models.Calendar; + +namespace Wino.Core.Domain; + +public static class CalendarRecurrenceSummaryFormatter +{ + private static readonly DayOfWeek[] OrderedDays = + [ + DayOfWeek.Monday, + DayOfWeek.Tuesday, + DayOfWeek.Wednesday, + DayOfWeek.Thursday, + DayOfWeek.Friday, + DayOfWeek.Saturday, + DayOfWeek.Sunday + ]; + + public static string BuildSummary( + bool isRecurring, + DateTimeOffset effectiveStart, + DateTimeOffset effectiveEnd, + bool isAllDay, + CalendarSettings settings, + int interval, + CalendarItemRecurrenceFrequency frequency, + IReadOnlyCollection daysOfWeek, + DateTimeOffset? recurrenceEndDate) + { + var culture = settings?.CultureInfo ?? CultureInfo.CurrentCulture; + var timeSummary = isAllDay + ? Translator.CalendarItemAllDay + : string.Format( + culture, + Translator.CalendarEventCompose_TimeRangeSummary, + effectiveStart.ToString(settings?.DayHeaderDisplayType == DayHeaderDisplayType.TwentyFourHour ? "HH:mm" : "h:mm tt", culture), + effectiveEnd.ToString(settings?.DayHeaderDisplayType == DayHeaderDisplayType.TwentyFourHour ? "HH:mm" : "h:mm tt", culture)); + + if (!isRecurring) + { + return string.Format( + culture, + Translator.CalendarEventCompose_SingleOccurrenceSummary, + effectiveStart.ToString("dddd yyyy-MM-dd", culture), + timeSummary); + } + + var normalizedDays = NormalizeDays(daysOfWeek); + var isEveryDay = (frequency == CalendarItemRecurrenceFrequency.Daily && interval == 1) || + (frequency == CalendarItemRecurrenceFrequency.Weekly && interval == 1 && normalizedDays.Count == 7); + + var cadenceSummary = isEveryDay + ? $"{Translator.CalendarEventCompose_Every} {Translator.CalendarEventCompose_FrequencyDay}" + : interval == 1 + ? $"{Translator.CalendarEventCompose_Every} {GetSingularFrequencyLabel(frequency)}" + : $"{Translator.CalendarEventCompose_Every} {interval.ToString(culture)} {GetPluralFrequencyLabel(frequency)}"; + + var weekdaySummary = string.Empty; + if (frequency == CalendarItemRecurrenceFrequency.Weekly && normalizedDays.Count > 0 && normalizedDays.Count < 7) + { + weekdaySummary = string.Format( + culture, + Translator.CalendarEventCompose_WeekdaySummary, + string.Join(", ", normalizedDays.Select(day => culture.DateTimeFormat.GetDayName(day)))); + } + + var untilSummary = recurrenceEndDate.HasValue + ? string.Format( + culture, + Translator.CalendarEventCompose_UntilSummary, + recurrenceEndDate.Value.ToString("ddd yyyy-MM-dd", culture)) + : string.Empty; + + return string.Format( + culture, + Translator.GetTranslatedString("CalendarEventCompose_RecurringSummarySmart"), + cadenceSummary, + weekdaySummary, + timeSummary, + effectiveStart.ToString("dddd yyyy-MM-dd", culture), + untilSummary).Trim(); + } + + private static IReadOnlyList NormalizeDays(IReadOnlyCollection daysOfWeek) + { + if (daysOfWeek == null || daysOfWeek.Count == 0) + { + return []; + } + + return daysOfWeek + .Distinct() + .OrderBy(day => Array.IndexOf(OrderedDays, day)) + .ToList(); + } + + private static string GetSingularFrequencyLabel(CalendarItemRecurrenceFrequency frequency) + { + return frequency switch + { + CalendarItemRecurrenceFrequency.Daily => Translator.CalendarEventCompose_FrequencyDay, + CalendarItemRecurrenceFrequency.Weekly => Translator.CalendarEventCompose_FrequencyWeek, + CalendarItemRecurrenceFrequency.Monthly => Translator.CalendarEventCompose_FrequencyMonth, + CalendarItemRecurrenceFrequency.Yearly => Translator.CalendarEventCompose_FrequencyYear, + _ => Translator.CalendarEventCompose_FrequencyWeek + }; + } + + private static string GetPluralFrequencyLabel(CalendarItemRecurrenceFrequency frequency) + { + return frequency switch + { + CalendarItemRecurrenceFrequency.Daily => Translator.CalendarEventCompose_FrequencyDayPlural, + CalendarItemRecurrenceFrequency.Weekly => Translator.CalendarEventCompose_FrequencyWeekPlural, + CalendarItemRecurrenceFrequency.Monthly => Translator.CalendarEventCompose_FrequencyMonthPlural, + CalendarItemRecurrenceFrequency.Yearly => Translator.CalendarEventCompose_FrequencyYearPlural, + _ => Translator.CalendarEventCompose_FrequencyWeekPlural + }; + } +} + + diff --git a/Wino.Core.Domain/Translations/en_US/resources.json b/Wino.Core.Domain/Translations/en_US/resources.json index 70a2e718..7d62dab9 100644 --- a/Wino.Core.Domain/Translations/en_US/resources.json +++ b/Wino.Core.Domain/Translations/en_US/resources.json @@ -1,4 +1,4 @@ -{ +{ "AccountAlias_Column_Alias": "Alias", "AccountAlias_Column_IsPrimaryAlias": "Primary", "AccountAlias_Column_Verified": "Verified", diff --git a/Wino.Mail.WinUI/Controls/EditorCommanding.cs b/Wino.Mail.WinUI/Controls/EditorCommanding.cs new file mode 100644 index 00000000..063c512c --- /dev/null +++ b/Wino.Mail.WinUI/Controls/EditorCommanding.cs @@ -0,0 +1,164 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using Microsoft.UI; +using Microsoft.UI.Xaml.Media; +using Windows.UI; + +namespace Wino.Mail.Controls; + +public interface IEditorCommandTarget +{ + EditorState CurrentState { get; } + EditorCapabilities Capabilities { get; } + event EventHandler? StateChanged; + event EventHandler? CapabilitiesChanged; + Task ExecuteCommandAsync(EditorCommand command); +} + +public interface IEditorCommandControl +{ + IEditorCommandTarget? CommandTarget { get; set; } + void AttachCommandTarget(IEditorCommandTarget? target); + void DetachCommandTarget(); +} + +public enum EditorCommandKind +{ + ToggleBold, + ToggleItalic, + ToggleUnderline, + ToggleStrikethrough, + ToggleOrderedList, + ToggleUnorderedList, + Indent, + Outdent, + SetAlignment, + SetFontFamily, + SetFontSize, + SetParagraphStyle, + SetTextColor, + SetHighlightColor, + SetLineHeight, + InsertImage, + InsertLink, + RemoveLink, + InsertEmoji, + InsertTable, + ToggleBuiltInToolbar, + ToggleTheme, + ToggleSpellCheck +} + +public enum EditorTextAlignment +{ + Left, + Center, + Right, + Justify +} + +public sealed record class EditorCommand(EditorCommandKind Kind, object? Value = null) +{ + public static EditorCommand ToggleBold() => new(EditorCommandKind.ToggleBold); + public static EditorCommand ToggleItalic() => new(EditorCommandKind.ToggleItalic); + public static EditorCommand ToggleUnderline() => new(EditorCommandKind.ToggleUnderline); + public static EditorCommand ToggleStrikethrough() => new(EditorCommandKind.ToggleStrikethrough); + public static EditorCommand ToggleOrderedList() => new(EditorCommandKind.ToggleOrderedList); + public static EditorCommand ToggleUnorderedList() => new(EditorCommandKind.ToggleUnorderedList); + public static EditorCommand Indent() => new(EditorCommandKind.Indent); + public static EditorCommand Outdent() => new(EditorCommandKind.Outdent); + public static EditorCommand SetAlignment(EditorTextAlignment alignment) => new(EditorCommandKind.SetAlignment, alignment); + public static EditorCommand SetFontFamily(string fontFamily) => new(EditorCommandKind.SetFontFamily, fontFamily); + public static EditorCommand SetFontSize(int fontSize) => new(EditorCommandKind.SetFontSize, fontSize); + public static EditorCommand SetParagraphStyle(string tagName) => new(EditorCommandKind.SetParagraphStyle, tagName); + public static EditorCommand SetTextColor(string color) => new(EditorCommandKind.SetTextColor, color); + public static EditorCommand SetHighlightColor(string color) => new(EditorCommandKind.SetHighlightColor, color); + public static EditorCommand SetLineHeight(string lineHeight) => new(EditorCommandKind.SetLineHeight, lineHeight); + public static EditorCommand InsertImage() => new(EditorCommandKind.InsertImage); + public static EditorCommand InsertEmoji() => new(EditorCommandKind.InsertEmoji); + public static EditorCommand InsertLink(EditorLinkCommandArgs args) => new(EditorCommandKind.InsertLink, args); + public static EditorCommand RemoveLink() => new(EditorCommandKind.RemoveLink); + public static EditorCommand InsertTable(EditorTableCommandArgs args) => new(EditorCommandKind.InsertTable, args); + public static EditorCommand ToggleBuiltInToolbar(bool isVisible) => new(EditorCommandKind.ToggleBuiltInToolbar, isVisible); + public static EditorCommand ToggleTheme(bool isDarkMode) => new(EditorCommandKind.ToggleTheme, isDarkMode); + public static EditorCommand ToggleSpellCheck(bool isEnabled) => new(EditorCommandKind.ToggleSpellCheck, isEnabled); +} + +public sealed record class EditorLinkCommandArgs( + [property: JsonPropertyName("url")] string Url, + [property: JsonPropertyName("text")] string? Text = null, + [property: JsonPropertyName("openInNewWindow")] bool OpenInNewWindow = true); + +public sealed record class EditorTableCommandArgs( + [property: JsonPropertyName("rows")] int Rows, + [property: JsonPropertyName("columns")] int Columns); + +public sealed record class EditorColorOption(string Name, string Value) +{ + public SolidColorBrush Brush => new(ParseColor(Value)); + + private static Color ParseColor(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return Colors.Transparent; + } + + var hex = value.Trim().TrimStart('#'); + if (hex.Length == 6) + { + hex = $"FF{hex}"; + } + + if (hex.Length != 8 || !uint.TryParse(hex, System.Globalization.NumberStyles.HexNumber, null, out var argb)) + { + return Colors.Transparent; + } + + return Color.FromArgb( + (byte)((argb >> 24) & 0xFF), + (byte)((argb >> 16) & 0xFF), + (byte)((argb >> 8) & 0xFF), + (byte)(argb & 0xFF)); + } +} + +public sealed record class EditorParagraphStyleOption(string Name, string Tag); + +public sealed record class EditorCapabilities +{ + public IReadOnlyList Fonts { get; init; } = Array.Empty(); + public IReadOnlyList FontSizes { get; init; } = Array.Empty(); + public IReadOnlyList TextColors { get; init; } = Array.Empty(); + public IReadOnlyList HighlightColors { get; init; } = Array.Empty(); + public IReadOnlyList ParagraphStyles { get; init; } = Array.Empty(); + public IReadOnlyList LineHeights { get; init; } = Array.Empty(); + public IReadOnlyList Alignments { get; init; } = Array.Empty(); +} + +public sealed record class EditorState +{ + public bool IsBold { get; init; } + public bool IsItalic { get; init; } + public bool IsUnderline { get; init; } + public bool IsStrikethrough { get; init; } + public bool IsOrderedList { get; init; } + public bool IsUnorderedList { get; init; } + public bool CanIndent { get; init; } = true; + public bool CanOutdent { get; init; } + public bool HasSelection { get; init; } + public bool IsDarkMode { get; init; } + public bool IsBuiltInToolbarVisible { get; init; } + public bool IsSpellCheckEnabled { get; init; } = true; + public EditorTextAlignment Alignment { get; init; } = EditorTextAlignment.Left; + public string? FontFamily { get; init; } + public int? FontSize { get; init; } + public string? ParagraphStyle { get; init; } + public string? TextColor { get; init; } + public string? HighlightColor { get; init; } + public string? LineHeight { get; init; } + public string? LinkUrl { get; init; } + public string? SelectedText { get; init; } +} diff --git a/Wino.Mail.WinUI/Controls/EditorTabbedCommandBarControl.xaml b/Wino.Mail.WinUI/Controls/EditorTabbedCommandBarControl.xaml new file mode 100644 index 00000000..ca4f91a7 --- /dev/null +++ b/Wino.Mail.WinUI/Controls/EditorTabbedCommandBarControl.xaml @@ -0,0 +1,259 @@ + + + + + + + + + + + + + + + + + + + + + + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Wino.Mail.WinUI/Controls/EditorTabbedCommandBarControl.xaml.cs b/Wino.Mail.WinUI/Controls/EditorTabbedCommandBarControl.xaml.cs new file mode 100644 index 00000000..ccc57de4 --- /dev/null +++ b/Wino.Mail.WinUI/Controls/EditorTabbedCommandBarControl.xaml.cs @@ -0,0 +1,418 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace Wino.Mail.Controls; + +public sealed partial class EditorTabbedCommandBarControl : UserControl, IEditorCommandControl +{ + public static readonly DependencyProperty CommandTargetProperty = DependencyProperty.Register( + nameof(CommandTarget), + typeof(IEditorCommandTarget), + typeof(EditorTabbedCommandBarControl), + new PropertyMetadata(null, OnCommandTargetChanged)); + + public static readonly DependencyProperty PaneCustomContentProperty = DependencyProperty.Register( + nameof(PaneCustomContent), + typeof(object), + typeof(EditorTabbedCommandBarControl), + new PropertyMetadata(null)); + + public static readonly DependencyProperty InsertCustomContentProperty = DependencyProperty.Register( + nameof(InsertCustomContent), + typeof(object), + typeof(EditorTabbedCommandBarControl), + new PropertyMetadata(null)); + + public static readonly DependencyProperty OptionsCustomContentProperty = DependencyProperty.Register( + nameof(OptionsCustomContent), + typeof(object), + typeof(EditorTabbedCommandBarControl), + new PropertyMetadata(null)); + + private bool _isApplyingState; + private IEditorCommandTarget? _subscribedTarget; + + public IEditorCommandTarget? CommandTarget + { + get => (IEditorCommandTarget?)GetValue(CommandTargetProperty); + set => SetValue(CommandTargetProperty, value); + } + + public object? PaneCustomContent + { + get => GetValue(PaneCustomContentProperty); + set => SetValue(PaneCustomContentProperty, value); + } + + public object? InsertCustomContent + { + get => GetValue(InsertCustomContentProperty); + set => SetValue(InsertCustomContentProperty, value); + } + + public object? OptionsCustomContent + { + get => GetValue(OptionsCustomContentProperty); + set => SetValue(OptionsCustomContentProperty, value); + } + + public EditorTabbedCommandBarControl() + { + InitializeComponent(); + + Loaded += OnLoaded; + Unloaded += OnUnloaded; + } + + public void AttachCommandTarget(IEditorCommandTarget? target) + { + if (_subscribedTarget == target) + { + return; + } + + if (_subscribedTarget != null) + { + _subscribedTarget.StateChanged -= CommandTarget_StateChanged; + _subscribedTarget.CapabilitiesChanged -= CommandTarget_CapabilitiesChanged; + } + + _subscribedTarget = target; + + if (_subscribedTarget != null) + { + _subscribedTarget.StateChanged += CommandTarget_StateChanged; + _subscribedTarget.CapabilitiesChanged += CommandTarget_CapabilitiesChanged; + ApplyCapabilities(_subscribedTarget.Capabilities); + ApplyState(_subscribedTarget.CurrentState); + } + } + + public void DetachCommandTarget() + { + if (_subscribedTarget == null) + { + return; + } + + _subscribedTarget.StateChanged -= CommandTarget_StateChanged; + _subscribedTarget.CapabilitiesChanged -= CommandTarget_CapabilitiesChanged; + _subscribedTarget = null; + } + + private static void OnCommandTargetChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var control = (EditorTabbedCommandBarControl)d; + control.AttachCommandTarget((IEditorCommandTarget?)e.NewValue); + } + + private void OnLoaded(object sender, RoutedEventArgs e) + { + AttachCommandTarget(CommandTarget); + } + + private void OnUnloaded(object sender, RoutedEventArgs e) + { + DetachCommandTarget(); + } + + private void CommandTarget_StateChanged(object? sender, EditorState e) + { + ApplyState(e); + } + + private void CommandTarget_CapabilitiesChanged(object? sender, EditorCapabilities e) + { + ApplyCapabilities(e); + } + + private void ApplyCapabilities(EditorCapabilities capabilities) + { + FontFamilyComboBox.ItemsSource = capabilities.Fonts; + FontSizeComboBox.ItemsSource = capabilities.FontSizes; + AlignmentComboBox.ItemsSource = capabilities.Alignments; + ParagraphStyleComboBox.ItemsSource = capabilities.ParagraphStyles; + TextColorComboBox.ItemsSource = capabilities.TextColors; + HighlightColorComboBox.ItemsSource = capabilities.HighlightColors; + LineHeightComboBox.ItemsSource = capabilities.LineHeights; + } + + private void ApplyState(EditorState state) + { + _isApplyingState = true; + + BoldButton.IsChecked = state.IsBold; + ItalicButton.IsChecked = state.IsItalic; + UnderlineButton.IsChecked = state.IsUnderline; + StrikeButton.IsChecked = state.IsStrikethrough; + BulletListButton.IsChecked = state.IsUnorderedList; + OrderedListButton.IsChecked = state.IsOrderedList; + IndentButton.IsEnabled = state.CanIndent; + OutdentButton.IsEnabled = state.CanOutdent; + RemoveLinkButton.IsEnabled = !string.IsNullOrWhiteSpace(state.LinkUrl); + RemoveLinkButton.Visibility = RemoveLinkButton.IsEnabled ? Visibility.Visible : Visibility.Collapsed; + BuiltInToolbarButton.IsChecked = state.IsBuiltInToolbarVisible; + SpellCheckButton.IsChecked = state.IsSpellCheckEnabled; + + AlignmentComboBox.SelectedItem = state.Alignment; + FontFamilyComboBox.SelectedItem = MatchStringItem(FontFamilyComboBox.ItemsSource, state.FontFamily); + FontSizeComboBox.SelectedItem = MatchValueItem(FontSizeComboBox.ItemsSource, state.FontSize); + LineHeightComboBox.SelectedItem = MatchStringItem(LineHeightComboBox.ItemsSource, state.LineHeight); + ParagraphStyleComboBox.SelectedItem = MatchParagraphItem(state.ParagraphStyle); + TextColorComboBox.SelectedItem = MatchColorItem(TextColorComboBox.ItemsSource, state.TextColor); + HighlightColorComboBox.SelectedItem = MatchColorItem(HighlightColorComboBox.ItemsSource, state.HighlightColor); + + _isApplyingState = false; + } + + private static object? MatchStringItem(object? itemsSource, string? value) + { + if (itemsSource is IEnumerable strings) + { + return strings.FirstOrDefault(item => string.Equals(item, value, StringComparison.OrdinalIgnoreCase)); + } + + return null; + } + + private static object? MatchValueItem(object? itemsSource, T? value) where T : struct + { + if (!value.HasValue || itemsSource is not IEnumerable values) + { + return null; + } + + foreach (var item in values) + { + if (EqualityComparer.Default.Equals(item, value.Value)) + { + return item; + } + } + + return null; + } + + private object? MatchParagraphItem(string? tag) + { + if (ParagraphStyleComboBox.ItemsSource is not IEnumerable styles) + { + return null; + } + + return styles.FirstOrDefault(item => string.Equals(item.Tag, tag, StringComparison.OrdinalIgnoreCase)); + } + + private static object? MatchColorItem(object? itemsSource, string? value) + { + if (itemsSource is not IEnumerable colors) + { + return null; + } + + return colors.FirstOrDefault(item => string.Equals(item.Value, value ?? string.Empty, StringComparison.OrdinalIgnoreCase)); + } + + private async Task ExecuteAsync(EditorCommand command) + { + if (_isApplyingState || CommandTarget == null) + { + return; + } + + await CommandTarget.ExecuteCommandAsync(command); + } + + private async void BoldButton_Click(object sender, RoutedEventArgs e) => await ExecuteAsync(EditorCommand.ToggleBold()); + private async void ItalicButton_Click(object sender, RoutedEventArgs e) => await ExecuteAsync(EditorCommand.ToggleItalic()); + private async void UnderlineButton_Click(object sender, RoutedEventArgs e) => await ExecuteAsync(EditorCommand.ToggleUnderline()); + private async void StrikeButton_Click(object sender, RoutedEventArgs e) => await ExecuteAsync(EditorCommand.ToggleStrikethrough()); + private async void BulletListButton_Click(object sender, RoutedEventArgs e) => await ExecuteAsync(EditorCommand.ToggleUnorderedList()); + private async void OrderedListButton_Click(object sender, RoutedEventArgs e) => await ExecuteAsync(EditorCommand.ToggleOrderedList()); + private async void IndentButton_Click(object sender, RoutedEventArgs e) => await ExecuteAsync(EditorCommand.Indent()); + private async void OutdentButton_Click(object sender, RoutedEventArgs e) => await ExecuteAsync(EditorCommand.Outdent()); + private async void ImageButton_Click(object sender, RoutedEventArgs e) => await ExecuteAsync(EditorCommand.InsertImage()); + private async void EmojiButton_Click(object sender, RoutedEventArgs e) => await ExecuteAsync(EditorCommand.InsertEmoji()); + private async void RemoveLinkButton_Click(object sender, RoutedEventArgs e) => await ExecuteAsync(EditorCommand.RemoveLink()); + + private async void AlignmentComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_isApplyingState || AlignmentComboBox.SelectedItem is not EditorTextAlignment alignment) + { + return; + } + + await ExecuteAsync(EditorCommand.SetAlignment(alignment)); + } + + private async void FontFamilyComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_isApplyingState || FontFamilyComboBox.SelectedItem is not string fontFamily || string.IsNullOrWhiteSpace(fontFamily)) + { + return; + } + + await ExecuteAsync(EditorCommand.SetFontFamily(fontFamily)); + } + + private async void FontSizeComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_isApplyingState || FontSizeComboBox.SelectedItem is not int fontSize) + { + return; + } + + await ExecuteAsync(EditorCommand.SetFontSize(fontSize)); + } + + private async void ParagraphStyleComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_isApplyingState || ParagraphStyleComboBox.SelectedItem is not EditorParagraphStyleOption paragraphStyle) + { + return; + } + + await ExecuteAsync(EditorCommand.SetParagraphStyle(paragraphStyle.Tag)); + } + + private async void TextColorComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_isApplyingState || TextColorComboBox.SelectedItem is not EditorColorOption color) + { + return; + } + + await ExecuteAsync(EditorCommand.SetTextColor(color.Value)); + } + + private async void HighlightColorComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_isApplyingState || HighlightColorComboBox.SelectedItem is not EditorColorOption color) + { + return; + } + + await ExecuteAsync(EditorCommand.SetHighlightColor(color.Value)); + } + + private async void LineHeightComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_isApplyingState || LineHeightComboBox.SelectedItem is not string lineHeight || string.IsNullOrWhiteSpace(lineHeight)) + { + return; + } + + await ExecuteAsync(EditorCommand.SetLineHeight(lineHeight)); + } + + private async void BuiltInToolbarButton_Click(object sender, RoutedEventArgs e) + { + await ExecuteAsync(EditorCommand.ToggleBuiltInToolbar(BuiltInToolbarButton.IsChecked == true)); + } + + private async void SpellCheckButton_Click(object sender, RoutedEventArgs e) + { + await ExecuteAsync(EditorCommand.ToggleSpellCheck(SpellCheckButton.IsChecked == true)); + } + + private async void LinkButton_Click(object sender, RoutedEventArgs e) + { + if (CommandTarget == null) + { + return; + } + + var currentState = CommandTarget.CurrentState; + var urlTextBox = new TextBox + { + Header = "URL", + Text = currentState.LinkUrl ?? string.Empty, + PlaceholderText = "https://example.com" + }; + var textTextBox = new TextBox + { + Header = "Text", + Text = currentState.SelectedText ?? string.Empty, + PlaceholderText = "Link text" + }; + var openInNewWindow = new CheckBox + { + Content = "Open in new window", + IsChecked = true + }; + + var dialog = new ContentDialog + { + XamlRoot = XamlRoot, + Title = "Insert link", + PrimaryButtonText = "Apply", + CloseButtonText = "Cancel", + DefaultButton = ContentDialogButton.Primary, + Content = new StackPanel + { + Spacing = 12, + Children = + { + urlTextBox, + textTextBox, + openInNewWindow + } + } + }; + + if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(urlTextBox.Text)) + { + await ExecuteAsync(EditorCommand.InsertLink(new EditorLinkCommandArgs(urlTextBox.Text.Trim(), textTextBox.Text.Trim(), openInNewWindow.IsChecked == true))); + } + } + + private async void TableButton_Click(object sender, RoutedEventArgs e) + { + var rowsBox = new NumberBox + { + Header = "Rows", + Minimum = 1, + Maximum = 10, + SmallChange = 1, + Value = 2 + }; + var columnsBox = new NumberBox + { + Header = "Columns", + Minimum = 1, + Maximum = 10, + SmallChange = 1, + Value = 2 + }; + + var dialog = new ContentDialog + { + XamlRoot = XamlRoot, + Title = "Insert table", + PrimaryButtonText = "Insert", + CloseButtonText = "Cancel", + DefaultButton = ContentDialogButton.Primary, + Content = new StackPanel + { + Spacing = 12, + Children = + { + rowsBox, + columnsBox + } + } + }; + + if (await dialog.ShowAsync() == ContentDialogResult.Primary) + { + await ExecuteAsync(EditorCommand.InsertTable(new EditorTableCommandArgs((int)Math.Max(1, rowsBox.Value), (int)Math.Max(1, columnsBox.Value)))); + } + } +} + + + diff --git a/Wino.Mail.WinUI/Controls/WebViewEditorControl.cs b/Wino.Mail.WinUI/Controls/WebViewEditorControl.cs index 561c4c19..ab7d0bcc 100644 --- a/Wino.Mail.WinUI/Controls/WebViewEditorControl.cs +++ b/Wino.Mail.WinUI/Controls/WebViewEditorControl.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Text.Json; +using System.Text.Json.Serialization; using System.Threading.Tasks; using CommunityToolkit.WinUI; using Microsoft.Extensions.DependencyInjection; @@ -12,13 +13,50 @@ using Wino.Core.Domain; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models; using Wino.Core.Domain.Models.Reader; -using Wino.Mail.WinUI.Extensions; using Wino.Mail.WinUI; +using Wino.Mail.WinUI.Extensions; namespace Wino.Mail.Controls; -public sealed partial class WebViewEditorControl : Control, IDisposable +public sealed partial class WebViewEditorControl : Control, IDisposable, IEditorCommandTarget { + private const string PART_WebView = "WebView"; + + private static readonly IReadOnlyList DefaultFontSizes = [8, 9, 10, 11, 12, 14, 16, 18, 20, 24, 28, 32, 36]; + private static readonly IReadOnlyList DefaultTextColors = + [ + new("Default", string.Empty), + new("Black", "#000000"), + new("Gray", "#666666"), + new("Red", "#c62828"), + new("Orange", "#ef6c00"), + new("Yellow", "#f9a825"), + new("Green", "#2e7d32"), + new("Blue", "#1565c0"), + new("Purple", "#6a1b9a") + ]; + private static readonly IReadOnlyList DefaultHighlightColors = + [ + new("None", string.Empty), + new("Yellow", "#fff59d"), + new("Green", "#c8e6c9"), + new("Blue", "#bbdefb"), + new("Pink", "#f8bbd0"), + new("Orange", "#ffe0b2") + ]; + private static readonly IReadOnlyList DefaultParagraphStyles = + [ + new("Paragraph", "div"), + new("Heading 1", "h1"), + new("Heading 2", "h2"), + new("Heading 3", "h3"), + new("Quote", "blockquote"), + new("Code", "pre") + ]; + private static readonly IReadOnlyList DefaultLineHeights = ["normal", "1", "1.15", "1.5", "2"]; + private static readonly IReadOnlyList DefaultAlignments = + [EditorTextAlignment.Left, EditorTextAlignment.Center, EditorTextAlignment.Right, EditorTextAlignment.Justify]; + private readonly INativeAppService _nativeAppService = App.Current.Services.GetService()!; private readonly IFontService _fontService = App.Current.Services.GetService()!; private readonly IPreferencesService _preferencesService = App.Current.Services.GetService()!; @@ -27,150 +65,158 @@ public sealed partial class WebViewEditorControl : Control, IDisposable public partial bool IsEditorDarkMode { get; set; } async partial void OnIsEditorDarkModeChanged(bool newValue) { + UpdateState(GetCurrentStateOrDefault() with { IsDarkMode = newValue }); await UpdateEditorThemeAsync(); } - [GeneratedDependencyProperty] - public partial bool IsEditorBold { get; set; } - private bool _isEditorBoldInternal; - async partial void OnIsEditorBoldChanged(bool newValue) - { - if (newValue != _isEditorBoldInternal) - { - await _chromium.ExecuteScriptFunctionSafeAsync("editor.execCommand", JsonSerializer.Serialize("bold", BasicTypesJsonContext.Default.String)); - } - } - - [GeneratedDependencyProperty] - public partial bool IsEditorItalic { get; set; } - private bool _isEditorItalicInternal; - async partial void OnIsEditorItalicChanged(bool newValue) - { - if (newValue != _isEditorItalicInternal) - { - await _chromium.ExecuteScriptFunctionSafeAsync("editor.execCommand", JsonSerializer.Serialize("italic", BasicTypesJsonContext.Default.String)); - } - } - - [GeneratedDependencyProperty] - public partial bool IsEditorUnderline { get; set; } - private bool _isEditorUnderlineInternal; - async partial void OnIsEditorUnderlineChanged(bool newValue) - { - if (newValue != _isEditorUnderlineInternal) - { - await _chromium.ExecuteScriptFunctionSafeAsync("editor.execCommand", JsonSerializer.Serialize("underline", BasicTypesJsonContext.Default.String)); - } - } - - [GeneratedDependencyProperty] - public partial bool IsEditorStrikethrough { get; set; } - private bool _isEditorStrikethroughInternal; - async partial void OnIsEditorStrikethroughChanged(bool newValue) - { - if (newValue != _isEditorStrikethroughInternal) - { - await _chromium.ExecuteScriptFunctionSafeAsync("editor.execCommand", JsonSerializer.Serialize("strikethrough", BasicTypesJsonContext.Default.String)); - } - } - - [GeneratedDependencyProperty] - public partial bool IsEditorOl { get; set; } - private bool _isEditorOlInternal; - async partial void OnIsEditorOlChanged(bool newValue) - { - if (newValue != _isEditorOlInternal) - { - await _chromium.ExecuteScriptFunctionSafeAsync("editor.execCommand", JsonSerializer.Serialize("insertorderedlist", BasicTypesJsonContext.Default.String)); - } - } - - [GeneratedDependencyProperty] - public partial bool IsEditorUl { get; set; } - private bool _isEditorUlInternal; - async partial void OnIsEditorUlChanged(bool newValue) - { - if (newValue != _isEditorUlInternal) - { - await _chromium.ExecuteScriptFunctionSafeAsync("editor.execCommand", JsonSerializer.Serialize("insertunorderedlist", BasicTypesJsonContext.Default.String)); - } - } - - [GeneratedDependencyProperty(DefaultValue = true)] - public partial bool IsEditorIndentEnabled { get; private set; } - - [GeneratedDependencyProperty] - public partial bool IsEditorOutdentEnabled { get; private set; } - - [GeneratedDependencyProperty] - public partial int EditorAlignmentSelectedIndex { get; set; } - private int _editorAlignmentSelectedIndexInternal; - async partial void OnEditorAlignmentSelectedIndexChanged(int newValue) - { - if (newValue != _editorAlignmentSelectedIndexInternal) - { - var alignmentAction = newValue switch - { - 0 => "justifyleft", - 1 => "justifycenter", - 2 => "justifyright", - 3 => "justifyfull", - _ => throw new ArgumentOutOfRangeException(nameof(newValue)) - }; - - await _chromium.ExecuteScriptFunctionSafeAsync("editor.execCommand", JsonSerializer.Serialize(alignmentAction, BasicTypesJsonContext.Default.String)); - } - } - [GeneratedDependencyProperty] public partial bool IsEditorWebViewEditor { get; set; } - async partial void OnIsEditorWebViewEditorChanged(bool newValue) { - await _chromium.ExecuteScriptFunctionSafeAsync("toggleToolbar", JsonSerializer.Serialize(newValue, BasicTypesJsonContext.Default.Boolean)); + UpdateState(GetCurrentStateOrDefault() with { IsBuiltInToolbarVisible = newValue }); + await ApplyBuiltInToolbarVisibilityAsync(); } - private const string PART_WebView = "WebView"; - private WebView2 _chromium = null!; + private WebView2? _chromium; private bool _disposedValue; private bool? _lastAppliedDarkTheme; private readonly TaskCompletionSource _domLoadedTask = new(); + private readonly TaskCompletionSource _editorReadyTask = new(); + private readonly object _editorInitializationLock = new(); + private Task? _editorInitializationTask; + + public event EventHandler? StateChanged; + public event EventHandler? CapabilitiesChanged; + + public EditorState CurrentState { get; private set; } + public EditorCapabilities Capabilities { get; private set; } public WebViewEditorControl() { - this.DefaultStyleKey = typeof(WebViewEditorControl); + DefaultStyleKey = typeof(WebViewEditorControl); IsEditorDarkMode = WinoApplication.Current.UnderlyingThemeService.IsUnderlyingThemeDark(); + Capabilities = BuildCapabilities(_fontService.GetFonts()); + CurrentState = CreateDefaultState(); } protected override async void OnApplyTemplate() { base.OnApplyTemplate(); - _chromium = (GetTemplateChild(PART_WebView) as WebView2)!; + var newWebView = GetTemplateChild(PART_WebView) as WebView2; + if (newWebView == null) + { + return; + } - await InitializeComponent(); + if (_chromium != null && !ReferenceEquals(_chromium, newWebView)) + { + DetachChromium(_chromium, closeWebView: false); + } + + _chromium = newWebView; + await InitializeWebViewAsync(); } - private async Task InitializeComponent() + public async Task ExecuteCommandAsync(EditorCommand command) { - WebViewExtensions.EnsureWebView2Environment(); + ObjectDisposedException.ThrowIf(_disposedValue, this); + await EnsureEditorReadyAsync(); - _chromium.CoreWebView2Initialized -= ChromiumInitialized; - _chromium.CoreWebView2Initialized += ChromiumInitialized; + if (_chromium == null) + { + return; + } - await _chromium.EnsureCoreWebView2Async(); + switch (command.Kind) + { + case EditorCommandKind.ToggleBold: + await ExecuteEditorCommandAsync("bold"); + break; + case EditorCommandKind.ToggleItalic: + await ExecuteEditorCommandAsync("italic"); + break; + case EditorCommandKind.ToggleUnderline: + await ExecuteEditorCommandAsync("underline"); + break; + case EditorCommandKind.ToggleStrikethrough: + await ExecuteEditorCommandAsync("strikethrough"); + break; + case EditorCommandKind.ToggleOrderedList: + await ExecuteEditorCommandAsync("insertorderedlist"); + break; + case EditorCommandKind.ToggleUnorderedList: + await ExecuteEditorCommandAsync("insertunorderedlist"); + break; + case EditorCommandKind.Indent: + await ExecuteEditorCommandAsync("indent"); + break; + case EditorCommandKind.Outdent: + await ExecuteEditorCommandAsync("outdent"); + break; + case EditorCommandKind.SetAlignment when command.Value is EditorTextAlignment alignment: + await ExecuteEditorCommandAsync(alignment switch + { + EditorTextAlignment.Left => "justifyleft", + EditorTextAlignment.Center => "justifycenter", + EditorTextAlignment.Right => "justifyright", + EditorTextAlignment.Justify => "justifyfull", + _ => "justifyleft" + }); + break; + case EditorCommandKind.SetFontFamily when command.Value is string fontFamily: + await _chromium.ExecuteScriptFunctionSafeAsync("setFontFamily", JsonSerializer.Serialize(fontFamily, BasicTypesJsonContext.Default.String)); + break; + case EditorCommandKind.SetFontSize when command.Value is int fontSize: + await _chromium.ExecuteScriptFunctionSafeAsync("setFontSize", JsonSerializer.Serialize(fontSize, BasicTypesJsonContext.Default.Int32)); + break; + case EditorCommandKind.SetParagraphStyle when command.Value is string paragraphStyle: + await _chromium.ExecuteScriptFunctionSafeAsync("setParagraphStyle", JsonSerializer.Serialize(paragraphStyle, BasicTypesJsonContext.Default.String)); + break; + case EditorCommandKind.SetTextColor when command.Value is string textColor: + await _chromium.ExecuteScriptFunctionSafeAsync("setTextColor", JsonSerializer.Serialize(textColor, BasicTypesJsonContext.Default.String)); + break; + case EditorCommandKind.SetHighlightColor when command.Value is string highlightColor: + await _chromium.ExecuteScriptFunctionSafeAsync("setHighlightColor", JsonSerializer.Serialize(highlightColor, BasicTypesJsonContext.Default.String)); + break; + case EditorCommandKind.SetLineHeight when command.Value is string lineHeight: + await _chromium.ExecuteScriptFunctionSafeAsync("setLineHeight", JsonSerializer.Serialize(lineHeight, BasicTypesJsonContext.Default.String)); + break; + case EditorCommandKind.InsertImage: + await ShowImagePickerAsync(); + return; + case EditorCommandKind.InsertLink when command.Value is EditorLinkCommandArgs linkArgs: + await _chromium.ExecuteScriptFunctionSafeAsync("upsertLink", SerializeLinkArgs(linkArgs)); + break; + case EditorCommandKind.RemoveLink: + await _chromium.ExecuteScriptFunctionSafeAsync("removeLink"); + break; + case EditorCommandKind.InsertEmoji: + await ShowEmojiPickerAsync(); + return; + case EditorCommandKind.InsertTable when command.Value is EditorTableCommandArgs tableArgs: + await _chromium.ExecuteScriptFunctionSafeAsync("insertTableHtml", SerializeTableArgs(tableArgs)); + break; + case EditorCommandKind.ToggleBuiltInToolbar when command.Value is bool isToolbarVisible: + IsEditorWebViewEditor = isToolbarVisible; + return; + case EditorCommandKind.ToggleTheme when command.Value is bool isDarkMode: + IsEditorDarkMode = isDarkMode; + return; + case EditorCommandKind.ToggleSpellCheck when command.Value is bool isSpellCheckEnabled: + await _chromium.ExecuteScriptFunctionSafeAsync("setSpellCheck", JsonSerializer.Serialize(isSpellCheckEnabled, BasicTypesJsonContext.Default.Boolean)); + break; + default: + throw new InvalidOperationException($"Unsupported editor command: {command.Kind}"); + } + + await RefreshStateAsync(); } - public async void EditorIndentAsync() - { - await _chromium.ExecuteScriptFunctionSafeAsync("editor.execCommand", JsonSerializer.Serialize("indent", BasicTypesJsonContext.Default.String)); - } + public async Task EditorIndentAsync() => await ExecuteCommandAsync(EditorCommand.Indent()); - public async void EditorOutdentAsync() - { - await _chromium.ExecuteScriptFunctionSafeAsync("editor.execCommand", JsonSerializer.Serialize("outdent", BasicTypesJsonContext.Default.String)); - } + public async Task EditorOutdentAsync() => await ExecuteCommandAsync(EditorCommand.Outdent()); public void ToggleEditorTheme() { @@ -179,98 +225,77 @@ public sealed partial class WebViewEditorControl : Control, IDisposable public async Task GetHtmlBodyAsync() { - var editorContent = await _chromium.ExecuteScriptFunctionSafeAsync("GetHTMLContent"); + await EnsureEditorReadyAsync(); + if (_chromium == null) + { + return null; + } + + var editorContent = await _chromium.ExecuteScriptFunctionSafeAsync("GetHTMLContent"); return JsonSerializer.Deserialize(editorContent, BasicTypesJsonContext.Default.String); } + public async Task ShowImagePickerAsync() + { + await EnsureEditorReadyAsync(); + if (_chromium != null) + { + await _chromium.ExecuteScriptFunctionSafeAsync("imageInput.click"); + } + } + public async void ShowImagePicker() { - await _chromium.ExecuteScriptFunctionSafeAsync("imageInput.click"); + await ShowImagePickerAsync(); } public async Task InsertImagesAsync(List images) { - await _chromium.ExecuteScriptFunctionSafeAsync("insertImages", JsonSerializer.Serialize(images, DomainModelsJsonContext.Default.ListImageInfo)); + await EnsureEditorReadyAsync(); + if (_chromium != null) + { + await _chromium.ExecuteScriptFunctionSafeAsync("insertImages", JsonSerializer.Serialize(images, DomainModelsJsonContext.Default.ListImageInfo)); + await RefreshStateAsync(); + } + } + + public async Task ShowEmojiPickerAsync() + { + CoreInputView.GetForCurrentView().TryShow(CoreInputViewKind.Emoji); + await FocusEditorAsync(focusControlAsWell: true); } public async void ShowEmojiPicker() { - CoreInputView.GetForCurrentView().TryShow(CoreInputViewKind.Emoji); - - await FocusEditorAsync(focusControlAsWell: true); + await ShowEmojiPickerAsync(); } - public WebView2 GetUnderlyingWebView() => _chromium; + public WebView2 GetUnderlyingWebView() => _chromium!; public async Task RenderHtmlAsync(string htmlBody) { - await _domLoadedTask.Task; + await EnsureEditorReadyAsync(); - await UpdateEditorThemeAsync(); - await InitializeEditorAsync(); - - await _chromium.ExecuteScriptFunctionAsync("RenderHTML", parameters: JsonSerializer.Serialize(string.IsNullOrEmpty(htmlBody) ? " " : htmlBody, BasicTypesJsonContext.Default.String)); - } - - private async Task InitializeEditorAsync() - { - var fonts = _fontService.GetFonts(); - var composerFont = _preferencesService.ComposerFont; - int composerFontSize = _preferencesService.ComposerFontSize; - var readerFont = _preferencesService.ReaderFont; - int readerFontSize = _preferencesService.ReaderFontSize; - return await _chromium.ExecuteScriptFunctionAsync("initializeJodit", - JsonSerializer.Serialize(fonts, BasicTypesJsonContext.Default.ListString), - JsonSerializer.Serialize(composerFont, BasicTypesJsonContext.Default.String), - JsonSerializer.Serialize(composerFontSize, BasicTypesJsonContext.Default.Int32), - JsonSerializer.Serialize(readerFont, BasicTypesJsonContext.Default.String), - JsonSerializer.Serialize(readerFontSize, BasicTypesJsonContext.Default.Int32)); - } - - private async void ChromiumInitialized(WebView2 sender, CoreWebView2InitializedEventArgs args) - { - var editorBundlePath = (await _nativeAppService.GetEditorBundlePathAsync()).Replace("editor.html", string.Empty); - - _chromium.CoreWebView2.SetVirtualHostNameToFolderMapping("app.editor", editorBundlePath, CoreWebView2HostResourceAccessKind.Allow); - _chromium.Source = new Uri("https://app.editor/editor.html"); - - _chromium.CoreWebView2.DOMContentLoaded -= DomLoaded; - _chromium.CoreWebView2.DOMContentLoaded += DomLoaded; - - _chromium.CoreWebView2.WebMessageReceived -= ScriptMessageReceived; - _chromium.CoreWebView2.WebMessageReceived += ScriptMessageReceived; - } - - public async Task UpdateEditorThemeAsync(bool force = false) - { - await _domLoadedTask.Task; - - var isDark = IsEditorDarkMode; - - if (!force && _lastAppliedDarkTheme == isDark) return; - - _lastAppliedDarkTheme = isDark; - - if (isDark) + if (_chromium == null) { - _chromium.CoreWebView2.Profile.PreferredColorScheme = CoreWebView2PreferredColorScheme.Dark; - await _chromium.ExecuteScriptFunctionSafeAsync("SetDarkEditor"); - } - else - { - _chromium.CoreWebView2.Profile.PreferredColorScheme = CoreWebView2PreferredColorScheme.Light; - await _chromium.ExecuteScriptFunctionSafeAsync("SetLightEditor"); + return; } + + await _chromium.ExecuteScriptFunctionAsync("RenderHTML", JsonSerializer.Serialize(string.IsNullOrEmpty(htmlBody) ? " " : htmlBody, BasicTypesJsonContext.Default.String)); + await RefreshStateAsync(); } - /// - /// Places the cursor in the composer. - /// - /// Whether control itself should be focused as well or not. public async Task FocusEditorAsync(bool focusControlAsWell) { - await _chromium.ExecuteScriptSafeAsync("editor.selection.setCursorIn(editor.editor.firstChild, true)"); + await EnsureEditorReadyAsync(); + + if (_chromium == null) + { + return; + } + + await _chromium.ExecuteScriptSafeAsync("focusEditor();"); if (focusControlAsWell) { @@ -279,66 +304,392 @@ public sealed partial class WebViewEditorControl : Control, IDisposable } } - private void ScriptMessageReceived(CoreWebView2 sender, CoreWebView2WebMessageReceivedEventArgs args) + public async Task UpdateEditorThemeAsync(bool force = false) { - var change = JsonSerializer.Deserialize(args.WebMessageAsJson, DomainModelsJsonContext.Default.WebViewMessage); + if (_chromium?.CoreWebView2 == null) + { + return; + } - if (change == null) return; + if (!_editorReadyTask.Task.IsCompleted) + { + return; + } - if (change.Type == "bold") + var isDark = IsEditorDarkMode; + if (!force && _lastAppliedDarkTheme == isDark) { - _isEditorBoldInternal = change.Value == "true"; - IsEditorBold = _isEditorBoldInternal; + return; } - else if (change.Type == "italic") + + _lastAppliedDarkTheme = isDark; + _chromium.CoreWebView2.Profile.PreferredColorScheme = isDark + ? CoreWebView2PreferredColorScheme.Dark + : CoreWebView2PreferredColorScheme.Light; + + await _chromium.ExecuteScriptFunctionSafeAsync(isDark ? "SetDarkEditor" : "SetLightEditor"); + UpdateState(CurrentState with { IsDarkMode = isDark }); + } + + private async Task InitializeWebViewAsync() + { + if (_chromium == null) { - _isEditorItalicInternal = change.Value == "true"; - IsEditorItalic = _isEditorItalicInternal; + return; } - else if (change.Type == "underline") + + WebViewExtensions.EnsureWebView2Environment(); + _chromium.CoreWebView2Initialized -= ChromiumInitialized; + _chromium.CoreWebView2Initialized += ChromiumInitialized; + await _chromium.EnsureCoreWebView2Async(); + } + + private Task EnsureEditorReadyAsync() + { + if (_chromium == null || _disposedValue) { - _isEditorUnderlineInternal = change.Value == "true"; - IsEditorUnderline = _isEditorUnderlineInternal; + return Task.CompletedTask; } - else if (change.Type == "strikethrough") + + return EnsureEditorReadyCoreAsync(); + } + + private async Task EnsureEditorReadyCoreAsync() + { + await EnsureEditorInitializedAsync(); + await _editorReadyTask.Task; + } + + private Task EnsureEditorInitializedAsync() + { + lock (_editorInitializationLock) { - _isEditorStrikethroughInternal = change.Value == "true"; - IsEditorStrikethrough = _isEditorStrikethroughInternal; - } - else if (change.Type == "ol") - { - _isEditorOlInternal = change.Value == "true"; - IsEditorOl = _isEditorOlInternal; - } - else if (change.Type == "ul") - { - _isEditorUlInternal = change.Value == "true"; - IsEditorUl = _isEditorUlInternal; - } - else if (change.Type == "indent") - { - IsEditorIndentEnabled = change.Value != "disabled"; - } - else if (change.Type == "outdent") - { - IsEditorOutdentEnabled = change.Value != "disabled"; - } - else if (change.Type == "alignment") - { - var parsedValue = change.Value switch - { - "jodit-icon_left" => 0, - "jodit-icon_center" => 1, - "jodit-icon_right" => 2, - "jodit-icon_justify" => 3, - _ => 0 - }; - _editorAlignmentSelectedIndexInternal = parsedValue; - EditorAlignmentSelectedIndex = _editorAlignmentSelectedIndexInternal; + _editorInitializationTask ??= EnsureEditorInitializedCoreAsync(); + return _editorInitializationTask; } } - private void DomLoaded(CoreWebView2 sender, CoreWebView2DOMContentLoadedEventArgs args) => _domLoadedTask.TrySetResult(true); + private async Task EnsureEditorInitializedCoreAsync() + { + if (_chromium == null || _disposedValue) + { + _editorReadyTask.TrySetResult(true); + return; + } + + await _domLoadedTask.Task; + + if (_chromium == null || _disposedValue) + { + _editorReadyTask.TrySetResult(true); + return; + } + + var fonts = _fontService.GetFonts(); + await _chromium.ExecuteScriptFunctionAsync( + "initializeJodit", + JsonSerializer.Serialize(fonts, BasicTypesJsonContext.Default.ListString), + JsonSerializer.Serialize(_preferencesService.ComposerFont, BasicTypesJsonContext.Default.String), + JsonSerializer.Serialize(_preferencesService.ComposerFontSize, BasicTypesJsonContext.Default.Int32), + JsonSerializer.Serialize(_preferencesService.ReaderFont, BasicTypesJsonContext.Default.String), + JsonSerializer.Serialize(_preferencesService.ReaderFontSize, BasicTypesJsonContext.Default.Int32)); + + UpdateCapabilities(BuildCapabilities(fonts)); + _editorReadyTask.TrySetResult(true); + + await UpdateEditorThemeAsync(force: true); + await ApplyBuiltInToolbarVisibilityAsync(force: true); + await RefreshStateAsync(); + } + + private async Task ExecuteEditorCommandAsync(string command) + { + if (_chromium != null) + { + await _chromium.ExecuteScriptFunctionSafeAsync("executeEditorCommand", JsonSerializer.Serialize(command, BasicTypesJsonContext.Default.String)); + } + } + + private async Task ApplyBuiltInToolbarVisibilityAsync(bool force = false) + { + if (_chromium == null) + { + return; + } + + if (!_editorReadyTask.Task.IsCompleted) + { + return; + } + + await _chromium.ExecuteScriptFunctionSafeAsync("toggleToolbar", JsonSerializer.Serialize(IsEditorWebViewEditor, BasicTypesJsonContext.Default.Boolean)); + UpdateState(CurrentState with { IsBuiltInToolbarVisible = IsEditorWebViewEditor }); + } + + private async Task RefreshStateAsync() + { + if (_chromium == null || !_editorReadyTask.Task.IsCompleted) + { + return; + } + + var stateResult = await _chromium.ExecuteScriptFunctionSafeAsync("getEditorState"); + if (string.IsNullOrWhiteSpace(stateResult)) + { + return; + } + + var snapshot = DeserializeStateSnapshot(stateResult); + if (snapshot != null) + { + UpdateState(MapState(snapshot)); + } + } + + private void ChromiumInitialized(WebView2 sender, CoreWebView2InitializedEventArgs args) + { + if (args.Exception != null || _chromium?.CoreWebView2 == null) + { + return; + } + + _ = ConfigureChromiumAsync(); + } + + private async Task ConfigureChromiumAsync() + { + if (_chromium?.CoreWebView2 == null) + { + return; + } + + var editorBundlePath = (await _nativeAppService.GetEditorBundlePathAsync()).Replace("editor.html", string.Empty, StringComparison.OrdinalIgnoreCase); + _chromium.CoreWebView2.SetVirtualHostNameToFolderMapping("app.editor", editorBundlePath, CoreWebView2HostResourceAccessKind.Allow); + _chromium.CoreWebView2.DOMContentLoaded -= DomLoaded; + _chromium.CoreWebView2.DOMContentLoaded += DomLoaded; + _chromium.CoreWebView2.WebMessageReceived -= ScriptMessageReceived; + _chromium.CoreWebView2.WebMessageReceived += ScriptMessageReceived; + _chromium.Source = new Uri("https://app.editor/editor.html"); + } + + private void DomLoaded(CoreWebView2 sender, CoreWebView2DOMContentLoadedEventArgs args) + { + _domLoadedTask.TrySetResult(true); + _ = EnsureEditorInitializedAsync(); + } + + private void ScriptMessageReceived(CoreWebView2 sender, CoreWebView2WebMessageReceivedEventArgs args) + { + using var document = JsonDocument.Parse(args.WebMessageAsJson); + if (!document.RootElement.TryGetProperty("type", out var typeElement) || + !string.Equals(typeElement.GetString(), "state", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + if (document.RootElement.TryGetProperty("state", out var stateElement)) + { + var snapshot = DeserializeStateSnapshot(stateElement); + if (snapshot != null) + { + UpdateState(MapState(snapshot)); + } + } + } + + private EditorState MapState(EditorStateSnapshot snapshot) + { + return new EditorState + { + IsBold = snapshot.IsBold, + IsItalic = snapshot.IsItalic, + IsUnderline = snapshot.IsUnderline, + IsStrikethrough = snapshot.IsStrikethrough, + IsOrderedList = snapshot.IsOrderedList, + IsUnorderedList = snapshot.IsUnorderedList, + CanIndent = snapshot.CanIndent, + CanOutdent = snapshot.CanOutdent, + HasSelection = snapshot.HasSelection, + IsDarkMode = IsEditorDarkMode, + IsBuiltInToolbarVisible = IsEditorWebViewEditor, + IsSpellCheckEnabled = snapshot.IsSpellCheckEnabled, + Alignment = ParseAlignment(snapshot.Alignment), + FontFamily = string.IsNullOrWhiteSpace(snapshot.FontFamily) ? _preferencesService.ComposerFont : snapshot.FontFamily, + FontSize = snapshot.FontSize ?? _preferencesService.ComposerFontSize, + ParagraphStyle = string.IsNullOrWhiteSpace(snapshot.ParagraphStyle) ? "div" : snapshot.ParagraphStyle, + TextColor = snapshot.TextColor ?? string.Empty, + HighlightColor = snapshot.HighlightColor ?? string.Empty, + LineHeight = string.IsNullOrWhiteSpace(snapshot.LineHeight) ? "normal" : snapshot.LineHeight, + LinkUrl = snapshot.LinkUrl, + SelectedText = snapshot.SelectedText + }; + } + + private static EditorTextAlignment ParseAlignment(string? alignment) + { + return alignment?.ToLowerInvariant() switch + { + "center" => EditorTextAlignment.Center, + "right" => EditorTextAlignment.Right, + "justify" => EditorTextAlignment.Justify, + _ => EditorTextAlignment.Left + }; + } + + private static string SerializeLinkArgs(EditorLinkCommandArgs args) + { + var url = JsonSerializer.Serialize(args.Url, BasicTypesJsonContext.Default.String); + var text = JsonSerializer.Serialize(args.Text, BasicTypesJsonContext.Default.String); + var openInNewWindow = JsonSerializer.Serialize(args.OpenInNewWindow, BasicTypesJsonContext.Default.Boolean); + return $"{{\"url\":{url},\"text\":{text},\"openInNewWindow\":{openInNewWindow}}}"; + } + + private static string SerializeTableArgs(EditorTableCommandArgs args) + { + var rows = JsonSerializer.Serialize(args.Rows, BasicTypesJsonContext.Default.Int32); + var columns = JsonSerializer.Serialize(args.Columns, BasicTypesJsonContext.Default.Int32); + return $"{{\"rows\":{rows},\"columns\":{columns}}}"; + } + + private static EditorStateSnapshot? DeserializeStateSnapshot(string json) + { + using var document = JsonDocument.Parse(json); + return DeserializeStateSnapshot(document.RootElement); + } + + private static EditorStateSnapshot? DeserializeStateSnapshot(JsonElement element) + { + if (element.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + { + return null; + } + + return new EditorStateSnapshot + { + IsBold = GetBoolean(element, "bold"), + IsItalic = GetBoolean(element, "italic"), + IsUnderline = GetBoolean(element, "underline"), + IsStrikethrough = GetBoolean(element, "strikethrough"), + IsOrderedList = GetBoolean(element, "orderedList"), + IsUnorderedList = GetBoolean(element, "unorderedList"), + CanIndent = GetBoolean(element, "canIndent", true), + CanOutdent = GetBoolean(element, "canOutdent"), + HasSelection = GetBoolean(element, "hasSelection"), + IsSpellCheckEnabled = GetBoolean(element, "isSpellCheckEnabled", true), + Alignment = GetString(element, "alignment"), + FontFamily = GetString(element, "fontFamily"), + FontSize = GetNullableInt32(element, "fontSize"), + ParagraphStyle = GetString(element, "paragraphStyle"), + TextColor = GetString(element, "textColor"), + HighlightColor = GetString(element, "highlightColor"), + LineHeight = GetString(element, "lineHeight"), + LinkUrl = GetString(element, "linkUrl"), + SelectedText = GetString(element, "selectedText") + }; + } + + private static bool GetBoolean(JsonElement element, string propertyName, bool defaultValue = false) + { + if (element.TryGetProperty(propertyName, out var valueElement) && valueElement.ValueKind is JsonValueKind.True or JsonValueKind.False) + { + return valueElement.GetBoolean(); + } + + return defaultValue; + } + + private static int? GetNullableInt32(JsonElement element, string propertyName) + { + if (element.TryGetProperty(propertyName, out var valueElement) && valueElement.ValueKind == JsonValueKind.Number && valueElement.TryGetInt32(out var value)) + { + return value; + } + + return null; + } + + private static string? GetString(JsonElement element, string propertyName) + { + if (element.TryGetProperty(propertyName, out var valueElement) && valueElement.ValueKind == JsonValueKind.String) + { + return valueElement.GetString(); + } + + return null; + } + + private static EditorCapabilities BuildCapabilities(IReadOnlyList fonts) + { + return new EditorCapabilities + { + Fonts = fonts, + FontSizes = DefaultFontSizes, + TextColors = DefaultTextColors, + HighlightColors = DefaultHighlightColors, + ParagraphStyles = DefaultParagraphStyles, + LineHeights = DefaultLineHeights, + Alignments = DefaultAlignments + }; + } + + private EditorState GetCurrentStateOrDefault() + { + return CurrentState ?? CreateDefaultState(); + } + + private EditorState CreateDefaultState() + { + return new EditorState + { + IsDarkMode = IsEditorDarkMode, + IsBuiltInToolbarVisible = IsEditorWebViewEditor, + IsSpellCheckEnabled = true, + FontFamily = _preferencesService.ComposerFont, + FontSize = _preferencesService.ComposerFontSize, + ParagraphStyle = "div", + TextColor = string.Empty, + HighlightColor = string.Empty, + LineHeight = "normal" + }; + } + + private void UpdateState(EditorState newState) + { + if (newState == CurrentState) + { + return; + } + + CurrentState = newState; + StateChanged?.Invoke(this, CurrentState); + } + + private void UpdateCapabilities(EditorCapabilities newCapabilities) + { + if (newCapabilities == Capabilities) + { + return; + } + + Capabilities = newCapabilities; + CapabilitiesChanged?.Invoke(this, Capabilities); + } + + private void DetachChromium(WebView2 chromium, bool closeWebView) + { + chromium.CoreWebView2Initialized -= ChromiumInitialized; + + if (chromium.CoreWebView2 != null) + { + chromium.CoreWebView2.DOMContentLoaded -= DomLoaded; + chromium.CoreWebView2.WebMessageReceived -= ScriptMessageReceived; + } + + if (closeWebView) + { + chromium.Close(); + } + } private void Dispose(bool disposing) { @@ -346,24 +697,95 @@ public sealed partial class WebViewEditorControl : Control, IDisposable { if (disposing && _chromium != null) { - _chromium.CoreWebView2Initialized -= ChromiumInitialized; - - if (_chromium.CoreWebView2 != null) - { - _chromium.CoreWebView2.DOMContentLoaded -= DomLoaded; - _chromium.CoreWebView2.WebMessageReceived -= ScriptMessageReceived; - } - - _chromium.Close(); + DetachChromium(_chromium, closeWebView: true); + _chromium = null; } + _disposedValue = true; } } public void Dispose() { - // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method Dispose(disposing: true); GC.SuppressFinalize(this); } + + private sealed class EditorWebMessage + { + [JsonPropertyName("type")] + public string? Type { get; set; } + + [JsonPropertyName("state")] + public EditorStateSnapshot? State { get; set; } + } + + private sealed class EditorStateSnapshot + { + [JsonPropertyName("bold")] + public bool IsBold { get; set; } + + [JsonPropertyName("italic")] + public bool IsItalic { get; set; } + + [JsonPropertyName("underline")] + public bool IsUnderline { get; set; } + + [JsonPropertyName("strikethrough")] + public bool IsStrikethrough { get; set; } + + [JsonPropertyName("orderedList")] + public bool IsOrderedList { get; set; } + + [JsonPropertyName("unorderedList")] + public bool IsUnorderedList { get; set; } + + [JsonPropertyName("canIndent")] + public bool CanIndent { get; set; } + + [JsonPropertyName("canOutdent")] + public bool CanOutdent { get; set; } + + [JsonPropertyName("hasSelection")] + public bool HasSelection { get; set; } + + [JsonPropertyName("isSpellCheckEnabled")] + public bool IsSpellCheckEnabled { get; set; } = true; + + [JsonPropertyName("alignment")] + public string? Alignment { get; set; } + + [JsonPropertyName("fontFamily")] + public string? FontFamily { get; set; } + + [JsonPropertyName("fontSize")] + public int? FontSize { get; set; } + + [JsonPropertyName("paragraphStyle")] + public string? ParagraphStyle { get; set; } + + [JsonPropertyName("textColor")] + public string? TextColor { get; set; } + + [JsonPropertyName("highlightColor")] + public string? HighlightColor { get; set; } + + [JsonPropertyName("lineHeight")] + public string? LineHeight { get; set; } + + [JsonPropertyName("linkUrl")] + public string? LinkUrl { get; set; } + + [JsonPropertyName("selectedText")] + public string? SelectedText { get; set; } + } } + + + + + + + + + diff --git a/Wino.Mail.WinUI/Dialogs/SignatureEditorDialog.xaml b/Wino.Mail.WinUI/Dialogs/SignatureEditorDialog.xaml index 4145318d..4feadd96 100644 --- a/Wino.Mail.WinUI/Dialogs/SignatureEditorDialog.xaml +++ b/Wino.Mail.WinUI/Dialogs/SignatureEditorDialog.xaml @@ -47,212 +47,7 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + diff --git a/Wino.Mail.WinUI/Helpers/CalendarXamlHelpers.cs b/Wino.Mail.WinUI/Helpers/CalendarXamlHelpers.cs index 8f9d4eab..cc928bab 100644 --- a/Wino.Mail.WinUI/Helpers/CalendarXamlHelpers.cs +++ b/Wino.Mail.WinUI/Helpers/CalendarXamlHelpers.cs @@ -1,4 +1,5 @@ -using System.Linq; +using System; +using System.Linq; using System.Text.RegularExpressions; using Ical.Net.CalendarComponents; using Ical.Net.DataTypes; @@ -17,9 +18,6 @@ public static class CalendarXamlHelpers public static CalendarItemViewModel GetFirstAllDayEvent(CalendarEventCollection collection) => collection.AllDayEvents.OfType().FirstOrDefault()!; - /// - /// Returns full date + duration info in Event Details page details title. - /// public static string GetEventDetailsDateString(CalendarItemViewModel calendarItemViewModel, CalendarSettings settings) { if (calendarItemViewModel == null || settings == null) return string.Empty; @@ -34,18 +32,17 @@ public static class CalendarXamlHelpers { return $"{start.ToString($"dd MMMM ddd {timeFormat}", settings.CultureInfo)} - {end.ToString($"dd MMMM ddd {timeFormat}", settings.CultureInfo)}"; } - else - { - return $"{start.ToString(dateFormat, settings.CultureInfo)} - {end.ToString(timeFormat, settings.CultureInfo)}"; - } + + return $"{start.ToString(dateFormat, settings.CultureInfo)} - {end.ToString(timeFormat, settings.CultureInfo)}"; } - public static string GetRecurrenceString(CalendarItemViewModel calendarItemViewModel) + public static string GetRecurrenceString(CalendarItemViewModel calendarItemViewModel, CalendarSettings settings) { - // TODO: This is incorrect. - if (calendarItemViewModel == null || string.IsNullOrEmpty(calendarItemViewModel.CalendarItem.Recurrence)) return string.Empty; + if (calendarItemViewModel == null || string.IsNullOrEmpty(calendarItemViewModel.CalendarItem.Recurrence)) + { + return string.Empty; + } - // Parse recurrence rules var calendarEvent = new CalendarEvent { Start = new CalDateTime(calendarItemViewModel.StartDate), @@ -53,44 +50,50 @@ public static class CalendarXamlHelpers }; var recurrenceLines = Regex.Split(calendarItemViewModel.CalendarItem.Recurrence, Constants.CalendarEventRecurrenceRuleSeperator); - - foreach (var line in recurrenceLines) + foreach (var line in recurrenceLines.Where(line => !string.IsNullOrWhiteSpace(line))) { calendarEvent.RecurrenceRules.Add(new RecurrencePattern(line)); } - if (calendarEvent.RecurrenceRules == null || !calendarEvent.RecurrenceRules.Any()) + var recurrenceRule = calendarEvent.RecurrenceRules.FirstOrDefault(); + if (recurrenceRule == null) { - return "No recurrence pattern."; + return string.Empty; } - var recurrenceRule = calendarEvent.RecurrenceRules.First(); - var daysOfWeek = string.Join(", ", recurrenceRule.ByDay.Select(day => day.DayOfWeek.ToString())); - string timeZone = calendarEvent.DtStart.TzId ?? "UTC"; + var frequency = MapFrequency(recurrenceRule.Frequency.ToString()); + if (!frequency.HasValue) + { + return string.Empty; + } - return $"Every {daysOfWeek}, effective {calendarEvent.DtStart.Value.ToShortDateString()} " + - $"from {calendarEvent.DtStart.Value.ToShortTimeString()} to {calendarEvent.DtEnd.Value.ToShortTimeString()} " + - $"{timeZone}."; + return CalendarRecurrenceSummaryFormatter.BuildSummary( + isRecurring: true, + effectiveStart: calendarItemViewModel.Period.Start, + effectiveEnd: calendarItemViewModel.Period.End, + isAllDay: calendarItemViewModel.IsAllDayEvent, + settings: settings, + interval: recurrenceRule.Interval <= 0 ? 1 : recurrenceRule.Interval, + frequency: frequency.Value, + daysOfWeek: recurrenceRule.ByDay?.Select(day => day.DayOfWeek).ToList() ?? [], + recurrenceEndDate: recurrenceRule.Until == default ? null : new DateTimeOffset(recurrenceRule.Until)); } public static string GetDetailsPopupDurationString(CalendarItemViewModel calendarItemViewModel, CalendarSettings settings) { if (calendarItemViewModel == null || settings == null) return string.Empty; - // Single event in a day. if (!calendarItemViewModel.IsAllDayEvent && !calendarItemViewModel.IsMultiDayEvent) { return $"{calendarItemViewModel.Period.Start.ToString("d", settings.CultureInfo)} {settings.GetTimeString(calendarItemViewModel.Period.Duration)}"; } - else if (calendarItemViewModel.IsMultiDayEvent) + + if (calendarItemViewModel.IsMultiDayEvent) { return $"{calendarItemViewModel.Period.Start.ToString("d", settings.CultureInfo)} - {calendarItemViewModel.Period.End.ToString("d", settings.CultureInfo)}"; } - else - { - // All day event. - return $"{calendarItemViewModel.Period.Start.ToString("d", settings.CultureInfo)} ({Translator.CalendarItemAllDay})"; - } + + return $"{calendarItemViewModel.Period.Start.ToString("d", settings.CultureInfo)} ({Translator.CalendarItemAllDay})"; } public static PopupPlacementMode GetDesiredPlacementModeForEventsDetailsPopup( @@ -99,21 +102,14 @@ public static class CalendarXamlHelpers { if (calendarItemViewModel == null) return PopupPlacementMode.Auto; - // All and/or multi day events always go to the top of the screen. if (calendarItemViewModel.IsAllDayEvent || calendarItemViewModel.IsMultiDayEvent) return PopupPlacementMode.Bottom; return XamlHelpers.GetPlaccementModeForCalendarType(calendarDisplayType); } - /// - /// Returns true if the calendar item has an online meeting link. - /// public static bool HasOnlineMeetingLink(CalendarItemViewModel calendarItemViewModel) => calendarItemViewModel != null && !string.IsNullOrEmpty(calendarItemViewModel.CalendarItem?.HtmlLink); - /// - /// Returns the text representation of an attendee's status. - /// public static string GetAttendeeStatusText(AttendeeStatus status) { return status switch @@ -126,13 +122,21 @@ public static class CalendarXamlHelpers }; } - /// - /// Returns visibility for attendee status badge. - /// Always shows status for all attendees. - /// public static Microsoft.UI.Xaml.Visibility GetAttendeeStatusVisibility(AttendeeStatus status) { - // Always show status return Microsoft.UI.Xaml.Visibility.Visible; } + + private static CalendarItemRecurrenceFrequency? MapFrequency(string frequency) + { + return frequency.ToUpperInvariant() switch + { + "DAILY" => CalendarItemRecurrenceFrequency.Daily, + "WEEKLY" => CalendarItemRecurrenceFrequency.Weekly, + "MONTHLY" => CalendarItemRecurrenceFrequency.Monthly, + "YEARLY" => CalendarItemRecurrenceFrequency.Yearly, + _ => null + }; + } } + diff --git a/Wino.Mail.WinUI/JS/editor.js b/Wino.Mail.WinUI/JS/editor.js index 181bcdbe..76d62ca2 100644 --- a/Wino.Mail.WinUI/JS/editor.js +++ b/Wino.Mail.WinUI/JS/editor.js @@ -8,6 +8,7 @@ const joditConfig = { "showCharsCounter": false, "showWordsCounter": false, "showXPathInStatusbar": false, + "spellcheck": true, "link": { "processVideoLink": false }, @@ -17,92 +18,78 @@ const joditConfig = { "insertImageAsBase64URI": true }, "enter": "DIV" -} +}; + +let editor; +let editorDomObserver; +let selectionChangeHandler; +let stateSyncQueued = false; +let imageInputBound = false; +let inlineFontsPluginRegistered = false; +let lastKnownRange = null; -// This method should be called first all the time. function initializeJodit(fonts, defaultComposerFont, defaultComposerFontSize, defaultReaderFont, defaultReaderFontSize) { - const fontsWithFallabckObject = fonts.reduce((acc, font) => { acc[`'${font}',Arial,sans-serif`] = font; return acc; }, {}); + if (editor) { + scheduleStateSync(); + return true; + } + + const fontsWithFallbackObject = fonts.reduce((acc, font) => { + acc[`'${font}',Arial,sans-serif`] = font; + return acc; + }, {}); + const mergedConfig = { ...joditConfig, controls: { font: { - list: Jodit.atom(fontsWithFallabckObject) + list: Jodit.atom(fontsWithFallbackObject) } }, - style: { font: `${defaultReaderFontSize}px ${defaultReaderFont}` }, - } + style: { font: `${defaultReaderFontSize}px ${defaultReaderFont}` } + }; - Jodit.plugins.add('inlineFonts', jodit => { - jodit.events.on('afterEnter', e => { - const current = jodit.selection.current().parentNode; - current.style.fontFamily = `'${defaultComposerFont}',Arial,sans-serif`; - current.style.fontSize = `${defaultComposerFontSize}px`; + if (!inlineFontsPluginRegistered) { + Jodit.plugins.add('inlineFonts', jodit => { + jodit.events.on('afterEnter', () => { + const current = getSelectionElement(); + if (!current) { + return; + } + + current.style.fontFamily = `'${defaultComposerFont}',Arial,sans-serif`; + current.style.fontSize = `${defaultComposerFontSize}px`; + rememberSelection(); + scheduleStateSync(); + }); }); - }); - // Don't add const/let/var here, it should be global - editor = Jodit.make("#editor", mergedConfig); - - // Handle the image input change event - imageInput.addEventListener('change', () => { - const file = imageInput.files[0]; - if (file) { - const reader = new FileReader(); - reader.onload = function (event) { - const base64Image = event.target.result; - insertImages([{ data: base64Image, name: file.name }]); - }; - reader.readAsDataURL(file); - } - }); - - // Listeners for button events - const disabledButtons = ["indent", "outdent"]; - const ariaPressedButtons = ["bold", "italic", "underline", "strikethrough", "ul", "ol"]; - - const alignmentButton = document.querySelector(`[ref='left']`).firstChild.firstChild; - const alignmentObserver = new MutationObserver(function () { - const value = alignmentButton.firstChild.getAttribute('class').split(' ')[0]; - window.chrome.webview.postMessage({ type: 'alignment', value: value }); - }); - alignmentObserver.observe(alignmentButton, { childList: true, attributes: true, attributeFilter: ["class"] }); - - const ariaObservers = ariaPressedButtons.map(button => { - const buttonContainer = document.querySelector(`[ref='${button}']`); - const observer = new MutationObserver(function () { pressedChanged(buttonContainer) }); - observer.observe(buttonContainer.firstChild, { attributes: true, attributeFilter: ["aria-pressed"] }); - - return observer; - }); - - const disabledObservers = disabledButtons.map(button => { - const buttonContainer = document.querySelector(`[ref='${button}']`); - const observer = new MutationObserver(function () { disabledButtonChanged(buttonContainer) }); - observer.observe(buttonContainer.firstChild, { attributes: true, attributeFilter: ["disabled"] }); - - return observer; - }); - - function pressedChanged(buttonContainer) { - const ref = buttonContainer.getAttribute('ref'); - const value = buttonContainer.firstChild.getAttribute('aria-pressed'); - window.chrome.webview.postMessage({ type: ref, value: value }); + inlineFontsPluginRegistered = true; } - function disabledButtonChanged(buttonContainer) { - const ref = buttonContainer.getAttribute('ref'); - const value = buttonContainer.firstChild.getAttribute('disabled'); - window.chrome.webview.postMessage({ type: ref, value: value }); - } + editor = Jodit.make('#editor', mergedConfig); + + bindImageInput(); + bindEditorStateTracking(); + toggleToolbar(false); + scheduleStateSync(); + + return true; } function RenderHTML(htmlString) { + if (!editor) { + return; + } + editor.value = htmlString; editor.synchronizeValues(); + rememberSelection(); + scheduleStateSync(); } function GetHTMLContent() { - return editor.value; + return editor ? editor.value : ''; } function SetLightEditor() { @@ -115,16 +102,655 @@ function SetDarkEditor() { function toggleToolbar(enable) { const toolbar = document.querySelector('.jodit-toolbar__box'); - if (enable) { - toolbar.style.display = 'flex'; + if (toolbar) { + toolbar.style.display = enable ? 'flex' : 'none'; } - else { - toolbar.style.display = 'none'; + + scheduleStateSync(); +} + +function setSpellCheck(enable) { + if (!editor || !editor.editor) { + return; } + + const isEnabled = !!enable; + editor.options.spellcheck = isEnabled; + editor.editor.spellcheck = isEnabled; + editor.editor.setAttribute('spellcheck', isEnabled ? 'true' : 'false'); + scheduleStateSync(); } function insertImages(imagesInfo) { + if (!editor) { + return; + } + + restoreEditorSelection(); + imagesInfo.forEach(imageInfo => { - editor.selection.insertHTML(`${imageInfo.name}`); + editor.selection.insertHTML(`${escapeHtmlAttribute(imageInfo.name)}`); }); -}; + + rememberSelection(); + scheduleStateSync(); +} + +function focusEditor() { + if (!editor) { + return; + } + + if (restoreEditorSelection()) { + return; + } + + editor.selection.focus(); + + const lastChild = editor.editor.lastChild; + if (lastChild) { + editor.selection.setCursorIn(lastChild, false); + } +} + +function getEditorState() { + return buildEditorState(); +} + +function executeEditorCommand(commandName) { + if (!editor) { + return; + } + + restoreEditorSelection(); + editor.execCommand(commandName); + rememberSelection(); + scheduleStateSync(); +} + +function setFontFamily(fontFamily) { + applyInlineStyleToSelection({ fontFamily: `'${fontFamily}',Arial,sans-serif` }); +} + +function setFontSize(fontSize) { + applyInlineStyleToSelection({ fontSize: `${fontSize}px` }); +} + +function setTextColor(color) { + applyInlineStyleToSelection({ color: color || '' }); +} + +function setHighlightColor(color) { + applyInlineStyleToSelection({ backgroundColor: color || '' }); +} + +function setParagraphStyle(tagName) { + if (!editor) { + return; + } + + restoreEditorSelection(); + + const normalizedTag = (tagName || 'div').toLowerCase(); + + try { + document.execCommand('formatBlock', false, normalizedTag); + } + catch { + const block = getCurrentBlockElement(); + if (block && block.tagName.toLowerCase() !== normalizedTag) { + const replacement = document.createElement(normalizedTag); + while (block.firstChild) { + replacement.appendChild(block.firstChild); + } + + block.parentNode.replaceChild(replacement, block); + } + } + + rememberSelection(); + scheduleStateSync(); +} + +function setLineHeight(lineHeight) { + restoreEditorSelection(); + + const block = getCurrentBlockElement(); + if (!block) { + return; + } + + block.style.lineHeight = lineHeight || ''; + rememberSelection(); + scheduleStateSync(); +} + +function upsertLink(linkArgs) { + if (!editor) { + return; + } + + restoreEditorSelection(); + + const normalizedUrl = normalizeLinkUrl(linkArgs && linkArgs.url ? linkArgs.url : ''); + if (!normalizedUrl) { + return; + } + + const linkText = linkArgs && linkArgs.text ? linkArgs.text.trim() : ''; + const existingLink = getSelectionElement() ? getSelectionElement().closest('a[href]') : null; + + if (existingLink) { + existingLink.setAttribute('href', normalizedUrl); + if (linkArgs.openInNewWindow) { + existingLink.setAttribute('target', '_blank'); + existingLink.setAttribute('rel', 'noopener noreferrer'); + } + else { + existingLink.removeAttribute('target'); + existingLink.removeAttribute('rel'); + } + + if (linkText) { + existingLink.textContent = linkText; + } + + rememberSelection(); + scheduleStateSync(); + return; + } + + const selection = window.getSelection(); + if (selection && selection.rangeCount > 0 && !selection.isCollapsed && isSelectionInsideEditor()) { + try { + document.execCommand('createLink', false, normalizedUrl); + const createdLink = getSelectionElement() ? getSelectionElement().closest('a[href]') : null; + if (createdLink) { + if (linkArgs.openInNewWindow) { + createdLink.setAttribute('target', '_blank'); + createdLink.setAttribute('rel', 'noopener noreferrer'); + } + if (linkText) { + createdLink.textContent = linkText; + } + } + } + catch { + const selectedText = linkText || selection.toString() || normalizedUrl; + editor.selection.insertHTML(`${escapeHtmlText(selectedText)}`); + } + + rememberSelection(); + scheduleStateSync(); + return; + } + + const text = linkText || normalizedUrl; + editor.selection.insertHTML(`${escapeHtmlText(text)}`); + rememberSelection(); + scheduleStateSync(); +} + +function removeLink() { + restoreEditorSelection(); + + const selectionElement = getSelectionElement(); + const linkElement = selectionElement ? selectionElement.closest('a[href]') : null; + if (!linkElement) { + return; + } + + try { + document.execCommand('unlink'); + } + catch { + unwrapElement(linkElement); + } + + rememberSelection(); + scheduleStateSync(); +} + +function insertTableHtml(tableArgs) { + if (!editor) { + return; + } + + restoreEditorSelection(); + + const rows = clampInteger(tableArgs && tableArgs.rows, 1, 10); + const columns = clampInteger(tableArgs && tableArgs.columns, 1, 10); + const htmlRows = []; + + for (let rowIndex = 0; rowIndex < rows; rowIndex += 1) { + const cells = []; + for (let columnIndex = 0; columnIndex < columns; columnIndex += 1) { + cells.push('
'); + } + + htmlRows.push(`${cells.join('')}`); + } + + editor.selection.insertHTML(`${htmlRows.join('')}

`); + rememberSelection(); + scheduleStateSync(); +} + +function bindImageInput() { + if (imageInputBound) { + return; + } + + imageInput.addEventListener('change', () => { + const file = imageInput.files[0]; + if (!file) { + return; + } + + const reader = new FileReader(); + reader.onload = event => { + const base64Image = event.target.result; + insertImages([{ data: base64Image, name: file.name }]); + imageInput.value = ''; + }; + reader.readAsDataURL(file); + }); + + imageInputBound = true; +} + +function bindEditorStateTracking() { + if (!editor || !editor.editor) { + return; + } + + const syncHandler = () => { + rememberSelection(); + scheduleStateSync(); + }; + + ['keyup', 'mouseup', 'click', 'input', 'focus', 'blur'].forEach(eventName => { + editor.editor.addEventListener(eventName, syncHandler); + }); + + if (editor.events && editor.events.on) { + editor.events.on('afterSetMode change afterCommand', syncHandler); + } + + editorDomObserver = new MutationObserver(() => scheduleStateSync()); + editorDomObserver.observe(editor.editor, { + subtree: true, + childList: true, + attributes: true, + characterData: true, + attributeFilter: ['style', 'class', 'href', 'spellcheck'] + }); + + selectionChangeHandler = () => { + if (isSelectionInsideEditor()) { + rememberSelection(); + scheduleStateSync(); + } + }; + + document.addEventListener('selectionchange', selectionChangeHandler); +} + +function scheduleStateSync() { + if (stateSyncQueued) { + return; + } + + stateSyncQueued = true; + window.requestAnimationFrame(() => { + stateSyncQueued = false; + notifyState(); + }); +} + +function notifyState() { + if (!window.chrome || !window.chrome.webview) { + return; + } + + window.chrome.webview.postMessage({ + type: 'state', + state: buildEditorState() + }); +} + +function buildEditorState() { + const selectionElement = getSelectionElement(); + const contextElement = selectionElement || (editor && editor.editor ? editor.editor : document.body); + const blockElement = getCurrentBlockElement() || contextElement; + const style = window.getComputedStyle(contextElement); + const blockStyle = window.getComputedStyle(blockElement); + const selection = window.getSelection(); + const listElement = selectionElement ? selectionElement.closest('ol,ul') : null; + const linkElement = selectionElement ? selectionElement.closest('a[href]') : null; + const fontSize = parsePixelSize(style.fontSize); + + return { + bold: queryCommandState('bold', isBoldStyle(style)), + italic: queryCommandState('italic', style.fontStyle === 'italic'), + underline: queryCommandState('underline', (style.textDecorationLine || '').includes('underline')), + strikethrough: queryCommandState('strikeThrough', (style.textDecorationLine || '').includes('line-through')), + orderedList: !!(listElement && listElement.tagName.toLowerCase() === 'ol'), + unorderedList: !!(listElement && listElement.tagName.toLowerCase() === 'ul'), + canIndent: queryCommandEnabled('indent', true), + canOutdent: queryCommandEnabled('outdent', !!listElement || !!(selectionElement && selectionElement.closest('blockquote'))), + hasSelection: !!(selection && selection.rangeCount > 0 && !selection.isCollapsed && isSelectionInsideEditor()), + isSpellCheckEnabled: !!(editor && editor.editor && editor.editor.spellcheck), + alignment: normalizeAlignment(blockStyle.textAlign), + fontFamily: normalizeFontFamily(style.fontFamily), + fontSize: fontSize, + paragraphStyle: normalizeParagraphTag(blockElement), + textColor: normalizeColor(style.color), + highlightColor: normalizeColor(style.backgroundColor), + lineHeight: normalizeLineHeight(blockStyle.lineHeight, fontSize), + linkUrl: linkElement ? linkElement.getAttribute('href') || '' : '', + selectedText: selection && isSelectionInsideEditor() ? selection.toString() : '' + }; +} + +function getSelectionElement() { + if (!editor || !editor.editor) { + return null; + } + + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) { + return editor.editor; + } + + const node = selection.anchorNode; + if (!node) { + return editor.editor; + } + + const element = node.nodeType === Node.TEXT_NODE ? node.parentElement : node; + if (!element || !editor.editor.contains(element)) { + return editor.editor; + } + + return element; +} + +function getCurrentBlockElement() { + const selectionElement = getSelectionElement(); + if (!selectionElement) { + return null; + } + + return selectionElement.closest('h1,h2,h3,h4,h5,h6,p,blockquote,pre,div,li,td,th') || selectionElement; +} + +function rememberSelection() { + if (!editor || !editor.editor) { + return; + } + + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0 || !isSelectionInsideEditor()) { + return; + } + + try { + lastKnownRange = selection.getRangeAt(0).cloneRange(); + } + catch { + lastKnownRange = null; + } +} + +function restoreEditorSelection() { + if (!editor || !editor.editor) { + return false; + } + + editor.selection.focus(); + + if (!lastKnownRange) { + return false; + } + + try { + const selection = window.getSelection(); + if (!selection) { + return false; + } + + const restoredRange = lastKnownRange.cloneRange(); + selection.removeAllRanges(); + selection.addRange(restoredRange); + return true; + } + catch { + lastKnownRange = null; + return false; + } +} + +function isSelectionInsideEditor() { + if (!editor || !editor.editor) { + return false; + } + + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) { + return false; + } + + const anchorNode = selection.anchorNode; + const focusNode = selection.focusNode; + return !!anchorNode && !!focusNode && editor.editor.contains(anchorNode) && editor.editor.contains(focusNode); +} + +function queryCommandState(commandName, fallbackValue) { + try { + const value = document.queryCommandState(commandName); + return typeof value === 'boolean' ? value : fallbackValue; + } + catch { + return fallbackValue; + } +} + +function queryCommandEnabled(commandName, fallbackValue) { + try { + const value = document.queryCommandEnabled(commandName); + return typeof value === 'boolean' ? value : fallbackValue; + } + catch { + return fallbackValue; + } +} + +function isBoldStyle(style) { + const fontWeight = style.fontWeight || '400'; + const numericWeight = parseInt(fontWeight, 10); + return fontWeight === 'bold' || Number.isFinite(numericWeight) && numericWeight >= 600; +} + +function normalizeAlignment(value) { + const normalized = (value || '').toLowerCase(); + if (normalized === 'center' || normalized === 'right' || normalized === 'justify') { + return normalized; + } + + return 'left'; +} + +function normalizeFontFamily(value) { + if (!value) { + return ''; + } + + return value.split(',')[0].replace(/["']/g, '').trim(); +} + +function normalizeParagraphTag(element) { + return element && element.tagName ? element.tagName.toLowerCase() : 'div'; +} + +function normalizeLineHeight(value, fontSize) { + if (!value || value === 'normal') { + return 'normal'; + } + + const numericValue = parseFloat(value); + if (!Number.isFinite(numericValue)) { + return value; + } + + if (value.endsWith('px') && fontSize) { + const ratio = numericValue / fontSize; + return Number.isInteger(ratio) ? `${ratio}` : ratio.toFixed(2).replace(/0+$/, '').replace(/\.$/, ''); + } + + return Number.isInteger(numericValue) ? `${numericValue}` : numericValue.toString(); +} + +function parsePixelSize(value) { + const numericValue = parseFloat(value || ''); + return Number.isFinite(numericValue) ? Math.round(numericValue) : null; +} + +function normalizeColor(value) { + if (!value || value === 'transparent' || value === 'rgba(0, 0, 0, 0)') { + return ''; + } + + if (value.startsWith('#')) { + return value.toLowerCase(); + } + + const rgbaMatch = value.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/i); + if (!rgbaMatch) { + return value.toLowerCase(); + } + + const [, red, green, blue] = rgbaMatch; + return `#${toHex(red)}${toHex(green)}${toHex(blue)}`; +} + +function toHex(value) { + return Number(value).toString(16).padStart(2, '0'); +} + +function applyInlineStyleToSelection(styles) { + if (!editor || !editor.editor) { + return; + } + + restoreEditorSelection(); + + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0 || !isSelectionInsideEditor()) { + return; + } + + const range = selection.getRangeAt(0); + if (selection.isCollapsed) { + const contextElement = getSelectionElement(); + if (contextElement) { + Object.entries(styles).forEach(([propertyName, propertyValue]) => { + contextElement.style[propertyName] = propertyValue || ''; + }); + } + + rememberSelection(); + scheduleStateSync(); + return; + } + + const span = document.createElement('span'); + Object.entries(styles).forEach(([propertyName, propertyValue]) => { + if (propertyValue) { + span.style[propertyName] = propertyValue; + } + }); + + try { + span.appendChild(range.extractContents()); + range.insertNode(span); + selection.removeAllRanges(); + const newRange = document.createRange(); + newRange.selectNodeContents(span); + selection.addRange(newRange); + } + catch { + const css = styleObjectToCss(styles); + const selectedText = escapeHtmlText(selection.toString()); + editor.selection.insertHTML(`${selectedText}`); + } + + rememberSelection(); + scheduleStateSync(); +} + +function styleObjectToCss(styles) { + return Object.entries(styles) + .filter(([, value]) => value) + .map(([propertyName, propertyValue]) => `${camelToKebabCase(propertyName)}:${propertyValue}`) + .join(';'); +} + +function camelToKebabCase(value) { + return value.replace(/[A-Z]/g, match => `-${match.toLowerCase()}`); +} + +function unwrapElement(element) { + const parent = element.parentNode; + if (!parent) { + return; + } + + while (element.firstChild) { + parent.insertBefore(element.firstChild, element); + } + + parent.removeChild(element); +} + +function clampInteger(value, min, max) { + const numericValue = parseInt(value, 10); + if (!Number.isFinite(numericValue)) { + return min; + } + + return Math.min(max, Math.max(min, numericValue)); +} + +function normalizeLinkUrl(url) { + const trimmed = (url || '').trim(); + if (!trimmed) { + return ''; + } + + if (/^[a-z]+:/i.test(trimmed)) { + return trimmed; + } + + if (trimmed.includes('@') && !trimmed.includes('/')) { + return `mailto:${trimmed}`; + } + + return `https://${trimmed}`; +} + +function escapeHtmlAttribute(value) { + return `${value || ''}` + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>'); +} + +function escapeHtmlText(value) { + return `${value || ''}` + .replace(/&/g, '&') + .replace(//g, '>'); +} + diff --git a/Wino.Mail.WinUI/Services/NavigationService.cs b/Wino.Mail.WinUI/Services/NavigationService.cs index bcea463c..060d3eed 100644 --- a/Wino.Mail.WinUI/Services/NavigationService.cs +++ b/Wino.Mail.WinUI/Services/NavigationService.cs @@ -187,6 +187,9 @@ public class NavigationService : NavigationServiceBase, INavigationService // Update the application mode in state persistence service _statePersistanceService.ApplicationMode = mode; + _statePersistanceService.CoreWindowTitle = mode == WinoApplicationMode.Calendar + ? "Wino Calendar" + : "Wino Mail"; var targetPageType = mode == WinoApplicationMode.Mail ? typeof(MailAppShell) : typeof(CalendarAppShell); var currentPageType = coreFrame.Content?.GetType(); diff --git a/Wino.Mail.WinUI/Styles/SharedStyles.xaml b/Wino.Mail.WinUI/Styles/SharedStyles.xaml index a0386068..d3b4a93e 100644 --- a/Wino.Mail.WinUI/Styles/SharedStyles.xaml +++ b/Wino.Mail.WinUI/Styles/SharedStyles.xaml @@ -4,6 +4,17 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:local="using:Wino.Mail.WinUI.Styles"> + + - - - - - - - - - - - - - - - + - - + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - + Spacing="4"> + + + + + + + + + + - - - - - - - - - - - - - - + +
@@ -572,14 +556,14 @@ Icon="Draft" /> - + + IsEditorDarkMode="{x:Bind ViewModel.IsDarkWebviewRenderer, Mode=OneWay}" /> + diff --git a/Wino.Mail.WinUI/Views/Calendar/EventDetailsPage.xaml b/Wino.Mail.WinUI/Views/Calendar/EventDetailsPage.xaml index 97cb2bdc..d8d7bf8d 100644 --- a/Wino.Mail.WinUI/Views/Calendar/EventDetailsPage.xaml +++ b/Wino.Mail.WinUI/Views/Calendar/EventDetailsPage.xaml @@ -57,17 +57,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +