Skip to content

Commit 153c530

Browse files
authored
Merge pull request #214 from reactivemarbles/UpdateRichTextBox
Add FlowDocument and HTML support to RichTextBox
2 parents 417ce77 + 326bb20 commit 153c530

File tree

14 files changed

+1993
-492
lines changed

14 files changed

+1993
-492
lines changed

src/CrissCross.Avalonia.UI.Gallery/Views/Pages/InputPageView.axaml

Lines changed: 166 additions & 80 deletions
Large diffs are not rendered by default.

src/CrissCross.Avalonia.UI/Controls/RichTextBox/FlowDocument.cs

Lines changed: 550 additions & 0 deletions
Large diffs are not rendered by default.

src/CrissCross.Avalonia.UI/Controls/RichTextBox/FormattedTextPresenter.cs

Lines changed: 202 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,18 @@
22
// ReactiveUI Association Incorporated licenses this file to you under the MIT license.
33
// See the LICENSE file in the project root for full license information.
44

5+
using System;
6+
using System.IO;
7+
using System.Net.Http;
58
using Avalonia;
69
using Avalonia.Controls;
710
using Avalonia.Controls.Documents;
11+
using Avalonia.Layout;
812
using Avalonia.Media;
13+
using Avalonia.Media.Imaging;
14+
using Avalonia.Platform;
15+
using DocumentsInline = Avalonia.Controls.Documents.Inline;
16+
using DocumentsInlineUIContainer = Avalonia.Controls.Documents.InlineUIContainer;
917

1018
namespace CrissCross.Avalonia.UI.Controls;
1119

@@ -17,8 +25,8 @@ public class FormattedTextPresenter : SelectableTextBlock
1725
/// <summary>
1826
/// Property for <see cref="Document"/>.
1927
/// </summary>
20-
public static readonly StyledProperty<RichTextDocument?> DocumentProperty =
21-
AvaloniaProperty.Register<FormattedTextPresenter, RichTextDocument?>(nameof(Document));
28+
public static readonly StyledProperty<FlowDocument?> DocumentProperty =
29+
AvaloniaProperty.Register<FormattedTextPresenter, FlowDocument?>(nameof(Document));
2230

2331
/// <summary>
2432
/// Property for <see cref="DefaultForeground"/>.
@@ -32,6 +40,8 @@ public class FormattedTextPresenter : SelectableTextBlock
3240
public static readonly StyledProperty<double> DefaultFontSizeProperty =
3341
AvaloniaProperty.Register<FormattedTextPresenter, double>(nameof(DefaultFontSize), 14);
3442

43+
private static readonly HttpClient HttpClient = new();
44+
3545
/// <summary>
3646
/// Initializes a new instance of the <see cref="FormattedTextPresenter"/> class.
3747
/// </summary>
@@ -44,7 +54,7 @@ public FormattedTextPresenter()
4454
/// <summary>
4555
/// Gets or sets the document to display.
4656
/// </summary>
47-
public RichTextDocument? Document
57+
public FlowDocument? Document
4858
{
4959
get => GetValue(DocumentProperty);
5060
set => SetValue(DocumentProperty, value);
@@ -80,45 +90,57 @@ public void UpdateInlines()
8090
return;
8191
}
8292

83-
Inlines ??= [];
84-
8593
foreach (var segment in Document.Segments)
8694
{
87-
var run = new Run(segment.Text)
95+
if (segment.IsParagraphBreak)
8896
{
89-
FontWeight = segment.FontWeight,
90-
FontStyle = segment.FontStyle,
91-
};
97+
Inlines!.Add(new LineBreak());
98+
Inlines.Add(new LineBreak());
99+
continue;
100+
}
92101

93-
// Apply text decorations
94-
if (segment.TextDecorations is { } decorations)
102+
if (segment.IsLineBreak)
95103
{
96-
run.TextDecorations = decorations;
104+
Inlines!.Add(new LineBreak());
105+
continue;
97106
}
98107

99-
// Apply custom foreground
100-
if (segment.Foreground is not null)
108+
if (segment.IsImage)
101109
{
102-
run.Foreground = segment.Foreground;
110+
var imageInline = CreateImageInline(segment);
111+
if (imageInline is not null)
112+
{
113+
Inlines!.Add(imageInline);
114+
}
115+
116+
continue;
103117
}
104-
else if (DefaultForeground is not null)
118+
119+
if (!segment.HasRenderableText)
105120
{
106-
run.Foreground = DefaultForeground;
121+
continue;
107122
}
108123

109-
// Apply custom font size
110-
if (segment.FontSize.HasValue)
124+
var run = new Run(segment.Text)
111125
{
112-
run.FontSize = segment.FontSize.Value;
126+
FontWeight = segment.FontWeight,
127+
FontStyle = segment.FontStyle,
128+
FontSize = segment.FontSize ?? DefaultFontSize,
129+
FontFamily = segment.FontFamily ?? FontFamily,
130+
};
131+
132+
if (segment.TextDecorations is { } decorations)
133+
{
134+
run.TextDecorations = decorations;
113135
}
114136

115-
// Apply custom font family
116-
if (segment.FontFamily is not null)
137+
run.Foreground = segment.Foreground ?? DefaultForeground ?? Foreground;
138+
if (segment.Background is not null)
117139
{
118-
run.FontFamily = segment.FontFamily;
140+
run.Background = segment.Background;
119141
}
120142

121-
Inlines.Add(run);
143+
Inlines!.Add(run);
122144
}
123145
}
124146

