diff --git a/README.md b/README.md index 65bbc12..54b78a4 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,9 @@ - Easy to use - Highly customizable +- :fire: Hot Reload support :fire: - For local and remote use cases -- Hot Reload support :fire: +- IgnoreSafeArea support - Massively scalable - Fully works with the default MAUI UI rendering - No hacking or other fragile mechanisms @@ -115,7 +116,28 @@ This plugin has been created with only structured object-oriented code, without For customizability, against the principles of some developers, almost all methods are virtual and ready to be overridden. Use this power wisely, you may break this system but the freedom it gives to make it your own is endless :smirk:. ### Use UI Hot-Reload on multiple platforms simultaneously -- TODO: +For UI Development purposes a dedicated dummy sample iOS project has been created to be used in a ‘multi startup projects’ scenario. This can be used to simultaneously use UI Hot-Reload on multiple platforms at the same time, that is iOS + a second desired platform. + +Because this scenario requires everything to be build for all desired platforms its recommended to manually build everything before starting a debug session with UI Hot-Reload. +1. Set ‘Plugin.Maui.MarkdownView.Sample’ as startup project +2. Set target to some iOS simulator +3. Build +4. Set target to some Android emulator or Windows device +5. Build +6. Set ‘Plugin.Maui.MarkdownView.Sample.iOS’ as startup project +7. Set target to some iOS simulator +8. Build +9. Start ‘Configure startup projects’ + 1. pick ‘Multiple startup projects’ + 2. set MarkdownView to action ‘None’ + 3. set MarkdownView.Sample to action ‘Start’ and target to an Android emulator or Windows device + 4. set MarkdownView.Sample.iOS to action ‘Start’ and target to an iOS simulator +10. Start debug session + +When you loose Hot-Reload for one of the platforms, do the following: +1. Temporarily set ‘Plugin.Maui.MarkdownView.Sample’ as startup project and shortly start a debug session. +2. Set the ‘Plugin.Maui.MarkdownView.Sample’ to previous target +3. Start ‘Configure startup projects’ and set the Multiple startup projects settings as before ## Side Notes - Italic and Bold/Strong do not work on iOS with most custom fonts like OpenSans. Reset the Font with `` will force to use the platform designated Font. So check if your font is supported on iOS for using italic and bold if you wan to use it. diff --git a/samples/Plugin.Maui.MarkdownView.Sample/App.xaml b/samples/Plugin.Maui.MarkdownView.Sample/App.xaml index c09b562..e19b75e 100644 --- a/samples/Plugin.Maui.MarkdownView.Sample/App.xaml +++ b/samples/Plugin.Maui.MarkdownView.Sample/App.xaml @@ -8,6 +8,7 @@ + diff --git a/samples/Plugin.Maui.MarkdownView.Sample/AppShell.xaml b/samples/Plugin.Maui.MarkdownView.Sample/AppShell.xaml index e00fba8..f518911 100644 --- a/samples/Plugin.Maui.MarkdownView.Sample/AppShell.xaml +++ b/samples/Plugin.Maui.MarkdownView.Sample/AppShell.xaml @@ -47,5 +47,15 @@ + + + + + + + + diff --git a/samples/Plugin.Maui.MarkdownView.Sample/AppShell.xaml.cs b/samples/Plugin.Maui.MarkdownView.Sample/AppShell.xaml.cs index f5df0db..52b8f5d 100644 --- a/samples/Plugin.Maui.MarkdownView.Sample/AppShell.xaml.cs +++ b/samples/Plugin.Maui.MarkdownView.Sample/AppShell.xaml.cs @@ -1,4 +1,6 @@ -namespace Plugin.Maui.MarkdownView.Sample; +using Plugin.Maui.MarkdownView.Sample.Pages; + +namespace Plugin.Maui.MarkdownView.Sample; public partial class AppShell : Shell { @@ -7,6 +9,8 @@ public partial class AppShell : Shell public AppShell() { InitializeComponent(); + + Routing.RegisterRoute(nameof(MarkdownFullExampleDetailPage), typeof(MarkdownFullExampleDetailPage)); } protected override void OnAppearing() diff --git a/samples/Plugin.Maui.MarkdownView.Sample/CustomViewSuppliers/MyViewSupplier.cs b/samples/Plugin.Maui.MarkdownView.Sample/CustomViewSuppliers/MyViewSupplier.cs new file mode 100644 index 0000000..7721f15 --- /dev/null +++ b/samples/Plugin.Maui.MarkdownView.Sample/CustomViewSuppliers/MyViewSupplier.cs @@ -0,0 +1,214 @@ +using MarkdownParser.Models.Segments.Indicators; +using MarkdownParser.Models.Segments; +using MarkdownParser.Models; +using Plugin.Maui.MarkdownView.Sample.Views; +using Plugin.Maui.MarkdownView.ViewSuppliers; + +namespace Plugin.Maui.MarkdownView.Sample.CustomViewSuppliers; + +public class MyViewSupplier : MauiFormattedTextViewSupplier +{ + public SupplierExtraStyles? ExtraStyles { get; set; } + + public Func? OnMenuItemTapped { get; set; } + + public override View? CreateHeaderView(TextBlock textBlock, int headerLevel) + { + var header = base.CreateHeaderView(textBlock, headerLevel); + if (headerLevel is 1 or 2) + { + var bottomLine = new BoxView + { + Style = ExtraStyles?.MenuItemSeparatorStyle + }; + + var gridLayout = new Grid + { + RowDefinitions = + [ + new RowDefinition { Height = GridLength.Auto }, + new RowDefinition { Height = GridLength.Star } + ], + IgnoreSafeArea = IgnoreSafeArea, + Style = headerLevel switch + { + 1 => ExtraStyles?.Header1LayoutViewStyle, + 2 => ExtraStyles?.Header2LayoutViewStyle + } + }; + + gridLayout.Add(header); + Grid.SetRow(header, 0); + + gridLayout.Add(bottomLine); + Grid.SetRow(bottomLine, 1); + + return gridLayout; + } + + return header; + } + + public override View? CreateTextView(TextBlock textBlock) + { + var textViewStyle = GetTextBlockStyleFor(textBlock); + var spanStyle = GetSpanStyleFor(textBlock); + + var collectedRootViews = new List(); + var textSpansCache = new List(); + + var activeIndicators = new List(); + LinkSegment? activeLinkSegment = null; + + var isCreatingPlaceholderMenu = false; + MenuItemView? placeholderMenuItemCache = null; + + foreach (var segment in textBlock.TextSegments) + { + // Placeholders like [my placeholder] can be one of two types: + // 1. Inline placeholder like a (hyper)link => this is handled by the default span and label creation + // 2. Designated Placeholder to be a custom view => this is handled by the TryCreateDesignatedPlaceholderView method + if (segment is PlaceholderSegment placeholderSegment + && !DoesReferenceDefinitionsContainPlaceholder(placeholderSegment.PlaceholderId)) + { + if (placeholderSegment.PlaceholderId == "MENU|START") + { + isCreatingPlaceholderMenu = true; + FlushSpansToRootViews(ref textSpansCache, ref collectedRootViews, textViewStyle); + + continue; + } + + if (placeholderSegment.PlaceholderId == "MENU|END") + { + isCreatingPlaceholderMenu = false; + + continue; + } + + FlushSpansToRootViews(ref textSpansCache, ref collectedRootViews, textViewStyle); + + // add designated Placeholder between to TextViews + var designatedPlaceholderView = CreateDesignatedPlaceholderView(placeholderSegment); + collectedRootViews.Add(designatedPlaceholderView); + + continue; + } + + if (isCreatingPlaceholderMenu) + { + if (segment is LinkSegment linkSegment) + { + if (linkSegment.IndicatorPosition == SegmentIndicatorPosition.Start) + { + placeholderMenuItemCache = new MenuItemView + { + Url = linkSegment.Url, + Icon = linkSegment.Title, + IgnoreSafeArea = IgnoreSafeArea, + Style = ExtraStyles?.MenuItemViewStyle, + LabelStyle = ExtraStyles?.MenuItemLabelStyle, + SeparatorStyle = ExtraStyles?.MenuItemSeparatorStyle, + OnItemTapped = async url => + { + if (OnMenuItemTapped != null) + { + await OnMenuItemTapped.Invoke(url); + } + } + }; + } + + if (linkSegment.IndicatorPosition == SegmentIndicatorPosition.End) + { + collectedRootViews.Add(placeholderMenuItemCache); + placeholderMenuItemCache = null; + } + } + + if (segment.HasLiteralContent + && placeholderMenuItemCache != null) + { + placeholderMenuItemCache.Text = segment.ToString(); + } + + continue; + } + + // Special segments like indicators + if (segment is IndicatorSegment indicatorSegment) + { + switch (indicatorSegment.Indicator) + { + case SegmentIndicator.Strong: + case SegmentIndicator.Italic: + case SegmentIndicator.Strikethrough: + case SegmentIndicator.Code: + case SegmentIndicator.Link: + if (indicatorSegment.IndicatorPosition == SegmentIndicatorPosition.Start) + { + activeIndicators.Add(indicatorSegment.Indicator); + } + else + { + activeIndicators.RemoveAll(x => x == indicatorSegment.Indicator); + } + + if (indicatorSegment.Indicator == SegmentIndicator.Link) + { + activeLinkSegment = indicatorSegment as LinkSegment; + } + + break; + case SegmentIndicator.LineBreak: + textSpansCache.Add(new Span { Text = Environment.NewLine }); + break; + } + } + + // Segments with text + if (segment.HasLiteralContent) + { + var span = CreateSpan(segment, activeIndicators, activeLinkSegment, spanStyle); + AddFormattingToSpan(span, activeIndicators); + + textSpansCache.Add(span); + } + } + + FlushSpansToRootViews(ref textSpansCache, ref collectedRootViews, textViewStyle); + + var flattenedView = FlattenToSingleView(collectedRootViews); + return flattenedView; + } + + protected override View? CreateDesignatedPlaceholderView(PlaceholderSegment placeholderSegment) + { + var placeholderParts = placeholderSegment.Title.Split('|'); + if (placeholderParts.Any() + && placeholderParts.First().Equals("audio", StringComparison.OrdinalIgnoreCase)) + { + if (placeholderParts.Length > 1) + { + var audioSource = placeholderParts[1]; + var button = new AudioButton + { + Text = $"Play {audioSource}", + AudioFileName = audioSource, + ButtonStyle = ExtraStyles?.ButtonViewStyle + }; + + return button; + } + } + + return base.CreateDesignatedPlaceholderView(placeholderSegment); + } + + private void FlushSpansToRootViews(ref List spans, ref List rootViewSink, Style? textViewStyle) + { + var flushedFormattedLabel = TryCreateFormattedLabel(spans, textViewStyle); + rootViewSink.Add(flushedFormattedLabel); + spans.Clear(); + } +} diff --git a/samples/Plugin.Maui.MarkdownView.Sample/CustomViewSuppliers/SupplierExtraStyles.cs b/samples/Plugin.Maui.MarkdownView.Sample/CustomViewSuppliers/SupplierExtraStyles.cs new file mode 100644 index 0000000..fcb7ef1 --- /dev/null +++ b/samples/Plugin.Maui.MarkdownView.Sample/CustomViewSuppliers/SupplierExtraStyles.cs @@ -0,0 +1,34 @@ +namespace Plugin.Maui.MarkdownView.Sample.CustomViewSuppliers; + +public class SupplierExtraStyles +{ + /// + /// Button style for button with image + /// + public Style? ButtonViewStyle { get; set; } + + /// + /// MenuItemView (Grid) style for menu items + /// + public Style? MenuItemViewStyle { get; set; } + + /// + /// Label style for menu items + /// + public Style? MenuItemLabelStyle { get; set; } + + /// + /// Boxview style for separator in menu items + /// + public Style? MenuItemSeparatorStyle { get; set; } + + /// + /// Header1 (Grid) style + /// + public Style? Header1LayoutViewStyle { get; set; } + + /// + /// Header2 (Grid) style + /// + public Style? Header2LayoutViewStyle { get; set; } +} \ No newline at end of file diff --git a/samples/Plugin.Maui.MarkdownView.Sample/Pages/MarkdownFullExampleDetailPage.xaml b/samples/Plugin.Maui.MarkdownView.Sample/Pages/MarkdownFullExampleDetailPage.xaml new file mode 100644 index 0000000..b48b1be --- /dev/null +++ b/samples/Plugin.Maui.MarkdownView.Sample/Pages/MarkdownFullExampleDetailPage.xaml @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + +*** + +**Aliquam rutrum** +Sed dignissim tortor diam, sed semper felis dapibus ut. Sed sed facilisis turpis. Phasellus mollis nec ante eu congue. Nulla facilisi. Mauris tincidunt scelerisque sem ut semper. +* Ut vitae eros eget nunc varius semper. Proin ut metus ut nisl eleifend finibus +* Mauris eget nulla et augue pellentesque varius quis id enim + +Integer sed imperdiet risus. Phasellus tempus leo metus, ut mattis augue consequat non. + +*** + +[audio|chirping-birds.wav] + +*** + +**Lorem ipsum dolor sit ametLorem ipsum dolor sit amet** +Consectetur adipiscing elit. Quisque at fermentum libero. Duis ac blandit leo. Suspendisse maximus augue varius dui auctor blandit. Curabitur vitae nisi porta odio accumsan mattis. + +**Phasellus tempus leo metus** +Mattis augue consequat non. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Fusce posuere magna eget libero elementum suscipit. Suspendisse nec feugiat lacus. + + + + + \ No newline at end of file diff --git a/samples/Plugin.Maui.MarkdownView.Sample/Pages/MarkdownFullExampleDetailPage.xaml.cs b/samples/Plugin.Maui.MarkdownView.Sample/Pages/MarkdownFullExampleDetailPage.xaml.cs new file mode 100644 index 0000000..c1ba7b6 --- /dev/null +++ b/samples/Plugin.Maui.MarkdownView.Sample/Pages/MarkdownFullExampleDetailPage.xaml.cs @@ -0,0 +1,20 @@ +namespace Plugin.Maui.MarkdownView.Sample.Pages; + +public partial class MarkdownFullExampleDetailPage : ContentPage, IQueryAttributable +{ + public MarkdownFullExampleDetailPage() + { + InitializeComponent(); + } + + public void ApplyQueryAttributes(IDictionary query) + { + if (!query.ContainsKey("Url")) + { + return; + } + + var url = query["Url"] as string; + Title = $"Details {url.Replace("/", ": ").Replace("_", " ")}"; + } +} \ No newline at end of file diff --git a/samples/Plugin.Maui.MarkdownView.Sample/Pages/MarkdownFullExamplePage.xaml b/samples/Plugin.Maui.MarkdownView.Sample/Pages/MarkdownFullExamplePage.xaml new file mode 100644 index 0000000..340fb1a --- /dev/null +++ b/samples/Plugin.Maui.MarkdownView.Sample/Pages/MarkdownFullExamplePage.xaml @@ -0,0 +1,118 @@ + + + + + + + + + + + + + + + + + + + +###### Aenean nulla: 06-oct-2028 + +*** + +Mauris faucibus +============ + +* Duis vitae efficitur quam. Nulla placerat tellus id ipsum aliquam, sit amet maximus quam tincidunt. Fusce vestibulum aliquet sem sit amet pellentesque. Maecenas porttitor pellentesque massa vitae blandit. Curabitur eget diam velit. Mauris in luctus dui, ut sollicitudin erat. Cras tristique nibh ut ipsum feugiat, sit amet aliquet metus hendrerit. +* Praesent vulputate tristique ex in feugiat. Morbi a sodales est. Donec faucibus tellus at pellentesque suscipit. Vestibulum sed ligula nisi. + +> Integer venenatis lectus eget magna vestibulum, eu mollis eros molestie. +> Lorem ipsum dolor sit amet, [consectetur adipiscing] elit. + +*** + +Praesent blandit ultricies turpis non? +--------------- +[menu|start] +[In facilisiss](/In_facilisis "") +[Praesent posuere](/Curabitur_eget "") +[Curabitur ut](/Curabitur_ut "") +[Pellentesque eget](/Pellentesque_eget "") +[In consectetur](/In_consectetur "") +[menu|end] + +*** + +Nam dapibus? +--------------- +[menu|start] +[Sed elementum](/Sed_elementum "") +[Morbi eget](/Morbi_eget "") +[Vestibulum ante](/Vestibulum_ante "") +[menu|end] + +*** + +[consectetur adipiscing]: https://www.lipsum.com/ + + + + \ No newline at end of file diff --git a/samples/Plugin.Maui.MarkdownView.Sample/Pages/MarkdownFullExamplePage.xaml.cs b/samples/Plugin.Maui.MarkdownView.Sample/Pages/MarkdownFullExamplePage.xaml.cs new file mode 100644 index 0000000..85a7195 --- /dev/null +++ b/samples/Plugin.Maui.MarkdownView.Sample/Pages/MarkdownFullExamplePage.xaml.cs @@ -0,0 +1,24 @@ +namespace Plugin.Maui.MarkdownView.Sample.Pages; + +public partial class MarkdownFullExamplePage : ContentPage +{ + public MarkdownFullExamplePage() + { + InitializeComponent(); + + MyViewSupplier.OnMenuItemTapped = OnMenuItemTapped; + } + + private async Task OnMenuItemTapped(string? url) + { + if (string.IsNullOrWhiteSpace(url)) + { + return; + } + + await Shell.Current.GoToAsync(nameof(MarkdownFullExampleDetailPage), true, new Dictionary() + { + { "Url", url } + }); + } +} \ No newline at end of file diff --git a/samples/Plugin.Maui.MarkdownView.Sample/Pages/MarkdownInXamlPage.xaml b/samples/Plugin.Maui.MarkdownView.Sample/Pages/MarkdownInXamlPage.xaml index add826e..43ed331 100644 --- a/samples/Plugin.Maui.MarkdownView.Sample/Pages/MarkdownInXamlPage.xaml +++ b/samples/Plugin.Maui.MarkdownView.Sample/Pages/MarkdownInXamlPage.xaml @@ -13,7 +13,19 @@ xml:space="preserve"> - + + + + - - - - + An h1 header ============ -Markdown in XAML! +Markdown in XAML! Paragraphs are separated by a blank line. 2nd paragraph. *Italic*, **bold**, ~~strikethrough~~, and `monospace`. -Itemized lists look like: +Itemized lists look like: * item one * item *two* @@ -156,9 +158,11 @@ that last line which continues item 3 above). Here's a link to [a website](http://foo.bar), to a [local doc](local-doc.html), and to a [section heading in the current -doc](#an-h2-header). Here's a footnote [^1]. +doc](#an-h2-header). + +Here's a footnote to [Lorem Ipsum]. -[^1]: Some footnote text. +[Lorem Ipsum]: https://www.lipsum.com/ ##### An h5 header ##### diff --git a/samples/Plugin.Maui.MarkdownView.Sample/Plugin.Maui.MarkdownView.Sample.csproj b/samples/Plugin.Maui.MarkdownView.Sample/Plugin.Maui.MarkdownView.Sample.csproj index 06c01b4..e149880 100644 --- a/samples/Plugin.Maui.MarkdownView.Sample/Plugin.Maui.MarkdownView.Sample.csproj +++ b/samples/Plugin.Maui.MarkdownView.Sample/Plugin.Maui.MarkdownView.Sample.csproj @@ -62,22 +62,11 @@ + - - - MSBuild:Compile - - - MSBuild:Compile - - - MSBuild:Compile - - - diff --git a/samples/Plugin.Maui.MarkdownView.Sample/Resources/Raw/chirping-birds.wav b/samples/Plugin.Maui.MarkdownView.Sample/Resources/Raw/chirping-birds.wav new file mode 100644 index 0000000..f8f3e8b Binary files /dev/null and b/samples/Plugin.Maui.MarkdownView.Sample/Resources/Raw/chirping-birds.wav differ diff --git a/samples/Plugin.Maui.MarkdownView.Sample/Resources/Styles/DefaultMauiFormattedTextViewSupplierStyles.cs b/samples/Plugin.Maui.MarkdownView.Sample/Resources/Styles/DefaultMauiFormattedTextViewSupplierStyles.cs index 753f61d..a65b621 100644 --- a/samples/Plugin.Maui.MarkdownView.Sample/Resources/Styles/DefaultMauiFormattedTextViewSupplierStyles.cs +++ b/samples/Plugin.Maui.MarkdownView.Sample/Resources/Styles/DefaultMauiFormattedTextViewSupplierStyles.cs @@ -19,5 +19,6 @@ public static void LoadDefaultFormattedTextStylesIn(DefaultMauiFormattedTextView styles.SpanHyperlinkTextDarkColor = (Color)resources["MarkdownSpanHyperlinkTextColorDark"]; styles.SpanCodeBackgroundLightColor = (Color)resources["MarkdownSpanCodeBackgroundColorLight"]; styles.SpanCodeBackgroundDarkColor = (Color)resources["MarkdownSpanCodeBackgroundColorDark"]; + styles.LayoutForSplitTextViewStyle = (Style)resources["MarkdownLayoutForSplitTextViewStyle"]; } } \ No newline at end of file diff --git a/samples/Plugin.Maui.MarkdownView.Sample/Resources/Styles/FullMarkdownExampleStyles.xaml b/samples/Plugin.Maui.MarkdownView.Sample/Resources/Styles/FullMarkdownExampleStyles.xaml new file mode 100644 index 0000000..d2236f8 --- /dev/null +++ b/samples/Plugin.Maui.MarkdownView.Sample/Resources/Styles/FullMarkdownExampleStyles.xaml @@ -0,0 +1,336 @@ + + + + #007BC7 + #0888D8 + + #00689B + #007CC0 + + #059D86 + #75D2B7 + + LightGray + Black + + White + #151515 + + #999999 + #999999 + + #007BC7 + #0888D8 + + #E1E1E1 + #6E6E6E + #007BC7 + #0888D8 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/samples/Plugin.Maui.MarkdownView.Sample/Resources/Styles/FullMarkdownExampleStyles.xaml.cs b/samples/Plugin.Maui.MarkdownView.Sample/Resources/Styles/FullMarkdownExampleStyles.xaml.cs new file mode 100644 index 0000000..6fd11d1 --- /dev/null +++ b/samples/Plugin.Maui.MarkdownView.Sample/Resources/Styles/FullMarkdownExampleStyles.xaml.cs @@ -0,0 +1,9 @@ +namespace Plugin.Maui.MarkdownView.Sample.Resources.Styles; + +public partial class FullMarkdownExampleStyles : ResourceDictionary +{ + public FullMarkdownExampleStyles() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/samples/Plugin.Maui.MarkdownView.Sample/Resources/Styles/MarkdownStyles.xaml b/samples/Plugin.Maui.MarkdownView.Sample/Resources/Styles/MarkdownStyles.xaml index 5a5f8bc..2f03d6d 100644 --- a/samples/Plugin.Maui.MarkdownView.Sample/Resources/Styles/MarkdownStyles.xaml +++ b/samples/Plugin.Maui.MarkdownView.Sample/Resources/Styles/MarkdownStyles.xaml @@ -185,4 +185,9 @@ + + diff --git a/samples/Plugin.Maui.MarkdownView.Sample/Views/AudioButton.xaml b/samples/Plugin.Maui.MarkdownView.Sample/Views/AudioButton.xaml new file mode 100644 index 0000000..0baec83 --- /dev/null +++ b/samples/Plugin.Maui.MarkdownView.Sample/Views/AudioButton.xaml @@ -0,0 +1,17 @@ + + + + diff --git a/samples/Plugin.Maui.MarkdownView.Sample/Views/AudioButton.xaml.cs b/samples/Plugin.Maui.MarkdownView.Sample/Views/AudioButton.xaml.cs new file mode 100644 index 0000000..1ac6d2f --- /dev/null +++ b/samples/Plugin.Maui.MarkdownView.Sample/Views/AudioButton.xaml.cs @@ -0,0 +1,64 @@ + +using Plugin.Maui.Audio; + +namespace Plugin.Maui.MarkdownView.Sample.Views; + +public partial class AudioButton +{ + public AudioButton() + { + InitializeComponent(); + } + + public static readonly BindableProperty TextProperty = + BindableProperty.Create(nameof(Text), typeof(string), typeof(AudioButton), string.Empty); + + public string Text + { + get => (string)GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public static readonly BindableProperty AudioFileNameProperty = + BindableProperty.Create(nameof(AudioFileName), typeof(string), typeof(AudioButton), string.Empty); + + public string AudioFileName + { + get => (string)GetValue(AudioFileNameProperty); + set => SetValue(AudioFileNameProperty, value); + } + + public static readonly BindableProperty ColorProperty = + BindableProperty.Create(nameof(Color), typeof(Color), typeof(AudioButton), Colors.White); + + public Color Color + { + get => (Color)GetValue(ColorProperty); + set => SetValue(ColorProperty, value); + } + + public static readonly BindableProperty ButtonColorProperty = + BindableProperty.Create(nameof(ButtonColor), typeof(Color), typeof(AudioButton), Colors.Black); + + public Color ButtonColor + { + get => (Color)GetValue(ButtonColorProperty); + set => SetValue(ButtonColorProperty, value); + } + + public static readonly BindableProperty ButtonStyleProperty = + BindableProperty.Create(nameof(ButtonStyle), typeof(Style), typeof(AudioButton), null); + + public Style ButtonStyle + { + get => (Style)GetValue(ButtonStyleProperty); + set => SetValue(ButtonStyleProperty, value); + } + + private async void OnClicked(object? sender, EventArgs e) + { + var audioFile = await FileSystem.OpenAppPackageFileAsync(AudioFileName); + var audioPlayer = AudioManager.Current.CreatePlayer(audioFile); + audioPlayer.Play(); + } +} \ No newline at end of file diff --git a/samples/Plugin.Maui.MarkdownView.Sample/Views/MenuItemView.xaml b/samples/Plugin.Maui.MarkdownView.Sample/Views/MenuItemView.xaml new file mode 100644 index 0000000..5e800da --- /dev/null +++ b/samples/Plugin.Maui.MarkdownView.Sample/Views/MenuItemView.xaml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + diff --git a/samples/Plugin.Maui.MarkdownView.Sample/Views/MenuItemView.xaml.cs b/samples/Plugin.Maui.MarkdownView.Sample/Views/MenuItemView.xaml.cs new file mode 100644 index 0000000..f6bb764 --- /dev/null +++ b/samples/Plugin.Maui.MarkdownView.Sample/Views/MenuItemView.xaml.cs @@ -0,0 +1,85 @@ +namespace Plugin.Maui.MarkdownView.Sample.Views; + +public partial class MenuItemView +{ + public static readonly BindableProperty UrlProperty = + BindableProperty.Create(nameof(Url), typeof(string), typeof(MenuItemView), string.Empty); + + public string Url + { + get => (string)GetValue(UrlProperty); + set => SetValue(UrlProperty, value); + } + + public static readonly BindableProperty TextProperty = + BindableProperty.Create(nameof(Text), typeof(string), typeof(MenuItemView), string.Empty); + + public string Text + { + get => (string)GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public static readonly BindableProperty IconProperty = + BindableProperty.Create(nameof(Icon), typeof(string), typeof(MenuItemView), string.Empty); + + public string Icon + { + get => (string)GetValue(IconProperty); + set => SetValue(IconProperty, value); + } + + public static readonly BindableProperty IconColorProperty = + BindableProperty.Create(nameof(IconColor), typeof(Color), typeof(MenuItemView), Colors.Black); + + public Color IconColor + { + get => (Color)GetValue(IconColorProperty); + set => SetValue(IconColorProperty, value); + } + + public static readonly BindableProperty ChevronColorProperty = + BindableProperty.Create(nameof(ChevronColor), typeof(Color), typeof(MenuItemView), Colors.Black); + + public Color ChevronColor + { + get => (Color)GetValue(ChevronColorProperty); + set => SetValue(ChevronColorProperty, value); + } + + public static readonly BindableProperty LabelStyleProperty = + BindableProperty.Create(nameof(LabelStyle), typeof(Style), typeof(MenuItemView), null); + + public Style LabelStyle + { + get => (Style)GetValue(LabelStyleProperty); + set => SetValue(LabelStyleProperty, value); + } + + public static readonly BindableProperty SeparatorStyleProperty = + BindableProperty.Create(nameof(SeparatorStyle), typeof(Style), typeof(MenuItemView), null); + + public Style SeparatorStyle + { + get => (Style)GetValue(SeparatorStyleProperty); + set => SetValue(SeparatorStyleProperty, value); + } + + public Func? OnItemTapped { get; set; } + + public MenuItemView() + { + InitializeComponent(); + } + + private async void OnTapped(object? sender, TappedEventArgs e) + { + VisualStateManager.GoToState(this, "Pressed"); + await Task.Delay(50); + if (OnItemTapped != null) + { + await OnItemTapped(Url); + } + VisualStateManager.GoToState(this, "Normal"); + } +} \ No newline at end of file diff --git a/src/Plugin.Maui.MarkdownView/Common/StringHelper.cs b/src/Plugin.Maui.MarkdownView/Common/StringHelper.cs index e261261..a8bb681 100644 --- a/src/Plugin.Maui.MarkdownView/Common/StringHelper.cs +++ b/src/Plugin.Maui.MarkdownView/Common/StringHelper.cs @@ -9,7 +9,7 @@ public static bool HasHttp(this string path) || path.StartsWith("https:"); } - public static string RemoveSpecialCharacters(this string input, char[] allowedSpecialChars) + public static string RemoveSpecialCharactersExcept(this string input, char[] allowedSpecialChars) { var sb = new StringBuilder(); foreach (var chr in input) diff --git a/src/Plugin.Maui.MarkdownView/MarkdownParser.csproj b/src/Plugin.Maui.MarkdownView/MarkdownParser.csproj deleted file mode 100644 index 75ad272..0000000 --- a/src/Plugin.Maui.MarkdownView/MarkdownParser.csproj +++ /dev/null @@ -1,25 +0,0 @@ - - - - net8.0-android;net8.0-ios - true - true - enable - enable - - 11.0 - 21.0 - - - - - - - - - - - - - - diff --git a/src/Plugin.Maui.MarkdownView/MarkdownView.cs b/src/Plugin.Maui.MarkdownView/MarkdownView.cs index 5e09cdc..f61c67a 100644 --- a/src/Plugin.Maui.MarkdownView/MarkdownView.cs +++ b/src/Plugin.Maui.MarkdownView/MarkdownView.cs @@ -15,9 +15,14 @@ public class MarkdownView : ContentView public MarkdownView() { - _layout = new VerticalStackLayout(); + _layout = new VerticalStackLayout + { + IgnoreSafeArea = this.IgnoreSafeArea, + }; Content = _layout; - + + SyncIgnoreSafeAreaSettingToSupplier(); + _logger ??= this.GetLogger(); } @@ -31,6 +36,23 @@ public string space set => SetValue(SpaceProperty, value); } + public static readonly BindableProperty InnerStackLayoutStyleProperty = + BindableProperty.Create(nameof(InnerStackLayoutStyle), typeof(Style), typeof(MarkdownView), default(Style), propertyChanged: InnerStackLayoutStyleChanged); + + private static void InnerStackLayoutStyleChanged(BindableObject bindable, object oldvalue, object newvalue) + { + if (bindable is MarkdownView markdownView) + { + markdownView._layout.Style = (Style)newvalue; + } + } + + public Style InnerStackLayoutStyle + { + get => (Style)GetValue(InnerStackLayoutStyleProperty); + set => SetValue(InnerStackLayoutStyleProperty, value); + } + public static readonly BindableProperty MarkdownTextProperty = BindableProperty.Create(nameof(MarkdownText), typeof(string), typeof(MarkdownView), string.Empty, propertyChanged: MarkdownTextPropertyChanged); @@ -49,12 +71,21 @@ public bool IsLoadingMarkdown private set => SetValue(IsLoadingMarkdownProperty, value); } + public static readonly BindableProperty IgnoreSafeAreaProperty = + BindableProperty.Create(nameof(IgnoreSafeArea), typeof(bool), typeof(MarkdownView), false, propertyChanged: OnIgnoreSafeAreaChanged); + + public bool IgnoreSafeArea + { + get => (bool)GetValue(IgnoreSafeAreaProperty); + private set => SetValue(IgnoreSafeAreaProperty, value); + } + public static readonly BindableProperty ViewSupplierProperty = - BindableProperty.Create(nameof(ViewSupplier), typeof(IViewSupplier), typeof(MarkdownView), null, BindingMode.TwoWay, propertyChanged: OnViewSupplierChanged); + BindableProperty.Create(nameof(ViewSupplier), typeof(IMauiViewSupplier), typeof(MarkdownView), null, BindingMode.TwoWay, propertyChanged: OnViewSupplierChanged); - public IViewSupplier? ViewSupplier + public IMauiViewSupplier? ViewSupplier { - get => (IViewSupplier?)GetValue(ViewSupplierProperty); + get => (IMauiViewSupplier?)GetValue(ViewSupplierProperty); set => SetValue(ViewSupplierProperty, value); } @@ -74,10 +105,31 @@ private static void MarkdownTextPropertyChanged(BindableObject bindable, object private static void OnViewSupplierChanged(BindableObject bindable, object oldvalue, object newvalue) { - if (bindable is MarkdownView markdownView - && !string.IsNullOrWhiteSpace(markdownView.MarkdownText)) + if (bindable is MarkdownView markdownView) { - markdownView.InvalidateMarkdownAsync(); + markdownView.SyncIgnoreSafeAreaSettingToSupplier(); + + if (!string.IsNullOrWhiteSpace(markdownView.MarkdownText)) + { + markdownView.InvalidateMarkdownAsync(); + } + } + } + + private static void OnIgnoreSafeAreaChanged(BindableObject bindable, object oldvalue, object newvalue) + { + if (bindable is MarkdownView markdownView) + { + markdownView._layout.IgnoreSafeArea = (bool)newvalue; + markdownView.SyncIgnoreSafeAreaSettingToSupplier(); + } + } + + protected void SyncIgnoreSafeAreaSettingToSupplier() + { + if (ViewSupplier != null) + { + ViewSupplier.IgnoreSafeArea = IgnoreSafeArea; } } diff --git a/src/Plugin.Maui.MarkdownView/Plugin.Maui.MarkdownView.csproj b/src/Plugin.Maui.MarkdownView/Plugin.Maui.MarkdownView.csproj index df55784..a46ac7e 100644 --- a/src/Plugin.Maui.MarkdownView/Plugin.Maui.MarkdownView.csproj +++ b/src/Plugin.Maui.MarkdownView/Plugin.Maui.MarkdownView.csproj @@ -69,7 +69,7 @@ - + diff --git a/src/Plugin.Maui.MarkdownView/ViewSuppliers/IMauiBasicViewSupplierStyles.cs b/src/Plugin.Maui.MarkdownView/ViewSuppliers/IMauiBasicViewSupplierStyles.cs deleted file mode 100644 index 18b8959..0000000 --- a/src/Plugin.Maui.MarkdownView/ViewSuppliers/IMauiBasicViewSupplierStyles.cs +++ /dev/null @@ -1,154 +0,0 @@ -namespace Plugin.Maui.MarkdownView.ViewSuppliers; - -public interface IMauiBasicViewSupplierStyles -{ - /// - /// VerticalStackLayout style for stacking all Views - /// - Style? LayoutViewStyle { get; set; } - - /// - /// Label style for default TextView - /// - Style? TextViewStyle { get; set; } - - /// - /// Label style for TextView in List - /// - Style? ListTextViewStyle { get; set; } - - /// - /// Label style for TextView in Blockquotes - /// - Style? BlockquotesTextViewStyle { get; set; } - - /// - /// Label style for HeaderView level 1 - /// - Style? HeaderViewLevel1Style { get; set; } - - /// - /// Label style for HeaderView level 2 - /// - Style? HeaderViewLevel2Style { get; set; } - - /// - /// Label style for HeaderView level 3 - /// - Style? HeaderViewLevel3Style { get; set; } - - /// - /// Label style for HeaderView level 4 - /// - Style? HeaderViewLevel4Style { get; set; } - - /// - /// Label style for HeaderView level 5 - /// - Style? HeaderViewLevel5Style { get; set; } - - /// - /// Label style for HeaderView level 6 - /// - Style? HeaderViewLevel6Style { get; set; } - - /// - /// Grid style for BlockquotesView outer layout - /// - Style? BlockquotesOuterViewStyle { get; set; } - - /// - /// Grid style for BlockquotesView inner layout - /// - Style? BlockquotesInnerViewStyle { get; set; } - - /// - /// Border layout style for FencedCodeBlock - /// - Style? FencedCodeBlockLayoutStyle { get; set; } - - /// - /// Label style for FencedCodeBlock - /// - Style? FencedCodeBlockLabelStyle { get; set; } - - /// - /// Border layout style for IndentedCodeBlock - /// - Style? IndentedCodeBlockLayoutStyle { get; set; } - - /// - /// Label style for IndentedCodeBlock - /// - Style? IndentedCodeBlockLabelStyle { get; set; } - - /// - /// VerticalStackLayout style for OrderedList - /// - Style? ListViewStyle { get; set; } - - /// - /// HorizontalStackLayout style for ListItemView - /// - Style? ListItemViewStyle { get; set; } - - /// - /// Label style for the list item bullet level 1 - /// - Style? ListItemBulletViewLevel1Style { get; set; } - - /// - /// Label style for the list item bullet level 2 - /// - Style? ListItemBulletViewLevel2Style { get; set; } - - /// - /// Label style for the list item bullet level 3 - /// - Style? ListItemBulletViewLevel3Style { get; set; } - - /// - /// Label style for the list item bullet level 4 - /// - Style? ListItemBulletViewLevel4Style { get; set; } - - /// - /// Label style for the list item bullet level 5 - /// - Style? ListItemBulletViewLevel5Style { get; set; } - - /// - /// Label style for the list item bullet level 6 - /// - Style? ListItemBulletViewLevel6Style { get; set; } - - /// - /// Image style for the image in the ImageView - /// - Style? ImageViewStyle { get; set; } - - /// - /// VerticalStackLayout style for stacklayout of image and subcription - /// - Style? ImageLayoutViewStyle { get; set; } - - /// - /// Label style for the subscription in the ImageView - /// - Style? ImageSubscriptionViewStyle { get; set; } - - /// - /// Grid layout style for HtmlBlock - /// - Style? HtmlBlockLayoutStyle { get; set; } - - /// - /// Label style for HtmlBlock - /// - Style? HtmlBlockLabelStyle { get; set; } - - /// - /// BoxView style for ThematicBreak - /// - Style? ThematicBreakStyle { get; set; } -} \ No newline at end of file diff --git a/src/Plugin.Maui.MarkdownView/ViewSuppliers/IMauiViewSupplier.cs b/src/Plugin.Maui.MarkdownView/ViewSuppliers/IMauiViewSupplier.cs new file mode 100644 index 0000000..d328fbe --- /dev/null +++ b/src/Plugin.Maui.MarkdownView/ViewSuppliers/IMauiViewSupplier.cs @@ -0,0 +1,8 @@ +using MarkdownParser; + +namespace Plugin.Maui.MarkdownView.ViewSuppliers; + +public interface IMauiViewSupplier : IViewSupplier +{ + bool IgnoreSafeArea { get; set; } +} \ No newline at end of file diff --git a/src/Plugin.Maui.MarkdownView/ViewSuppliers/MauiBasicViewSupplier.cs b/src/Plugin.Maui.MarkdownView/ViewSuppliers/MauiBasicViewSupplier.cs index a2de558..d75fbfd 100644 --- a/src/Plugin.Maui.MarkdownView/ViewSuppliers/MauiBasicViewSupplier.cs +++ b/src/Plugin.Maui.MarkdownView/ViewSuppliers/MauiBasicViewSupplier.cs @@ -1,25 +1,37 @@ -using MarkdownParser; -using MarkdownParser.Models; +using MarkdownParser.Models; using Plugin.Maui.MarkdownView.Common; using Plugin.Maui.MarkdownView.Controls; namespace Plugin.Maui.MarkdownView.ViewSuppliers; -public class MauiBasicViewSupplier : IViewSupplier +public class MauiBasicViewSupplier : IMauiViewSupplier { - public IMauiBasicViewSupplierStyles? Styles { get; set; } + public MauiBasicViewSupplierStyles? Styles { get; set; } public string? BasePathForRelativeUrlConversion { get; set; } public string[]? PrefixesToIgnoreForRelativeUrlConversion { get; set; } public IEnumerable? PublishedMarkdownReferenceDefinitions { get; private set; } + public bool IgnoreSafeArea { get; set; } + + public bool DoesReferenceDefinitionsContainPlaceholder(string placeholderId) + { + if (string.IsNullOrWhiteSpace(placeholderId)) + { + return false; + } + + var isKnown = PublishedMarkdownReferenceDefinitions?.Any(x => x.PlaceholderId == placeholderId) ?? false; + return isKnown; + } + public virtual void OnReferenceDefinitionsPublished(IEnumerable markdownReferenceDefinitions) { PublishedMarkdownReferenceDefinitions = markdownReferenceDefinitions; } - public virtual View CreateTextView(TextBlock textBlock) + public virtual View? CreateTextView(TextBlock textBlock) { var content = textBlock.ExtractLiteralContent(Environment.NewLine); var textViewStyle = GetTextBlockStyleFor(textBlock); @@ -33,24 +45,31 @@ public virtual View CreateTextView(TextBlock textBlock) return textview; } - public virtual View CreateBlockquotesView(View childView) + public virtual View? CreateBlockquotesView(View? childView) { + if (childView == null) + { + return null; + } + var innerBlockView = new Grid { Style = Styles?.BlockquotesInnerViewStyle, + IgnoreSafeArea = IgnoreSafeArea, Children = { childView } }; var outerBlockView = new Grid() { Style = Styles?.BlockquotesOuterViewStyle, + IgnoreSafeArea = IgnoreSafeArea, Children = { innerBlockView } }; return outerBlockView; } - public virtual View CreateHeaderView(TextBlock textBlock, int headerLevel) + public virtual View? CreateHeaderView(TextBlock textBlock, int headerLevel) { var content = textBlock.ExtractLiteralContent(Environment.NewLine); @@ -66,7 +85,7 @@ public virtual View CreateHeaderView(TextBlock textBlock, int headerLevel) }; var headingId = content.Replace(" ", "-") - .RemoveSpecialCharacters(['-']); + .RemoveSpecialCharactersExcept(['-']); var header = new HeaderLabel { @@ -78,7 +97,7 @@ public virtual View CreateHeaderView(TextBlock textBlock, int headerLevel) return header; } - public virtual View CreateImageView(string url, string subscription, string imageId) + public virtual View? CreateImageView(string url, string subscription, string imageId) { if (string.IsNullOrWhiteSpace(url)) { @@ -89,7 +108,8 @@ public virtual View CreateImageView(string url, string subscription, string imag var stackLayout = new VerticalStackLayout { - Style = Styles?.ImageLayoutViewStyle + Style = Styles?.ImageLayoutViewStyle, + IgnoreSafeArea = IgnoreSafeArea }; var imageView = new Image @@ -126,26 +146,46 @@ public virtual View CreateImageView(string url, string subscription, string imag return stackLayout; } - public virtual View CreateListItemView(View childView, bool isOrderedList, int sequenceNumber, int listLevel) + public virtual View? CreateListItemView(View? childView, bool isOrderedList, int sequenceNumber, int listLevel) { - var stackLayout = new HorizontalStackLayout + if (childView == null) + { + return null; + } + + var layout = new Grid() { - Style = Styles?.ListItemViewStyle + Style = Styles?.ListItemViewStyle, + IgnoreSafeArea = IgnoreSafeArea, + ColumnDefinitions = + [ + new ColumnDefinition { Width = GridLength.Auto }, + new ColumnDefinition { Width = GridLength.Star } + ] }; var bulletView = CreateListItemBullet(isOrderedList, sequenceNumber, listLevel); + Grid.SetColumn(bulletView, 0); + Grid.SetColumn(childView, 1); - stackLayout.Children.Add(bulletView); - stackLayout.Children.Add(childView); + layout.Children.Add(bulletView); + layout.Children.Add(childView); - return stackLayout; + return layout; } - public virtual View CreateListView(List items) + public virtual View? CreateListView(List items) { + if (items == null + || !items.Any()) + { + return null; + } + var stackLayout = new VerticalStackLayout { - Style = Styles?.ListViewStyle + Style = Styles?.ListViewStyle, + IgnoreSafeArea = IgnoreSafeArea }; foreach (var view in items) @@ -156,16 +196,7 @@ public virtual View CreateListView(List items) return stackLayout; } - public virtual View CreatePlaceholder(string placeholderName) - { - return new Label - { - LineBreakMode = LineBreakMode.WordWrap, - Text = $"PLACEHOLDER - {placeholderName} - PLACEHOLDER" - }; - } - - public virtual View CreateFencedCodeBlock(TextBlock textBlock, string codeInfo) + public virtual View? CreateFencedCodeBlock(TextBlock textBlock, string codeInfo) { var content = textBlock.ExtractLiteralContent(Environment.NewLine); var label = new Label @@ -183,7 +214,7 @@ public virtual View CreateFencedCodeBlock(TextBlock textBlock, string codeInfo) return blockView; } - public virtual View CreateIndentedCodeBlock(TextBlock textBlock) + public virtual View? CreateIndentedCodeBlock(TextBlock textBlock) { var content = textBlock.ExtractLiteralContent(Environment.NewLine); var label = new Label @@ -201,7 +232,7 @@ public virtual View CreateIndentedCodeBlock(TextBlock textBlock) return blockView; } - public virtual View CreateHtmlBlock(TextBlock textBlock) + public virtual View? CreateHtmlBlock(TextBlock textBlock) { var content = textBlock.ExtractLiteralContent(Environment.NewLine); var label = new Label @@ -213,17 +244,24 @@ public virtual View CreateHtmlBlock(TextBlock textBlock) var blockView = new Grid { Style = Styles?.HtmlBlockLayoutStyle, + IgnoreSafeArea = IgnoreSafeArea, Children = { label } }; return blockView; } - public virtual View CreateStackLayoutView(List childViews) + public virtual View? CreateStackLayoutView(List childViews) { + if (childViews == null) + { + return null; + } + var stackLayout = new VerticalStackLayout { Style = Styles?.LayoutViewStyle, + IgnoreSafeArea = IgnoreSafeArea }; foreach (var view in childViews) @@ -234,7 +272,7 @@ public virtual View CreateStackLayoutView(List childViews) return stackLayout; } - public virtual View CreateThematicBreak() + public virtual View? CreateThematicBreak() { return new BoxView { @@ -247,7 +285,7 @@ public virtual void Clear() PublishedMarkdownReferenceDefinitions = []; } - protected virtual View CreateListItemBullet(bool isOrderedList, int sequenceNumber, int listLevel) + protected virtual View? CreateListItemBullet(bool isOrderedList, int sequenceNumber, int listLevel) { var bulletStyle = listLevel switch { diff --git a/src/Plugin.Maui.MarkdownView/ViewSuppliers/MauiBasicViewSupplierStyles.cs b/src/Plugin.Maui.MarkdownView/ViewSuppliers/MauiBasicViewSupplierStyles.cs index 3938881..65b103c 100644 --- a/src/Plugin.Maui.MarkdownView/ViewSuppliers/MauiBasicViewSupplierStyles.cs +++ b/src/Plugin.Maui.MarkdownView/ViewSuppliers/MauiBasicViewSupplierStyles.cs @@ -1,6 +1,6 @@ namespace Plugin.Maui.MarkdownView.ViewSuppliers; -public class MauiBasicViewSupplierStyles : IMauiBasicViewSupplierStyles +public class MauiBasicViewSupplierStyles { /// /// VerticalStackLayout style for stacking all Views @@ -88,7 +88,7 @@ public class MauiBasicViewSupplierStyles : IMauiBasicViewSupplierStyles public Style? ListViewStyle { get; set; } /// - /// HorizontalStackLayout style for ListItemView + /// Grid style for ListItemView /// public Style? ListItemViewStyle { get; set; } diff --git a/src/Plugin.Maui.MarkdownView/ViewSuppliers/MauiFormattedTextViewSupplier.cs b/src/Plugin.Maui.MarkdownView/ViewSuppliers/MauiFormattedTextViewSupplier.cs index 9696683..4cb564c 100644 --- a/src/Plugin.Maui.MarkdownView/ViewSuppliers/MauiFormattedTextViewSupplier.cs +++ b/src/Plugin.Maui.MarkdownView/ViewSuppliers/MauiFormattedTextViewSupplier.cs @@ -11,21 +11,44 @@ public class MauiFormattedTextViewSupplier : MauiBasicViewSupplier { public MauiFormattedTextViewSupplierStyles? FormattedTextStyles { get; set; } - public override View CreateTextView(TextBlock textBlock) + public Func? OnHyperlinkTappedFallback { get; set; } + + public override View? CreateTextView(TextBlock textBlock) { var textViewStyle = GetTextBlockStyleFor(textBlock); var spanStyle = GetSpanStyleFor(textBlock); - var formattedString = new FormattedString(); + var collectedRootViews = new List(); + var textSpansCache = new List(); var activeIndicators = new List(); LinkSegment? activeLinkSegment = null; - foreach (var textBlockSegment in textBlock.TextSegments) + foreach (var segment in textBlock.TextSegments) { - var literalContent = textBlockSegment.ToString(); - - if (textBlockSegment is IndicatorSegment indicatorSegment) + // Placeholders like [my placeholder] can be one of two types: + // 1. Inline placeholder like a hyperlink (from ReferenceDefinitions) => this is handled by the default span and label creation + // 2. Designated Placeholder to be a custom view => this is handled by the TryCreateDesignatedPlaceholderView method + if (segment is PlaceholderSegment placeholderSegment + && !DoesReferenceDefinitionsContainPlaceholder(placeholderSegment.PlaceholderId)) + { + // Designated Placeholders cannot be placed inside a label + // because of this, we need to break current formatted span/label creation to inject the placeholder in between + var designatedPlaceholderView = CreateDesignatedPlaceholderView(placeholderSegment); + + // flush current Formatted spans if any + var view = TryCreateFormattedLabel(textSpansCache, textViewStyle); + collectedRootViews.Add(view); + textSpansCache.Clear(); + + // add designated Placeholder between to (possible) TextViews + collectedRootViews.Add(designatedPlaceholderView); + + continue; + } + + // Special segments like indicators + if (segment is IndicatorSegment indicatorSegment) { switch (indicatorSegment.Indicator) { @@ -50,84 +73,137 @@ public override View CreateTextView(TextBlock textBlock) break; case SegmentIndicator.LineBreak: - formattedString.Spans.Add(new Span { Text = Environment.NewLine }); + textSpansCache.Add(new Span { Text = Environment.NewLine }); break; } } - - if (!string.IsNullOrWhiteSpace(literalContent)) + + // Segments with text + if (segment.HasLiteralContent) { - Span span; - if (activeIndicators.Contains(SegmentIndicator.Link)) - { - var text = string.IsNullOrWhiteSpace(activeLinkSegment?.Title) - ? literalContent - : activeLinkSegment.Title; - var url = activeLinkSegment?.Url ?? string.Empty; - - span = new HyperlinkSpan - { - Text = text, - Url = url, - Style = spanStyle, - TextDecorations = TextDecorations.Underline, - Command = new Command(OnHyperlinkTappedAsync) - }; - - if (FormattedTextStyles?.SpanHyperlinkTextLightColor != null - && FormattedTextStyles?.SpanHyperlinkTextDarkColor != null) - { - span.SetAppThemeColor(Span.TextColorProperty, - FormattedTextStyles.SpanHyperlinkTextLightColor, - FormattedTextStyles.SpanHyperlinkTextDarkColor); - } - } - else - { - span = new Span - { - Text = literalContent, - Style = spanStyle - }; - } - - if (activeIndicators.Contains(SegmentIndicator.Italic) - && activeIndicators.Contains(SegmentIndicator.Strong)) - { - span.FontAttributes = FontAttributes.Italic | FontAttributes.Italic; - } - else if (activeIndicators.Contains(SegmentIndicator.Strong)) - { - span.FontAttributes = FontAttributes.Bold; - } - else if (activeIndicators.Contains(SegmentIndicator.Italic)) - { - span.FontAttributes = FontAttributes.Italic; - } + var span = CreateSpan(segment, activeIndicators, activeLinkSegment, spanStyle); + AddFormattingToSpan(span, activeIndicators); + + textSpansCache.Add(span); + } + } + + var textview = TryCreateFormattedLabel(textSpansCache, textViewStyle); + collectedRootViews.Add(textview); - if (activeIndicators.Contains(SegmentIndicator.Code)) + var flattenedView = FlattenToSingleView(collectedRootViews); + return flattenedView; + } + + protected virtual Span CreateSpan(BaseSegment segment, + List activeIndicators, + LinkSegment? activeLinkSegment, + Style? spanStyle) + { + var literalContent = segment.ToString(); + Span? span = null; + + if (segment is PlaceholderSegment placeholderTextBlockSegment + && DoesReferenceDefinitionsContainPlaceholder(placeholderTextBlockSegment.PlaceholderId)) + { + var foundReferenceDefinition = PublishedMarkdownReferenceDefinitions? + .First(x => x.PlaceholderId == placeholderTextBlockSegment.PlaceholderId); + + if (foundReferenceDefinition != null) + { + var url = foundReferenceDefinition.Url ?? foundReferenceDefinition.Title ?? string.Empty; + + span = new HyperlinkSpan { - span.SetAppThemeColor(Span.BackgroundColorProperty, - FormattedTextStyles?.SpanCodeBackgroundLightColor ?? Colors.Transparent, - FormattedTextStyles?.SpanCodeBackgroundDarkColor ?? Colors.Transparent); - } + Text = literalContent, + Url = url, + Style = spanStyle, + TextDecorations = TextDecorations.Underline, + Command = new Command(OnHyperlinkTappedAsync) + }; - if (activeIndicators.Contains(SegmentIndicator.Strikethrough)) + if (FormattedTextStyles?.SpanHyperlinkTextLightColor != null + && FormattedTextStyles?.SpanHyperlinkTextDarkColor != null) { - span.TextDecorations = TextDecorations.Strikethrough; + span.SetAppThemeColor(Span.TextColorProperty, + FormattedTextStyles.SpanHyperlinkTextLightColor, + FormattedTextStyles.SpanHyperlinkTextDarkColor); } + } + } + else if (activeIndicators.Contains(SegmentIndicator.Link)) + { + var text = string.IsNullOrWhiteSpace(activeLinkSegment?.Title) + ? literalContent + : activeLinkSegment.Title; + var url = activeLinkSegment?.Url ?? string.Empty; - formattedString.Spans.Add(span); + span = new HyperlinkSpan + { + Text = text, + Url = url, + Style = spanStyle, + TextDecorations = TextDecorations.Underline, + Command = new Command(OnHyperlinkTappedAsync) + }; + + if (FormattedTextStyles?.SpanHyperlinkTextLightColor != null + && FormattedTextStyles?.SpanHyperlinkTextDarkColor != null) + { + span.SetAppThemeColor(Span.TextColorProperty, + FormattedTextStyles.SpanHyperlinkTextLightColor, + FormattedTextStyles.SpanHyperlinkTextDarkColor); } } - var textview = new Label + // default span creation + span ??= new Span { - Style = textViewStyle, - FormattedText = formattedString + Text = literalContent, + Style = spanStyle }; - return textview; + return span; + } + + protected virtual void AddFormattingToSpan(Span span, List activeIndicators) + { + if (activeIndicators.Contains(SegmentIndicator.Italic) + && activeIndicators.Contains(SegmentIndicator.Strong)) + { + span.FontAttributes = FontAttributes.Italic | FontAttributes.Italic; + } + else if (activeIndicators.Contains(SegmentIndicator.Strong)) + { + span.FontAttributes = FontAttributes.Bold; + } + else if (activeIndicators.Contains(SegmentIndicator.Italic)) + { + span.FontAttributes = FontAttributes.Italic; + } + + if (activeIndicators.Contains(SegmentIndicator.Code)) + { + span.SetAppThemeColor(Span.BackgroundColorProperty, + FormattedTextStyles?.SpanCodeBackgroundLightColor ?? Colors.Transparent, + FormattedTextStyles?.SpanCodeBackgroundDarkColor ?? Colors.Transparent); + } + + if (activeIndicators.Contains(SegmentIndicator.Strikethrough)) + { + span.TextDecorations = TextDecorations.Strikethrough; + } + } + + protected virtual View? CreateDesignatedPlaceholderView(PlaceholderSegment placeholderSegment) + { + var mySpecialView = new Label + { + Style = Styles?.TextViewStyle, + Text = $">>>> placeholder '{placeholderSegment.PlaceholderId}' not found <<<<" + }; + + return mySpecialView; } protected virtual async void OnHyperlinkTappedAsync(HyperlinkSpan hyperlinkSpan) @@ -147,7 +223,13 @@ protected virtual async void OnHyperlinkTappedAsync(HyperlinkSpan hyperlinkSpan) return; } - await Launcher.OpenAsync(new OpenFileRequest(hyperlinkSpan.Text, new ReadOnlyFile(url))); + if (OnHyperlinkTappedFallback != null) + { + await OnHyperlinkTappedFallback(hyperlinkSpan); + return; + } + + throw new NotImplementedException("No fallback for OnHyperlinkTappedAsync available in OnHyperlinkTappedFallback"); } catch (Exception exception) { @@ -162,11 +244,60 @@ protected virtual async void OnHyperlinkTappedAsync(HyperlinkSpan hyperlinkSpan) { return FormattedTextStyles?.SpanBlockquotesTextViewStyle; } - else if (textBlock.AncestorsTree.Contains(BlockType.List)) + + if (textBlock.AncestorsTree.Contains(BlockType.List)) { return FormattedTextStyles?.SpanListTextViewStyle; } return FormattedTextStyles?.SpanTextViewStyle; } + + protected virtual View? TryCreateFormattedLabel(List textSpans, Style? style) + { + if (!textSpans.Any()) + { + return null; + } + + var formattedString = new FormattedString(); + foreach (var textSpan in textSpans) + { + formattedString.Spans.Add(textSpan); + } + + var textview = new Label + { + Style = style, + FormattedText = formattedString + }; + + return textview; + } + + protected virtual View? FlattenToSingleView(List views) + { + var validViews = views.Where(x => x != null).ToList(); + + switch (validViews.Count) + { + case 0: + return null; + case 1: + return validViews.First(); + default: + var stackLayout = new VerticalStackLayout + { + Style = FormattedTextStyles?.LayoutForSplitTextViewStyle, + IgnoreSafeArea = IgnoreSafeArea + }; + + foreach (var view in validViews) + { + stackLayout.Add(view); + } + + return stackLayout; + } + } } \ No newline at end of file diff --git a/src/Plugin.Maui.MarkdownView/ViewSuppliers/MauiFormattedTextViewSupplierStyles.cs b/src/Plugin.Maui.MarkdownView/ViewSuppliers/MauiFormattedTextViewSupplierStyles.cs index 33d2e92..3059914 100644 --- a/src/Plugin.Maui.MarkdownView/ViewSuppliers/MauiFormattedTextViewSupplierStyles.cs +++ b/src/Plugin.Maui.MarkdownView/ViewSuppliers/MauiFormattedTextViewSupplierStyles.cs @@ -36,4 +36,9 @@ public class MauiFormattedTextViewSupplierStyles /// Span style for TextView in Blockquotes /// public Style? SpanBlockquotesTextViewStyle { get; set; } + + /// + /// VerticalStackLayout style for Views when broken by designated placeholder + /// + public Style? LayoutForSplitTextViewStyle { get; set; } } \ No newline at end of file