Skip to content
251 changes: 251 additions & 0 deletions StabilityMatrix.Avalonia/Controls/DocumentationMarkdownViewer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Windows.Input;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
using Avalonia.Threading;
using Avalonia.VisualTree;
using ColorTextBlock.Avalonia;
using Markdown.Avalonia;
using StabilityMatrix.Core.Models.Documentation;

namespace StabilityMatrix.Avalonia.Controls;

/// <summary>
/// A <see cref="BetterMarkdownScrollViewer"/> that routes hyperlink clicks through a
/// bindable <see cref="LinkCommand"/> (so relative <c>.md</c> links can navigate in-app
/// and external links can open in the browser) and resolves relative image paths against
/// <see cref="ImageBaseUrl"/> via the engine's asset path root.
/// </summary>
public class DocumentationMarkdownViewer : BetterMarkdownScrollViewer
{
/// <summary>
/// Command invoked when a hyperlink is clicked. The command parameter is the raw href string.
/// </summary>
public static readonly StyledProperty<ICommand?> LinkCommandProperty = AvaloniaProperty.Register<
DocumentationMarkdownViewer,
ICommand?
>(nameof(LinkCommand));

/// <summary>
/// Base URL used to resolve relative image paths in the rendered markdown
/// (e.g. the raw URL of the current page's folder).
/// </summary>
public static readonly StyledProperty<string?> ImageBaseUrlProperty = AvaloniaProperty.Register<
DocumentationMarkdownViewer,
string?
>(nameof(ImageBaseUrl));

public ICommand? LinkCommand
{
get => GetValue(LinkCommandProperty);
set => SetValue(LinkCommandProperty, value);
}

/// <summary>
/// Zoom factor applied to the rendered document content (1.0 = 100%).
/// Scales the content inside the internal scroll viewer, so the scrollbar is unaffected.
/// </summary>
public static readonly StyledProperty<double> ContentZoomProperty = AvaloniaProperty.Register<
DocumentationMarkdownViewer,
double
>(nameof(ContentZoom), 1.0);

public string? ImageBaseUrl
{
get => GetValue(ImageBaseUrlProperty);
set => SetValue(ImageBaseUrlProperty, value);
}

public double ContentZoom
{
get => GetValue(ContentZoomProperty);
set => SetValue(ContentZoomProperty, value);
}

/// <summary>
/// Hosts the document content inside the internal scroll viewer so zoom can scale the
/// content without scaling the scrollbar. Null if the base control's composition changes.
/// </summary>
private readonly LayoutTransformControl? zoomHost;

public DocumentationMarkdownViewer()
{
ApplyLinkCommand();
ApplyImageBaseUrl();

// The base ctor composes a non-templated inner ScrollViewer (a direct visual child)
// whose Content is the document wrapper, and never reassigns Content afterwards
// (page changes only swap the wrapper's Document). Re-parent the wrapper into a
// LayoutTransformControl so zoom scales the document but not the scrollbar, and add
// right margin so the overlay scrollbar doesn't cover the rightmost text.
if (this.GetVisualChildren().OfType<ScrollViewer>().FirstOrDefault() is { } innerViewer)
{
if (innerViewer.Content is Control content)
{
innerViewer.Content = null;
zoomHost = new LayoutTransformControl
{
Child = content,
Margin = new Thickness(0, 0, 18, 0),
};
innerViewer.Content = zoomHost;
}
else
{
// Fallback: at least keep the scrollbar off the content.
innerViewer.Padding = new Thickness(0, 0, 18, 0);
}
}
}

protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);

if (change.Property == LinkCommandProperty)
{
ApplyLinkCommand();
}
else if (change.Property == ImageBaseUrlProperty)
{
ApplyImageBaseUrl();
}
Comment thread
mohnjiles marked this conversation as resolved.
else if (change.Property == ContentZoomProperty)
{
ApplyContentZoom();
}
}

private void ApplyContentZoom()
{
if (zoomHost is null)
return;

// Guard against zero/negative values from bad bindings.
var zoom = Math.Clamp(ContentZoom, 0.25, 4.0);
zoomHost.LayoutTransform = new ScaleTransform(zoom, zoom);
}

private void ApplyLinkCommand()
{
// The engine owns the HyperlinkCommand used for all rendered links. The Engine getter
// always returns an IMarkdownEngine2 (custom IMarkdownEngine values are upgraded to a
// wrapper that only implements IMarkdownEngine2), so match on that interface.
if (Engine is IMarkdownEngine2 engine)
{
engine.HyperlinkCommand = LinkCommand;
}
}

private void ApplyImageBaseUrl()
{
// AssetPathRoot flows through to the engine's bitmap loader so relative image
// paths resolve against the raw docs URL.
AssetPathRoot = ImageBaseUrl ?? string.Empty;
}

private static readonly string[] HeadingClasses =
[
"Heading1",
"Heading2",
"Heading3",
"Heading4",
"Heading5",
"Heading6",
];

/// <summary>
/// Scrolls the rendered content so the heading matching the given GitHub-style anchor slug
/// is brought to the top of the viewport.
/// </summary>
/// <param name="anchor">The bare heading slug (no leading <c>#</c>).</param>
/// <returns><c>true</c> if a matching heading was found at call time; otherwise <c>false</c>.</returns>
public bool ScrollToAnchor(string anchor)
{
if (string.IsNullOrWhiteSpace(anchor))
return false;

var slug = DocumentationPathResolver.Slugify(anchor);
if (slug.Length == 0)
return false;

// Content is built synchronously when Markdown changes, but layout/measure (needed for
// TranslatePoint) only runs on the next layout pass — defer the actual scroll.
var found = FindHeadingBySlug(slug) is not null;

Dispatcher.UIThread.Post(
() =>
{
var target = FindHeadingBySlug(slug);
if (target is not null)
ScrollHeadingIntoView(target);
},
DispatcherPriority.Background
);

return found;
}

/// <summary>
/// Locates the heading control whose slug matches, applying GitHub-style duplicate suffixes
/// (<c>-1</c>, <c>-2</c>, ...) in document order.
/// </summary>
private CTextBlock? FindHeadingBySlug(string slug)
{
var seen = new Dictionary<string, int>(StringComparer.Ordinal);

foreach (var descendant in this.GetVisualDescendants())
{
if (descendant is not CTextBlock textBlock || !IsHeading(textBlock))
continue;

var baseSlug = DocumentationPathResolver.Slugify(textBlock.Text ?? string.Empty);
if (baseSlug.Length == 0)
continue;

string effectiveSlug;
if (seen.TryGetValue(baseSlug, out var count))
{
effectiveSlug = $"{baseSlug}-{count}";
seen[baseSlug] = count + 1;
}
else
{
effectiveSlug = baseSlug;
seen[baseSlug] = 1;
}

if (string.Equals(effectiveSlug, slug, StringComparison.Ordinal))
return textBlock;
}

return null;
}

private static bool IsHeading(StyledElement control)
{
foreach (var cls in HeadingClasses)
{
if (control.Classes.Contains(cls))
return true;
}

return false;
}

private void ScrollHeadingIntoView(Visual heading)
{
// Position of the heading relative to this control's viewport, plus the current scroll
// offset, gives the heading's Y within the scrollable content.
var current = ScrollValue;
var point = heading.TranslatePoint(new Point(0, 0), this);
if (point is null)
return;

var targetY = Math.Max(0, point.Value.Y + current.Y);
ScrollValue = new Vector(current.X, targetY);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using CommunityToolkit.Mvvm.ComponentModel;

namespace StabilityMatrix.Avalonia.ViewModels.Documentation;

/// <summary>
/// Base for nodes shown in the documentation navigation tree (sections and pages).
/// Exposes the expansion state consumed by the TreeView's <c>TreeViewItem</c> style binding.
/// </summary>
public abstract partial class DocumentationNavNode : ObservableObject
{
/// <summary>Whether the corresponding <c>TreeViewItem</c> is expanded.</summary>
[ObservableProperty]
public partial bool IsExpanded { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace StabilityMatrix.Avalonia.ViewModels.Documentation;

/// <summary>
/// A single navigable documentation page entry (leaf) in the sidebar tree.
/// </summary>
public partial class DocumentationPageNavItem : DocumentationNavNode
{
/// <summary>Display title, e.g. "Overview".</summary>
public required string Title { get; init; }

/// <summary>Path relative to the docs root, e.g. <c>getting-started/overview.md</c>.</summary>
public required string Path { get; init; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using System.Collections.Generic;

namespace StabilityMatrix.Avalonia.ViewModels.Documentation;

/// <summary>
/// A section grouping in the documentation sidebar (e.g. "Getting Started").
/// </summary>
public partial class DocumentationSectionNavItem : DocumentationNavNode
{
public DocumentationSectionNavItem()
{
// Sections are expanded by default.
IsExpanded = true;
}

/// <summary>Section title. Empty for the root section (renders without a header).</summary>
public required string Title { get; init; }

/// <summary>Whether this section has a visible header (i.e. is not the root section).</summary>
public bool HasHeader => !string.IsNullOrEmpty(Title);

/// <summary>Pages within this section.</summary>
public required IReadOnlyList<DocumentationPageNavItem> Pages { get; init; }
}
Loading
Loading