@@ -142,4 +164,160 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang
142164
UpdateInlines();
143165
}
144166
}
167+
168+
private static DocumentsInline? CreateImageInline(TextSegment segment)
169+
{
170+
if (string.IsNullOrWhiteSpace(segment.ImageSource))
171+
{
172+
return null;
173+
}
174+
175+
if (!TryLoadImage(segment.ImageSource, out var bitmap))
176+
{
177+
return null;
178+
}
179+
180+
var image = new Image
181+
{
182+
Source = bitmap,
183+
Stretch = Stretch.Uniform,
184+
HorizontalAlignment = segment.ImageAlignment,
185+
};
186+
187+
if (segment.ImageWidth.HasValue)
188+
{
189+
image.Width = segment.ImageWidth.Value;
190+
}
191+
192+
if (segment.ImageHeight.HasValue)
193+
{
194+
image.Height = segment.ImageHeight.Value;
195+
}
196+
197+
return new DocumentsInlineUIContainer
198+
{
199+
Child = new Border
200+
{
201+
HorizontalAlignment = HorizontalAlignment.Stretch,
202+
Child = image,
203+
}
204+
};
205+
}
206+
207+
private static bool TryLoadImage(string source, out IImage? bitmap)
208+
{
209+
bitmap = null;
210+
211+
try
212+
{
213+
if (TryLoadDataUri(source, out bitmap))
214+
{
215+
return true;
216+
}
217+
218+
if (Uri.TryCreate(source, UriKind.Absolute, out var absolute))
219+
{
220+
if (absolute.IsFile)
221+
{
222+
var localPath = absolute.LocalPath;
223+
if (File.Exists(localPath))
224+
{
225+
using var fileStream = File.OpenRead(localPath);
226+
bitmap = CreateBitmapFromStream(fileStream);
227+
return true;
228+
}
229+
230+
return false;
231+
}
232+
233+
if (absolute.Scheme is "http" or "https")
234+
{
235+
return TryLoadFromHttp(absolute, out bitmap);
236+
}
237+
238+
using var assetStream = AssetLoader.Open(absolute);
239+
bitmap = CreateBitmapFromStream(assetStream);
240+
return true;
241+
}
242+
243+
if (File.Exists(source))
244+
{
245+
using var fallbackFile = File.OpenRead(source);
246+
bitmap = CreateBitmapFromStream(fallbackFile);
247+
return true;
248+
}
249+
}
250+
catch
251+
{
252+
bitmap = null;
253+
}
254+
255+
return false;
256+
}
257+
258+
private static bool TryLoadDataUri(string source, out IImage? bitmap)
259+
{
260+
bitmap = null;
261+
if (!source.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
262+
{
263+
return false;
264+
}
265+
266+
var commaIndex = source.IndexOf(',');
267+
if (commaIndex < 0)
268+
{
269+
return false;
270+
}
271+
272+
var metadata = source[..commaIndex];
273+
if (!metadata.Contains(";base64", StringComparison.OrdinalIgnoreCase))
274+
{
275+
return false;
276+
}
277+
278+
var payload = source[(commaIndex + 1)..].Trim();
279+
if (payload.Length == 0)
280+
{
281+
return false;
282+
}
283+
284+
try
285+
{
286+
var bytes = Convert.FromBase64String(payload);
287+
using var memory = new MemoryStream(bytes);
288+
bitmap = new Bitmap(memory);
289+
return true;
290+
}
291+
catch
292+
{
293+
bitmap = null;
294+
return false;
295+
}
296+
}
297+
298+
private static bool TryLoadFromHttp(Uri uri, out IImage? bitmap)
299+
{
300+
bitmap = null;
301+
try
302+
{
303+
using var response = HttpClient.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead).GetAwaiter().GetResult();
304+
response.EnsureSuccessStatusCode();
305+
using var stream = response.Content.ReadAsStreamAsync().GetAwaiter().GetResult();
306+
bitmap = CreateBitmapFromStream(stream);
307+
return true;
308+
}
309+
catch
310+
{
311+
bitmap = null;
312+
return false;
313+
}
314+
}
315+
316+
private static Bitmap CreateBitmapFromStream(Stream stream)
317+
{
318+
using var buffer = new MemoryStream();
319+
stream.CopyTo(buffer);
320+
buffer.Position = 0;
321+
return new Bitmap(buffer);
322+
}
145323
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// Copyright (c) 2019-2025 ReactiveUI Association Incorporated. All rights reserved.
2+
// ReactiveUI Association Incorporated licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for full license information.
4+
5+
using System;
6+
using System.Globalization;
7+
using System.Net;
8+
using System.Text;
9+
10+
namespace CrissCross.Avalonia.UI.Controls;
11+
12+
internal static class HtmlClipboardUtilities
13+
{
14+
public static string ExtractFragment(string html)
15+
{
16+
if (string.IsNullOrWhiteSpace(html))
17+
{
18+
return string.Empty;
19+
}
20+
21+
var start = GetFragmentIndex(html, "StartFragment:");
22+
var end = GetFragmentIndex(html, "EndFragment:");
23+
if (start >= 0 && end > start && end <= html.Length)
24+
{
25+
return html[start..end];
26+
}
27+
28+
const string startMarker = "<!--StartFragment-->";
29+
const string endMarker = "<!--EndFragment-->";
30+
var startMarkerIndex = html.IndexOf(startMarker, StringComparison.OrdinalIgnoreCase);
31+
if (startMarkerIndex >= 0)
32+
{
33+
startMarkerIndex += startMarker.Length;
34+
var endMarkerIndex = html.IndexOf(endMarker, startMarkerIndex, StringComparison.OrdinalIgnoreCase);
35+
if (endMarkerIndex > startMarkerIndex)
36+
{
37+
return html[startMarkerIndex..endMarkerIndex];
38+
}
39+
}
40+
41+
return html;
42+
}
43+
44+
public static string EncodePlainText(string? text)
45+
{
46+
if (string.IsNullOrEmpty(text))
47+
{
48+
return string.Empty;
49+
}
50+
51+
var encoded = WebUtility.HtmlEncode(text);
52+
return encoded.Replace("\r\n", "<br />", StringComparison.Ordinal)
53+
.Replace("\n", "<br />", StringComparison.Ordinal)
54+
.Replace("\r", "<br />", StringComparison.Ordinal);
55+
}
56+
57+
private static int GetFragmentIndex(string html, string marker)
58+
{
59+
var markerIndex = html.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
60+
if (markerIndex < 0)
61+
{
62+
return -1;
63+
}
64+
65+
var indexStart = markerIndex + marker.Length;
66+
var indexEnd = indexStart;
67+
while (indexEnd < html.Length && char.IsDigit(html[indexEnd]))
68+
{
69+
indexEnd++;
70+
}
71+
72+
if (indexEnd > indexStart &&
73+
int.TryParse(html[indexStart..indexEnd], NumberStyles.Integer, CultureInfo.InvariantCulture, out var value))
74+
{
75+
return value;
76+
}
77+
78+
return -1;
79+
}
80+
}

0 commit comments

Comments
 (0)