diff --git a/InterlinedList/ViewModels/PoweredDocumentPanelViewModel.cs b/InterlinedList/ViewModels/PoweredDocumentPanelViewModel.cs new file mode 100644 index 0000000..00ce02f --- /dev/null +++ b/InterlinedList/ViewModels/PoweredDocumentPanelViewModel.cs @@ -0,0 +1,526 @@ +using System.Collections.ObjectModel; +using System.Windows.Input; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using InterlinedList.Models; +using InterlinedList.Services; + +namespace InterlinedList.ViewModels; + +/// One source a derived Powered Document mode can point at. +public sealed record AiDocumentSource(string Id, string Title) +{ + public override string ToString() => Title; +} + +/// One entry in the mode picker. +public sealed record AiDocumentModeOption(AiDocumentMode Mode, string Label, string Hint); + +/// +/// Powered Document: draft a full markdown document, in one of four modes. +/// +/// You pass a reference, not source text. from_list sends a +/// listId, from_article a documentId, research_url a +/// URL — and the server loads (or fetches) and truncates the source itself. +/// Those three are IDOR-guarded: a reference that's missing or isn't yours comes +/// back 422 invalid_input. Verified live, all four for free (input +/// validation costs no quota): +/// +/// from_list with a non-owned id → {"error":"List not found.","code":"invalid_input"} +/// from_list with no id → {"error":"A list must be selected.","code":"invalid_input"} +/// from_article with a non-owned id → {"error":"Document not found.","code":"invalid_input"} +/// research_url with ftp:// → {"error":"Only http(s) URLs are supported.","code":"invalid_input"} +/// +/// Those all land on — next to the offending picker — +/// rather than in the generic notice slot, which is #15's last acceptance +/// criterion. +/// +/// One live finding shapes the mode picker. An unrecognized +/// context.mode is not cheap-rejected: {"mode":"not_a_mode"} fell +/// through to the model and came back 422 invalid_ai_output — billed +/// a quota unit. So the mode is never a free string here; it's the +/// four-value enum, always, and the picker can only +/// ever produce one of them. +/// +public sealed partial class PoweredDocumentPanelViewModel : AiPanelViewModelBase +{ + private bool _sourcesLoaded; + + public PoweredDocumentPanelViewModel(SessionService session, AiAvailabilityService? availability = null) + : base(session, availability) + { + Modes = new[] + { + new AiDocumentModeOption(AiDocumentMode.Article, "From a topic", "Drafts from your description alone."), + new AiDocumentModeOption(AiDocumentMode.FromList, "From one of my lists", "Reads the list's schema and up to 50 rows."), + new AiDocumentModeOption(AiDocumentMode.FromArticle, "From one of my documents", "Reads that document's title and body."), + new AiDocumentModeOption(AiDocumentMode.ResearchUrl, "From a URL", "Fetches the page and cites it.") + }; + + selectedMode = Modes[0]; + } + + /// Set by the hosting control from its OpenDocumentCommand dependency property. + public ICommand? OpenDocumentCommand { get; set; } + + /// Set by the hosting control from its RefreshCommand dependency property. + public ICommand? RefreshCommand { get; set; } + + // ── Mode ──────────────────────────────────────────────────────────────────── + + public IReadOnlyList Modes { get; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsListMode))] + [NotifyPropertyChangedFor(nameof(IsArticleSourceMode))] + [NotifyPropertyChangedFor(nameof(IsUrlMode))] + [NotifyPropertyChangedFor(nameof(NeedsSource))] + [NotifyPropertyChangedFor(nameof(ModeHint))] + private AiDocumentModeOption selectedMode; + + public bool IsListMode => SelectedMode.Mode == AiDocumentMode.FromList; + public bool IsArticleSourceMode => SelectedMode.Mode == AiDocumentMode.FromArticle; + public bool IsUrlMode => SelectedMode.Mode == AiDocumentMode.ResearchUrl; + + /// True for the three derived modes, which all need a reference. + public bool NeedsSource => SelectedMode.Mode != AiDocumentMode.Article; + + public string ModeHint => SelectedMode.Hint; + + partial void OnSelectedModeChanged(AiDocumentModeOption value) + { + SourceError = null; + SuggestCommand.NotifyCanExecuteChanged(); + + // Load the pickers' contents only once a mode actually needs them — + // two GETs nobody asked for is the wrong default on panel construction. + if (NeedsSource) _ = LoadSourcesAsync(); + } + + // ── Sources ───────────────────────────────────────────────────────────────── + + public ObservableCollection MyLists { get; } = new(); + + public ObservableCollection MyDocuments { get; } = new(); + + [ObservableProperty] + private AiDocumentSource? selectedList; + + [ObservableProperty] + private AiDocumentSource? selectedDocument; + + [ObservableProperty] + private string sourceUrl = ""; + + /// + /// The message shown against the source picker, not in the generic + /// notice slot. Both the client's own pre-flight and a server-side + /// reference rejection land here. + /// + [ObservableProperty] + private string? sourceError; + + [ObservableProperty] + private bool isLoadingSources; + + partial void OnSelectedListChanged(AiDocumentSource? value) + { + SourceError = null; + SuggestCommand.NotifyCanExecuteChanged(); + } + + partial void OnSelectedDocumentChanged(AiDocumentSource? value) + { + SourceError = null; + SuggestCommand.NotifyCanExecuteChanged(); + } + + partial void OnSourceUrlChanged(string value) + { + SourceError = null; + SuggestCommand.NotifyCanExecuteChanged(); + } + + private async Task LoadSourcesAsync() + { + if (_sourcesLoaded) return; + + IsLoadingSources = true; + try + { + var lists = await Session.Api.GetListsAsync(limit: 100, offset: 0); + MyLists.Clear(); + foreach (var list in lists.Lists) + MyLists.Add(new AiDocumentSource(list.Id, list.Title)); + + // Root documents plus everything filed in a folder — a document a + // user tidied away is still a legitimate source, and the folder + // entries carry their own ids. + var documents = await Session.Api.GetRootDocumentsAsync(); + var folders = await Session.Api.GetDocumentFoldersAsync(); + + MyDocuments.Clear(); + foreach (var doc in documents.Documents) + MyDocuments.Add(new AiDocumentSource(doc.Id, doc.Title)); + + foreach (var folder in folders.Folders) + foreach (var entry in folder.Documents) + MyDocuments.Add(new AiDocumentSource(entry.Id, $"{folder.Name} / {entry.Title}")); + + _sourcesLoaded = true; + } + catch (InterlinedApiException ex) + { + SourceError = ex.Message; + } + catch (Exception ex) + { + SourceError = "Couldn't load your lists and documents."; + AppLog.Warn($"Powered Document source load failed: {ex.Message}"); + } + finally + { + IsLoadingSources = false; + } + } + + [RelayCommand] + private async Task ReloadSourcesAsync() + { + _sourcesLoaded = false; + await LoadSourcesAsync(); + } + + // ── Input ─────────────────────────────────────────────────────────────────── + + /// Collapsed by default — an option in the Documents flow, not the default path. + [ObservableProperty] + private bool isOpen; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(WordCount))] + [NotifyPropertyChangedFor(nameof(WordCountLabel))] + [NotifyPropertyChangedFor(nameof(IsOverWordCap))] + private string topic = ""; + + public int WordCount => AiFeatureLimits.CountWords(Topic); + + public int MaxWords => AiFeatureLimits.For(AiFeature.PoweredDocument).MaxInputWords; + + public string WordCountLabel => $"{WordCount} / {MaxWords} words"; + + public bool IsOverWordCap => WordCount > MaxWords; + + partial void OnTopicChanged(string value) => SuggestCommand.NotifyCanExecuteChanged(); + + // ── Preview ───────────────────────────────────────────────────────────────── + + [ObservableProperty] + private bool hasPreview; + + [ObservableProperty] + private string draftTitle = ""; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CharacterCountLabel))] + [NotifyPropertyChangedFor(nameof(IsOverCharacterCap))] + private string draftMarkdown = ""; + + /// Server-side ceiling on a generated document. + public int MaxCharacters => AiFeatureLimits.MaxDocumentCharacters; + + public string CharacterCountLabel => $"{DraftMarkdown.Length:N0} / {MaxCharacters:N0} characters"; + + public bool IsOverCharacterCap => DraftMarkdown.Length > MaxCharacters; + + /// The model's section plan, shown as read-only context for the draft. + public ObservableCollection DraftOutline { get; } = new(); + + public bool HasOutline => DraftOutline.Count > 0; + + /// Whether the created document should be publicly readable. The artifact carries this. + [ObservableProperty] + private bool draftIsPublic; + + /// + /// Toggles the preview between the rendered document and the editable + /// markdown source. The source is the single point of truth — the rendered + /// view is a projection of it, so edits can't be lost to the toggle. + /// + [ObservableProperty] + private bool showMarkdownSource; + + [ObservableProperty] + private string? usageLabel; + + // ── Commands ──────────────────────────────────────────────────────────────── + + protected override void NotifyAiCommandsChanged() + { + SuggestCommand.NotifyCanExecuteChanged(); + ConfirmCommand.NotifyCanExecuteChanged(); + } + + [RelayCommand] + private void ToggleOpen() + { + IsOpen = !IsOpen; + if (IsOpen) _ = EnsureStatusLoadedAsync(); + } + + private bool CanSuggest() + { + if (!CanRunAi || string.IsNullOrWhiteSpace(Topic)) return false; + + return SelectedMode.Mode switch + { + AiDocumentMode.FromList => SelectedList is not null, + AiDocumentMode.FromArticle => SelectedDocument is not null, + AiDocumentMode.ResearchUrl => !string.IsNullOrWhiteSpace(SourceUrl), + _ => true + }; + } + + [RelayCommand(CanExecute = nameof(CanSuggest))] + private async Task SuggestAsync(CancellationToken ct) + { + SourceError = null; + + if (!TryBuildContext(out var context, out var sourceError)) + { + // Against the field, not in the generic slot. + SourceError = sourceError; + return; + } + + if (!AiFeatureLimits.TryValidateInput(AiFeature.PoweredDocument, Topic, out var inputError)) + { + RejectLocally(inputError!); + return; + } + + var mode = SelectedMode.Mode; + + var result = await RunAiAsync( + "Drafting your document…", + token => Session.Api.AiSuggestAsync(AiFeature.PoweredDocument, Topic.Trim(), context, ct: token), + r => r.Quota, + ct); + + if (result is null) + { + AttributeFailureToSource(mode); + return; + } + + if (result.Artifact.AsDocument() is not { } document) + { + Notice = new AiNotice(AiNoticeKind.ModelDeclined, + $"Expected a document but the AI returned a \"{result.Artifact.Kind}\". " + + "That attempt still counted against today's allowance — try rewording the topic.", + Spent: true); + return; + } + + DraftTitle = document.Title; + DraftMarkdown = document.Markdown; + DraftIsPublic = document.IsPublic; + + DraftOutline.Clear(); + foreach (var section in document.Outline) + DraftOutline.Add(section); + OnPropertyChanged(nameof(HasOutline)); + + ShowMarkdownSource = false; + HasPreview = true; + UsageLabel = result.Usage?.Display; + Notice = AiNotice.Info("Draft ready. Edit it below, then save it as a document."); + ConfirmCommand.NotifyCanExecuteChanged(); + } + + /// + /// Move a reference rejection out of the generic notice and onto the source + /// field, per #15. + /// + /// Why mode + code is enough, without reading the server's prose. + /// Every client-knowable input problem — the 500-word cap, an empty topic, + /// a missing reference, a non-http scheme — is already rejected locally + /// before the call goes out. So an invalid_input that comes back from + /// the server in a derived mode can only be the one thing the client + /// cannot check: the reference doesn't exist, or it isn't this user's. That + /// inference is what lets this branch on the code alone (the repo's rule) + /// while still showing the server's own wording, which is the specific and + /// useful part. + /// + private void AttributeFailureToSource(AiDocumentMode mode) + { + if (mode == AiDocumentMode.Article) return; + if (Notice is not { Kind: AiNoticeKind.Input } notice) return; + + SourceError = mode switch + { + AiDocumentMode.FromList => $"{notice.Text} Pick a different list.", + AiDocumentMode.FromArticle => $"{notice.Text} Pick a different document.", + AiDocumentMode.ResearchUrl => $"{notice.Text} Check the URL.", + _ => notice.Text + }; + + ClearNotice(); + } + + /// + /// Build context for the selected mode, running the checks the + /// server would answer 422 for. These cost no quota either way (verified), + /// but catching them locally also keeps the message on the right field. + /// + private bool TryBuildContext(out AiContext? context, out string? error) + { + context = null; + error = null; + + switch (SelectedMode.Mode) + { + case AiDocumentMode.Article: + context = AiContext.DocumentFromTopic(); + return true; + + case AiDocumentMode.FromList: + if (SelectedList is null) + { + error = "Pick one of your lists to draft from."; + return false; + } + context = AiContext.DocumentFromList(SelectedList.Id); + break; + + case AiDocumentMode.FromArticle: + if (SelectedDocument is null) + { + error = "Pick one of your documents to draft from."; + return false; + } + context = AiContext.DocumentFromArticle(SelectedDocument.Id); + break; + + case AiDocumentMode.ResearchUrl: + var url = SourceUrl.Trim(); + if (url.Length == 0) + { + error = "Enter the URL to research."; + return false; + } + + // Client-side http/https check. The server enforces it too + // ("Only http(s) URLs are supported.", verified), but catching + // it here puts the message on the field and skips a round trip. + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) + || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) + { + error = "Only http:// and https:// URLs are supported."; + return false; + } + + context = AiContext.DocumentFromResearchUrl(uri.ToString()); + break; + + default: + error = "Pick how the document should be drafted."; + return false; + } + + // Second belt: the service's own pre-flight for the same rules. + if (context is not null && !context.TryValidate(AiFeature.PoweredDocument, out var contextError)) + { + error = contextError; + return false; + } + + return true; + } + + [RelayCommand] + private void Discard() + { + HasPreview = false; + DraftTitle = ""; + DraftMarkdown = ""; + DraftIsPublic = false; + DraftOutline.Clear(); + OnPropertyChanged(nameof(HasOutline)); + ShowMarkdownSource = false; + UsageLabel = null; + ClearNotice(); + ConfirmCommand.NotifyCanExecuteChanged(); + } + + [RelayCommand] + private void ToggleSourceView() => ShowMarkdownSource = !ShowMarkdownSource; + + private bool CanConfirm() => CanRunAi && HasPreview; + + [RelayCommand(CanExecute = nameof(CanConfirm))] + private async Task ConfirmAsync(CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(DraftTitle)) + { + RejectLocally("Give the document a title before saving it."); + return; + } + + if (string.IsNullOrWhiteSpace(DraftMarkdown)) + { + RejectLocally("The document is empty — there's nothing to save."); + return; + } + + // Mirrored server ceiling. /generate costs a unit, so an over-long + // document is rejected here rather than there. + if (DraftMarkdown.Length > MaxCharacters) + { + RejectLocally($"A document is capped at {MaxCharacters:N0} characters (this one is {DraftMarkdown.Length:N0}). Trim it before saving."); + return; + } + + var artifact = AiArtifact.FromPayload(new AiDocumentArtifact + { + Title = DraftTitle.Trim(), + Markdown = DraftMarkdown, + Outline = DraftOutline.ToList(), + IsPublic = DraftIsPublic + }); + + var result = await RunAiAsync( + "Saving your document…", + token => Session.Api.AiGenerateAsync(AiFeature.PoweredDocument, artifact, ct: token), + r => r.Quota, + ct); + + if (result is null) return; + + // /generate's envelope is contract-transcribed, not observed, so the id + // is a hint — prove the document exists before handing it to the host. + if (result.Created.DocumentId is { Length: > 0 } documentId) + { + var created = await RunApiAsync("Loading the new document…", token => Session.Api.GetDocumentAsync(documentId, token), ct); + + RefreshCommand?.Execute(null); + + if (created is not null) + { + Notice = AiNotice.Info($"Saved \"{created.Title}\"."); + Discard(); + IsOpen = false; + OpenDocumentCommand?.Execute(created); + return; + } + + Notice = AiNotice.Info("The document was saved, but loading it back failed. It's in the document list."); + Discard(); + return; + } + + RefreshCommand?.Execute(null); + Notice = new AiNotice(AiNoticeKind.Error, + "The server accepted the document but didn't return its id. Check the document list before trying again — a second attempt would create a duplicate and spend another AI credit."); + } +} diff --git a/InterlinedList/Views/DocumentsView.xaml b/InterlinedList/Views/DocumentsView.xaml index 7f8347d..259fa40 100644 --- a/InterlinedList/Views/DocumentsView.xaml +++ b/InterlinedList/Views/DocumentsView.xaml @@ -394,6 +394,9 @@ + + + +/// A small markdown → renderer, so #15's "rendered +/// markdown preview" is actually rendered rather than a wall of hashes and +/// asterisks. +/// +/// Why hand-rolled. The app has two NuGet references and no markdown +/// library, and the artifact it has to display is a narrow, known subset: +/// powered_document returns model-written prose with ATX headings, lists +/// and light inline emphasis (verified live — see +/// ). Pulling in a +/// CommonMark implementation to render six constructs would add a dependency to +/// a csproj several other open PRs are also touching. This covers what the +/// server actually emits and degrades to plain paragraphs for anything else, +/// which is the right failure mode for a preview. +/// +/// Everything it draws uses the existing Strata theme brushes looked up +/// dynamically, so it follows light/dark like the rest of the app and +/// introduces no colour of its own. +/// +/// Supported: ATX headings (#…######), fenced code blocks, +/// unordered (-/*/+) and ordered lists, GFM pipe tables, +/// blockquotes, horizontal rules, paragraphs, and inline **bold**, +/// *italic*, `code` and [text](url). Anything else renders +/// as literal text. +/// +/// Pipe tables are here because the server emits them. The live +/// powered_document probe came back with a GFM table +/// (| Date | Platform | Topic / Asset | Owner |) in the middle of the +/// draft — without table support those three lines would have collapsed into +/// one run-together paragraph of pipes and dashes, which is exactly the kind of +/// mangling a preview exists to prevent. Constructs the real payload used: +/// h1, h2, bullet lists, paragraphs, *italic*, and that table. +/// +internal static class MarkdownRenderer +{ + private const string MonoFonts = "JetBrains Mono, Consolas"; + + public static FlowDocument Render(string? markdown) + { + var document = new FlowDocument + { + PagePadding = new Thickness(0), + FontSize = 12.5, + LineHeight = 19, + Foreground = Brush("TextBodyBrush", Colors.Black) + }; + + if (string.IsNullOrWhiteSpace(markdown)) + return document; + + var lines = markdown.Replace("\r\n", "\n").Replace('\r', '\n').Split('\n'); + var paragraph = new List(); + var index = 0; + + void FlushParagraph() + { + if (paragraph.Count == 0) return; + document.Blocks.Add(Body(string.Join(" ", paragraph))); + paragraph.Clear(); + } + + while (index < lines.Length) + { + var line = lines[index]; + var trimmed = line.TrimStart(); + + // ── Fenced code block ─────────────────────────────────────────── + if (MarkdownSyntax.IsCodeFence(trimmed)) + { + FlushParagraph(); + index++; + var code = new StringBuilder(); + while (index < lines.Length && !MarkdownSyntax.IsCodeFence(lines[index].TrimStart())) + { + code.AppendLine(lines[index]); + index++; + } + index++; // closing fence (or end of input) + document.Blocks.Add(CodeBlock(code.ToString().TrimEnd())); + continue; + } + + // ── Blank line ────────────────────────────────────────────────── + if (trimmed.Length == 0) + { + FlushParagraph(); + index++; + continue; + } + + // ── Horizontal rule ───────────────────────────────────────────── + if (MarkdownSyntax.IsHorizontalRule(trimmed)) + { + FlushParagraph(); + document.Blocks.Add(HorizontalRule()); + index++; + continue; + } + + // ── Heading ───────────────────────────────────────────────────── + if (MarkdownSyntax.TryReadHeading(trimmed, out var headingLevel, out var headingText)) + { + FlushParagraph(); + document.Blocks.Add(Heading(headingText, headingLevel)); + index++; + continue; + } + + // ── GFM pipe table ────────────────────────────────────────────── + // Needs a header row AND a |---|---| separator underneath, so a + // paragraph that merely contains a pipe isn't mistaken for one. + if (MarkdownSyntax.IsTableHeader(trimmed, index + 1 < lines.Length ? lines[index + 1] : null)) + { + FlushParagraph(); + + var header = MarkdownSyntax.SplitTableRow(trimmed); + index += 2; // header + separator + + var body = new List>(); + while (index < lines.Length) + { + var rowLine = lines[index].TrimStart(); + if (rowLine.Length == 0 || !rowLine.Contains('|')) break; + body.Add(MarkdownSyntax.SplitTableRow(rowLine)); + index++; + } + + document.Blocks.Add(TableBlock(header, body)); + continue; + } + + // ── Blockquote ────────────────────────────────────────────────── + if (trimmed[0] == '>') + { + FlushParagraph(); + var quote = new List(); + while (index < lines.Length && lines[index].TrimStart().StartsWith('>')) + { + quote.Add(lines[index].TrimStart().TrimStart('>').Trim()); + index++; + } + document.Blocks.Add(BlockQuote(string.Join(" ", quote))); + continue; + } + + // ── List ──────────────────────────────────────────────────────── + if (MarkdownSyntax.TryReadListMarker(trimmed, out var ordered, out _)) + { + FlushParagraph(); + var list = new List + { + MarkerStyle = ordered ? TextMarkerStyle.Decimal : TextMarkerStyle.Disc, + Margin = new Thickness(16, 4, 0, 8), + Padding = new Thickness(0) + }; + + while (index < lines.Length) + { + var itemLine = lines[index].TrimStart(); + if (itemLine.Length == 0) break; + if (!MarkdownSyntax.TryReadListMarker(itemLine, out var itemOrdered, out var content)) break; + if (itemOrdered != ordered) break; + + list.ListItems.Add(new ListItem(Body(content, bottomMargin: 2))); + index++; + } + + document.Blocks.Add(list); + continue; + } + + // ── Ordinary paragraph line ───────────────────────────────────── + paragraph.Add(trimmed); + index++; + } + + FlushParagraph(); + return document; + } + + // ── Block builders ────────────────────────────────────────────────────────── + + private static Paragraph Heading(string text, int level) + { + // Sizes stay on the app's existing type scale; no new tokens. + var size = level switch { 1 => 19.0, 2 => 16.0, 3 => 14.0, 4 => 13.0, _ => 12.5 }; + + var paragraph = new Paragraph + { + FontSize = size, + FontWeight = FontWeights.SemiBold, + Foreground = Brush("TextBrush", Colors.Black), + Margin = new Thickness(0, level == 1 ? 0 : 12, 0, 6) + }; + + AppendInline(paragraph.Inlines, text); + return paragraph; + } + + private static Paragraph Body(string text, double bottomMargin = 8) + { + var paragraph = new Paragraph { Margin = new Thickness(0, 0, 0, bottomMargin) }; + AppendInline(paragraph.Inlines, text); + return paragraph; + } + + private static Section BlockQuote(string text) + { + var body = Body(text, bottomMargin: 0); + body.Foreground = Brush("TextMutedBrush", Colors.Gray); + + // 3px amber edge: the brand's live/AI accent, same as the notice bar. + return new Section(body) + { + BorderBrush = Brush("AmberBrush", Color.FromRgb(0xF0, 0xA8, 0x30)), + BorderThickness = new Thickness(3, 0, 0, 0), + Padding = new Thickness(10, 4, 0, 4), + Margin = new Thickness(0, 4, 0, 8) + }; + } + + private static Section CodeBlock(string code) + { + var paragraph = new Paragraph(new Run(code)) + { + FontFamily = new FontFamily(MonoFonts), + FontSize = 11, + Margin = new Thickness(0) + }; + + return new Section(paragraph) + { + Background = Brush("Surface3Brush", Color.FromRgb(0xF1, 0xEA, 0xDD)), + Padding = new Thickness(10, 8, 10, 8), + Margin = new Thickness(0, 4, 0, 8) + }; + } + + /// + /// A GFM pipe table. Columns are sized off the widest row rather than the + /// header alone, so a header with fewer cells than a body row doesn't drop + /// data — malformed tables are common in generated markdown. + /// + private static Table TableBlock(List header, List> body) + { + var columns = Math.Max(header.Count, body.Count == 0 ? 0 : body.Max(row => row.Count)); + + var table = new Table + { + CellSpacing = 0, + Margin = new Thickness(0, 4, 0, 10), + BorderBrush = Brush("BorderBrush", Colors.Gray), + BorderThickness = new Thickness(1, 1, 0, 0) + }; + + for (var i = 0; i < columns; i++) + table.Columns.Add(new TableColumn()); + + var group = new TableRowGroup(); + table.RowGroups.Add(group); + + group.Rows.Add(BuildTableRow(header, columns, isHeader: true)); + foreach (var row in body) + group.Rows.Add(BuildTableRow(row, columns, isHeader: false)); + + return table; + } + + private static TableRow BuildTableRow(List cells, int columns, bool isHeader) + { + var row = new TableRow(); + if (isHeader) row.Background = Brush("Surface3Brush", Color.FromRgb(0xF1, 0xEA, 0xDD)); + + for (var i = 0; i < columns; i++) + { + var paragraph = new Paragraph { Margin = new Thickness(0), FontSize = 11 }; + if (isHeader) paragraph.FontWeight = FontWeights.SemiBold; + AppendInline(paragraph.Inlines, i < cells.Count ? cells[i] : ""); + + row.Cells.Add(new TableCell(paragraph) + { + Padding = new Thickness(7, 4, 7, 4), + BorderBrush = Brush("BorderBrush", Colors.Gray), + BorderThickness = new Thickness(0, 0, 1, 1) + }); + } + + return row; + } + + private static Paragraph HorizontalRule() => new() + { + BorderBrush = Brush("BorderStrongBrush", Colors.Gray), + BorderThickness = new Thickness(0, 0, 0, 1), + Margin = new Thickness(0, 8, 0, 12), + FontSize = 1 + }; + + // ── Inline parsing ────────────────────────────────────────────────────────── + + /// + /// Single left-to-right pass over **bold**, *italic*, + /// `code` and [text](url). An unclosed marker is emitted as + /// literal text rather than swallowing the rest of the line — a preview + /// should never lose the user's content to a parse failure. + /// + private static void AppendInline(InlineCollection inlines, string text) + { + var literal = new StringBuilder(); + var i = 0; + + void FlushLiteral() + { + if (literal.Length == 0) return; + inlines.Add(new Run(literal.ToString())); + literal.Clear(); + } + + while (i < text.Length) + { + // `code` + if (text[i] == '`') + { + var close = text.IndexOf('`', i + 1); + if (close > i) + { + FlushLiteral(); + inlines.Add(new Run(text[(i + 1)..close]) + { + FontFamily = new FontFamily(MonoFonts), + FontSize = 11.5 + }); + i = close + 1; + continue; + } + } + + // **bold** + if (text[i] == '*' && i + 1 < text.Length && text[i + 1] == '*') + { + var close = text.IndexOf("**", i + 2, StringComparison.Ordinal); + if (close > i + 1) + { + FlushLiteral(); + var bold = new Bold(); + AppendInline(bold.Inlines, text[(i + 2)..close]); + inlines.Add(bold); + i = close + 2; + continue; + } + } + + // *italic* / _italic_ + if (text[i] is '*' or '_') + { + var marker = text[i]; + var close = text.IndexOf(marker, i + 1); + if (close > i + 1) + { + FlushLiteral(); + var italic = new Italic(); + AppendInline(italic.Inlines, text[(i + 1)..close]); + inlines.Add(italic); + i = close + 1; + continue; + } + } + + // [text](url) — styled, not navigable. A preview shouldn't be able + // to launch a browser from model-authored text on a stray click. + if (text[i] == '[') + { + var closeBracket = text.IndexOf(']', i + 1); + if (closeBracket > i + && closeBracket + 1 < text.Length + && text[closeBracket + 1] == '(' + && text.IndexOf(')', closeBracket + 2) is var closeParen and > 0) + { + FlushLiteral(); + var label = text[(i + 1)..closeBracket]; + var url = text[(closeBracket + 2)..closeParen]; + + inlines.Add(new Run(label.Length > 0 ? label : url) + { + Foreground = Brush("LinkBrush", Colors.SteelBlue), + ToolTip = url + }); + i = closeParen + 1; + continue; + } + } + + literal.Append(text[i]); + i++; + } + + FlushLiteral(); + } + + /// + /// Resolve a theme brush by resource key, with a literal fallback so the + /// renderer still works in a designer or a unit-test context where no + /// Application exists. + /// + private static Brush Brush(string key, Color fallback) + => Application.Current?.TryFindResource(key) as Brush ?? new SolidColorBrush(fallback); +} diff --git a/InterlinedList/Views/MarkdownSyntax.cs b/InterlinedList/Views/MarkdownSyntax.cs new file mode 100644 index 0000000..aba17ea --- /dev/null +++ b/InterlinedList/Views/MarkdownSyntax.cs @@ -0,0 +1,126 @@ +namespace InterlinedList.Views; + +/// +/// The line-level markdown predicates classifies +/// with, split out from it deliberately: these are pure string functions with no +/// dependency on WPF, so they can be exercised on any platform, whereas +/// anything touching FlowDocument can only run on Windows. The +/// classification rules are the part most likely to get a construct wrong, so +/// they're the part worth being able to test. +/// +internal static class MarkdownSyntax +{ + /// + /// An ATX heading: 1–6 # followed by a space. Returns false for + /// #hashtag (no space) and for 7+ hashes, matching CommonMark. + /// + public static bool TryReadHeading(string line, out int level, out string text) + { + level = 0; + text = ""; + + var hashes = 0; + while (hashes < line.Length && line[hashes] == '#') hashes++; + + if (hashes is 0 or > 6) return false; + if (hashes >= line.Length || line[hashes] != ' ') return false; + + level = hashes; + text = line[(hashes + 1)..].Trim(); + return true; + } + + /// + /// A list item marker. distinguishes + /// 1./1) from -/*/+ so a renderer can + /// pick the right marker style and not merge the two kinds into one list. + /// + public static bool TryReadListMarker(string line, out bool ordered, out string content) + { + ordered = false; + content = ""; + + if (line.Length < 2) return false; + + if (line[0] is '-' or '*' or '+' && line[1] == ' ') + { + content = line[2..].Trim(); + return true; + } + + var digits = 0; + while (digits < line.Length && char.IsAsciiDigit(line[digits])) digits++; + + if (digits > 0 + && digits + 1 < line.Length + && line[digits] is '.' or ')' + && line[digits + 1] == ' ') + { + ordered = true; + content = line[(digits + 2)..].Trim(); + return true; + } + + return false; + } + + /// + /// A thematic break: three or more of the same -, * or + /// _, spaces allowed. Checked before the list rule, since + /// --- would otherwise never match and * * * would look like + /// a bullet. + /// + public static bool IsHorizontalRule(string line) + { + if (line.Length < 3) return false; + + var marker = line[0]; + if (marker is not ('-' or '*' or '_')) return false; + + var count = 0; + foreach (var c in line) + { + if (c == marker) count++; + else if (c != ' ') return false; + } + + return count >= 3; + } + + /// + /// The |---|:---:|---:| row under a GFM table header. Requires a + /// pipe and a dash and nothing but pipes, dashes, colons and spaces — which + /// is what tells a real table apart from a paragraph containing a dash. + /// + public static bool IsTableSeparator(string line) + { + var trimmed = line.Trim(); + if (!trimmed.Contains('|') || !trimmed.Contains('-')) return false; + + foreach (var c in trimmed) + if (c is not ('|' or '-' or ':' or ' ')) return false; + + return true; + } + + /// + /// A table row is a header candidate only when it contains a pipe and the + /// next line is a separator. Taking both lines is what stops a paragraph + /// with a stray | becoming a one-column table. + /// + public static bool IsTableHeader(string line, string? nextLine) + => line.Contains('|') && nextLine is not null && IsTableSeparator(nextLine); + + /// Split | a | b | c | into cells, dropping the outer pipes. + public static List SplitTableRow(string line) + { + var trimmed = line.Trim(); + if (trimmed.StartsWith('|')) trimmed = trimmed[1..]; + if (trimmed.EndsWith('|')) trimmed = trimmed[..^1]; + + return trimmed.Split('|').Select(cell => cell.Trim()).ToList(); + } + + /// A fenced code block delimiter (``` or longer). + public static bool IsCodeFence(string line) => line.StartsWith("```", StringComparison.Ordinal); +} diff --git a/InterlinedList/Views/MarkdownViewer.cs b/InterlinedList/Views/MarkdownViewer.cs new file mode 100644 index 0000000..9e183ed --- /dev/null +++ b/InterlinedList/Views/MarkdownViewer.cs @@ -0,0 +1,47 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Documents; + +namespace InterlinedList.Views; + +/// +/// A read-only rendered view of a markdown string. Bind +/// and it re-renders on every change: +/// +/// +/// <local:MarkdownViewer Markdown="{Binding DraftMarkdown}" MaxHeight="360"/> +/// +/// +/// Code-only (no XAML partner file) because it's a +/// with one dependency property and a +/// re-render callback — a markup file would only add indirection. +/// +/// stays off: the stock +/// toolbar brings page/zoom controls styled nothing like Strata. +/// +public sealed class MarkdownViewer : FlowDocumentScrollViewer +{ + public static readonly DependencyProperty MarkdownProperty = + DependencyProperty.Register( + nameof(Markdown), typeof(string), typeof(MarkdownViewer), + new PropertyMetadata(null, OnMarkdownChanged)); + + public MarkdownViewer() + { + IsToolBarVisible = false; + VerticalScrollBarVisibility = ScrollBarVisibility.Auto; + HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled; + Padding = new Thickness(0); + Document = MarkdownRenderer.Render(null); + } + + /// The markdown source to render. + public string? Markdown + { + get => (string?)GetValue(MarkdownProperty); + set => SetValue(MarkdownProperty, value); + } + + private static void OnMarkdownChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + => ((MarkdownViewer)d).Document = MarkdownRenderer.Render(e.NewValue as string); +} diff --git a/InterlinedList/Views/PoweredDocumentPanel.xaml b/InterlinedList/Views/PoweredDocumentPanel.xaml new file mode 100644 index 0000000..2f2c448 --- /dev/null +++ b/InterlinedList/Views/PoweredDocumentPanel.xaml @@ -0,0 +1,407 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +