feat(navigation): add native outline and section controls
Refs #9, #8. Preserve exact frontmatter assertions with fixture diagnostics; 161 core tests and 100% line coverage pass. Co-Authored-By: Codex (GPT-6) <noreply@openai.com> Signed-off-by: James Maes <jmaes@dmdbrands.com>
This commit is contained in:
15 files changed
+664
-84
No files matched your search
@@ -1,6 +1,6 @@
|
||||
# MarkdownViewr
|
||||
|
||||
> In development: a Windows counterpart to [Darin's markdownViewr](https://github.com/darinkelkhoff/markdownViewr), built with AppKit. The first reader implementation opens Markdown, renders GFM and supports manual and automatic reload. The 20 original document themes are implemented; user-theme editing is implemented with acceptance pending; navigation and printing remain in development. Hosted reader/reload and both installed update channels pass. See [the feature plan](docs/PRODUCT.md) and [development tasks](docs/TODO.md). No Windows release is available yet.
|
||||
> In development: a Windows counterpart to [Darin's markdownViewr](https://github.com/darinkelkhoff/markdownViewr), built with AppKit. The first reader implementation opens Markdown, renders GFM and supports manual and automatic reload. The 20 original document themes are implemented; user-theme editing has passed hosted acceptance. Frontmatter and outline controls are implemented with their new browser acceptance pending; source/find navigation and printing remain in development. Hosted reader/reload and both installed update channels pass. See [the feature plan](docs/PRODUCT.md) and [development tasks](docs/TODO.md). No Windows release is available yet.
|
||||
|
||||
A Windows Markdown viewer with live reload, themes and document navigation.
|
||||
|
||||
@@ -33,6 +33,7 @@ Prerelease tags (`v1.2.3-beta.1`) publish to the `dev` channel
|
||||
- **File → Reload:** reread the current file manually. Local images stay within the document folder; remote images and authored scripts/styles are blocked.
|
||||
- **View → Document theme:** choose one of Darin's 20 built-in palettes. The saved document palette is independent of the Windows shell theme; changing it retains the reading position.
|
||||
- **View → Edit document theme:** edit colors, code font/size and custom CSS. Keep its name to override a built-in, or enter a new name to create a custom theme. Invalid input keeps the editor open. **Restore built-in themes** removes built-in edits after confirmation and keeps separately named custom themes.
|
||||
- **View → Table of contents:** show the heading sidebar, choose levels 1–6, and drag its divider to resize. Select a heading to jump there; collapse a selected section or all sections, then expand when ready. Matching section state survives reloads and document theme changes.
|
||||
- **View → Frontmatter:** hide leading YAML by default, show an escaped metadata table, or render the block as Markdown. The mode is saved and applied to reloads.
|
||||
- **View:** choose the light, dark, or system shell theme; turn the startup splash and the
|
||||
startup update check on or off. Choices are saved immediately.
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
Build a Windows counterpart to [Darin Kelkhoff's markdownViewr](https://github.com/darinkelkhoff/markdownViewr), using the KofTwentyTwo AppKit foundations and standards. The agreed display name is MarkdownViewr, repository MarkdownViewr.Windows, and permanent application ID markdownviewr-windows.
|
||||
|
||||
The first reader implementation opens Markdown with a Windows picker or literal launch argument, renders sanitized GFM and supports bounded local images, manual reload and automatic saves with relative scroll preservation. The original 20 document themes and persistent palette selection are implemented. Hosted reader/live-reload/theme selection and both installed update channels passed on 77dd04d. Editable custom themes, color/code-font/CSS controls and restoration are implemented; their new dialog/browser acceptance is pending. The full feature list below remains the target; the first reader is not a completed port.
|
||||
The first reader implementation opens Markdown with a Windows picker or literal launch argument, renders sanitized GFM and supports bounded local images, manual reload and automatic saves with relative scroll preservation. The original 20 document themes and persistent palette selection are implemented. Hosted reader/live-reload/theme selection and both installed update channels passed on 77dd04d. Editable custom themes, color/code-font/CSS controls and restoration are implemented; their dialog/browser acceptance passed in CI 38102867556. Frontmatter and resizable outline/depth/jump/section controls are implemented, with hosted acceptance pending. The full feature list below remains the target; the first reader is not a completed port.
|
||||
|
||||
## Reference and feature scope
|
||||
|
||||
|
||||
+59
-62
@@ -2,70 +2,67 @@
|
||||
|
||||
Updated: 2026-10-10
|
||||
|
||||
## Active work
|
||||
|
||||
MarkdownViewr.Windows is the full Windows counterpart to Darin's markdownViewr,
|
||||
using published AppKit 0.1.0. Approved identity: MarkdownViewr / MarkdownViewr.Windows /
|
||||
markdownviewr-windows. Topic feat/windows-viewer, draft PR #3; do not shrink PRODUCT.md scope.
|
||||
|
||||
Published head 77dd04da96b75938a1c1d38c8bebe4851c11a011 completed CI 38102249889 SUCCESS:
|
||||
build/test, format, 26 native/UI tests, published 26/26 and installed stable/dev 25/25
|
||||
each, with no skips. Four byte gates verified 536 files in both Velopack payloads;
|
||||
AppKit update probes applied/restarted within both channels. These are synthetic test
|
||||
packages, not signed production releases. CodeQL 38102249846/security 38102249838 passed.
|
||||
Scripts 38102249407 failed inside unchanged PSScriptAnalyzer 1.25.0; issue #5 remains open.
|
||||
No rules suppressed or failed runs restarted. Local baseline all 13 scripts passed;
|
||||
this demonstrates intermittent behavior, not an engine fix.
|
||||
|
||||
## Reader and theme evidence
|
||||
|
||||
Reader provides picker/Ctrl+O/launch paths, sanitized GFM, bounded passive local images,
|
||||
debounced saves/replacement/deletion recovery and guarded relative scroll. Directory
|
||||
callbacks dispatch only; bounded editor-lock retries and load scheduling preserve old
|
||||
snapshots and picker requests. Native status declared before WebView2 resolved the
|
||||
embedded-provider traversal issue. Initial window also fits DPI-scaled monitor work
|
||||
area. Exact initial/missing/recovery assertions were preserved throughout diagnosis.
|
||||
Hosted actual replacement/lock/deletion/relative-scroll/remote-image tests now pass.
|
||||
|
||||
All 20 original JSON themes are embedded unchanged with Darin's MIT notice shipped.
|
||||
Built-in selection persists independently of the AppKit shell; actual dark/light paint
|
||||
and screenshots passed. Current local change adds saved user overrides/custom names,
|
||||
native color/code-font/size/CSS controls, invalid-input validation and built-in restoration
|
||||
that retains custom names. AppKit saves preferences atomically; corrupt saved themes are
|
||||
discarded before catalog loading. Core 149 tests/100% coverage and zero-warning build
|
||||
pass. New dialog/browser tests added but not run locally; next hosted acceptance pending.
|
||||
|
||||
Editable theme commit 30ca0943c09a8662794c2d400f1b5c49de426942 is now pushed.
|
||||
Its CI 38102867556 fully passed, including new theme editor/color/CSS/invalid-input/
|
||||
restore tests and both installed channels. CodeQL 38102867583 and scripts 38102867251
|
||||
also passed. New follow-up tests the NumberBox range-value edit to font size 17 and
|
||||
adds analyzer filename/full-exception reporting without changing rules or retry policy.
|
||||
|
||||
Frontmatter #8 now implements the reference's hidden default, escaped line-based
|
||||
metadata and sanitized Markdown display. Complete leading delimiters required;
|
||||
Windows CRLF and body preserved. Native mode changes persist and retain relative
|
||||
viewport; menu disabled while snapshot loading prevents stale mode changes. 149 core
|
||||
tests pass with 100% line coverage; native switching/table disappearance test added,
|
||||
not run locally. Darin's MIT notice and source attribution retained. Next push waits
|
||||
for these final local gates; observe its exact CI handles.
|
||||
|
||||
## Authority and continuity
|
||||
## Active work and authority
|
||||
|
||||
Build the full Windows counterpart to Darin's markdownViewr using published AppKit
|
||||
0.1.0. Approved identity: MarkdownViewr / MarkdownViewr.Windows / markdownviewr-windows.
|
||||
Topic feat/windows-viewer, draft PR #3; PRODUCT.md remains the complete scope.
|
||||
Owner authorized signed/DCO commits, pushes, repository configuration and ticket updates.
|
||||
No merges, tags/releases or new Azure attempts. Existing AppKit/standards/starter drafts
|
||||
remain unmerged; current-SHA human acknowledgement and producer-bound checks enforced.
|
||||
Actual Azure signing/final signatures remain unverified. Desktop reservation is unanswered;
|
||||
do not run local GUI input without availability. Hosted fixtures use an isolated runner.
|
||||
No merges, tags/releases or new Azure attempts. Human review acknowledgement remains
|
||||
required. Local desktop reservation is unanswered; hosted runner UI tests are authorized.
|
||||
|
||||
Pinned reference: 6d08bc9d27d7d7b2c2fa95bb282da330b8cf9021 at
|
||||
AppKit/artifacts/markdownviewr-reference. Preserve original MIT notices when adapting.
|
||||
Evidence: artifacts/verified-reader-38102249889 contains installed TRX/JSON and screenshots;
|
||||
artifacts/user-themes-final-core contains the current 100% coverage report.
|
||||
## Verified reader and editable themes
|
||||
|
||||
## Next
|
||||
77dd04da96b75938a1c1d38c8bebe4851c11a011 CI 38102249889 SUCCESS: build/test,
|
||||
format, native/UI 26/26, published 26/26, installed stable/dev 25/25 each, no skips.
|
||||
Four fixture byte gates verified 536 files in both payloads; AppKit probes applied and
|
||||
restarted within both channels. Fixtures are synthetic and unsigned, not production
|
||||
signing evidence. CodeQL/security passed. Artifacts: verified-reader-38102249889.
|
||||
|
||||
Commit/push the verified editable-theme milestone; observe its exact hosted handles.
|
||||
Update #4/#6 with their completed scoped acceptance and keep #1 full port/#7 theme work
|
||||
and #5 analyzer investigation accurate. Continue TOC/collapse/source/find/vim navigation,
|
||||
frontmatter, editor launch, printing, multiple windows/recents and Windows associations.
|
||||
Finish standards inventory and exact final signed delivery before claiming completion.
|
||||
Reader provides picker/Ctrl+O/literal launch paths, sanitized GFM, bounded passive
|
||||
local images, debounced saves/replacement/deletion recovery and relative scroll.
|
||||
Native status declaration before WebView2 solved embedded-provider traversal; exact
|
||||
missing/recovery assertions retained. Initial window fits the monitor's work area.
|
||||
|
||||
20 original JSON themes retain Darin's MIT notice. Selection persists separately from
|
||||
AppKit shell themes; actual dark/light paint checked. 30ca0943c09a8662794c2d400f1b5c49de426942
|
||||
CI 38102867556 fully SUCCESS, including 29 native/UI tests, editable colors/code font/CSS,
|
||||
validation/restoration and both installed channels. CodeQL/scripts/security passed.
|
||||
Later size-17 edit also passed among 29/30 UI tests. Theme import/export/delete and
|
||||
additional font preferences still need a full reference-parity audit.
|
||||
|
||||
## Frontmatter and outstanding diagnostic
|
||||
|
||||
3b6ebf83d0d88729887ce7678b0e3bf5cbe85989 implements hidden default, escaped line-based
|
||||
metadata and sanitized Markdown modes; saved native choices, CRLF/delimiter fidelity.
|
||||
149 core tests/100% coverage. CI 38103609174 failed accessible-table lookup, 29/30 UI;
|
||||
CodeQL/security/scripts passed. a141929820694976ab5ab13b2a9a86e687284bf4 adds semantic
|
||||
caption and row headers. CI 38104015508 now passes table presence but fails table
|
||||
removal after Markdown selection, still 29/30. Installed producer finished FAILURE at the same
|
||||
mode-switch assertion; CodeQL 38104015459/scripts/security passed. No run restarted.
|
||||
New diagnostic keeps the exact assertion and records fixture settings/log/UIA tree.
|
||||
|
||||
Issue #5 tracks intermittent PSScriptAnalyzer 1.25.0 internal exceptions. Signed
|
||||
d3530d106fa54316308a532dd1bb2d784f0ef207 logs offending file/full stack and rethrows;
|
||||
all rules and required checks preserved. Recent successful runs are not an engine fix.
|
||||
|
||||
## Outline milestone being prepared
|
||||
|
||||
Issue #9: sanitized heading labels, GFM fragment mapping, generated numeric identities,
|
||||
stack-free section construction, duplicate/nested headings and passive markup.
|
||||
Native persistent sidebar/depth/drag width and section operations compile. Fixed host
|
||||
scripts retain independent nested collapse across matching-heading reload/theme changes.
|
||||
161 core tests pass with 100% line coverage; zero-warning build. Native resize/jump/
|
||||
collapse/state tests added, not run locally. Observe the next exact hosted handles.
|
||||
|
||||
## Next and remaining scope
|
||||
|
||||
Finish frontmatter mode-switch diagnosis and hosted outline acceptance; update #4/#6
|
||||
scoped acceptance and #1/#5/#7/#8/#9 accurately. Continue source pane/synchronization,
|
||||
find/vim keys, external editor, print state/options, multiple windows/recents and
|
||||
Windows file associations. Audit syntax-highlighting/local-link/theme parity, standards
|
||||
inventory and reviewed AppKit API adoption. Verify real signed stable/dev final bytes
|
||||
before production publication; active Azure profiles do not prove successful signing.
|
||||
|
||||
Pinned reference: 6d08bc9d27d7d7b2c2fa95bb282da330b8cf9021 under
|
||||
AppKit/artifacts/markdownviewr-reference. Preserve original MIT attribution on adaptations.
|
||||
+4
-2
@@ -15,8 +15,10 @@
|
||||
- [x] Port Darin's 20 built-in themes and persistent document selection; validate schema, font escaping and invariant CSS.
|
||||
- [x] Verify hosted built-in theme selection and actual painted colors on 77dd04d.
|
||||
- [x] Implement persisted editable theme overrides/custom names, color/code-font/CSS controls and built-in restoration.
|
||||
- [ ] Complete hosted theme editor/validation/restoration acceptance (issue #7).
|
||||
- [ ] Add TOC, section collapse, source pane, synchronized navigation, find and vim keys.
|
||||
- [x] Complete hosted theme editor/validation/restoration acceptance (issue #7): CI 38102867556 passed; size 17 edit also passes in 29/30 later UI tests.
|
||||
- [x] Implement persistent resizable TOC/depth, safe heading/local-fragment jumps and independent nested section collapse (issue #9).
|
||||
- [ ] Verify hosted outline depth/resize/scroll/collapse/theme/reload acceptance.
|
||||
- [ ] Add source pane, synchronized navigation, find and vim keys.
|
||||
- [x] Implement and core-test hidden/metadata/Markdown frontmatter modes, with persisted native controls and attribution.
|
||||
- [ ] Verify actual hosted frontmatter mode switching and code-font size editing.
|
||||
- [ ] Add safe configured editor launch and printing options.
|
||||
|
||||
@@ -29,6 +29,7 @@ physical directory. It does not authorize native commands or remote resource acc
|
||||
| Local images | Per-document URI prefix; one decode; filename/extension policy; final open-handle containment; 16 MiB limit |
|
||||
| Remote resources | Denied by CSP and resource interception |
|
||||
| External links | HTTP(S) only, actual browser user gesture, external Windows handler; no document navigation outside current page |
|
||||
| Heading navigation | Labels extracted after sanitation; authored identity attributes stripped; generated numeric targets only. Fixed host operations reveal/jump/collapse on the current intercepted page; authored scripts, messages and host objects remain disabled |
|
||||
| Settings / logs | AppKit sanitation; per-user data root; log operational events without document content or paths |
|
||||
| Updates | Repository HTTPS feed, Velopack integrity checks and production signing preflight |
|
||||
| Repository / CI | Protected main and release tags; producer-bound checks; no untrusted PR checkout with privileged review token |
|
||||
@@ -37,10 +38,10 @@ physical directory. It does not authorize native commands or remote resource acc
|
||||
|
||||
| Threat | Mitigation and evidence |
|
||||
| --- | --- |
|
||||
| Document script or native-command execution | Sanitizer regressions pass; host disables scripts/bridge; actual browser acceptance pending |
|
||||
| Document script or native-command execution | Sanitizer regressions pass; host disables scripts/bridge; actual browser malicious-script and reader acceptance passed on 77dd04d |
|
||||
| Image traversal, device/ADS paths or encoded separators | Core path-policy tests pass; denied before filesystem access |
|
||||
| Junction/symlink escape or check/reopen race | Check normalized physical path on opened image handle; read same handle; real junction rejection test passes |
|
||||
| Remote image tracking / resource exfiltration | No-referrer header, no document networking; browser network acceptance pending |
|
||||
| Remote image tracking / resource exfiltration | No-referrer header, no document networking; actual listening-server browser denial passed on 77dd04d |
|
||||
| Oversized input / image denial of service | Pre-parse character bound and image byte bound; native limit tests pass; performance acceptance remains |
|
||||
| Settings corruption / UI exception | AppKit shell sanitation and crash handling; inherited foundation tests |
|
||||
| Tampered installer/update | Fail-closed signing/provenance/checksum release pipeline; real Azure CI authorization and signed artifacts remain unverified |
|
||||
@@ -48,11 +49,11 @@ physical directory. It does not authorize native commands or remote resource acc
|
||||
|
||||
## Remaining risks and work
|
||||
|
||||
This is a first reader, not a completed port. User theme editor acceptance, external editor commands,
|
||||
This is a first reader, not a completed port. Native outline/collapse acceptance, external editor commands,
|
||||
print and any future browser bridge need their own controls and tests. Save watching is
|
||||
implemented; hosted real browser replacement/deletion/scroll and remote-image denial pass on 77dd04d.
|
||||
Local links to other documents and mailto links are currently blocked. WebView2 uses
|
||||
the installed evergreen runtime; installed deployment acceptance remains required.
|
||||
the installed evergreen runtime; published and both installed channels passed reader acceptance on 77dd04d and editable themes on 30ca094. New frontmatter/outline acceptance remains required.
|
||||
|
||||
Historical EX-0002 does not authorize unsigned production publication. Azure setup
|
||||
and real final-artifact signature validation are deferred; an active profile alone
|
||||
|
||||
@@ -32,10 +32,26 @@ public sealed class AppSettings : ShellSettings
|
||||
public Documents.FrontmatterMode FrontmatterKind => Enum.TryParse(Frontmatter, out Documents.FrontmatterMode mode) && Enum.IsDefined(mode) ? mode : Documents.FrontmatterMode.Hide;
|
||||
|
||||
|
||||
/// <summary>Shows the native heading sidebar when requested.</summary>
|
||||
public bool ShowOutline { get; set; }
|
||||
|
||||
|
||||
|
||||
/// <summary>Maximum heading depth displayed in the sidebar.</summary>
|
||||
public int OutlineDepth { get; set; } = 3;
|
||||
|
||||
|
||||
|
||||
/// <summary>Last native sidebar width in device-independent pixels.</summary>
|
||||
public double OutlineWidth { get; set; } = 220;
|
||||
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void Sanitize()
|
||||
{
|
||||
base.Sanitize();
|
||||
OutlineDepth = Math.Clamp(OutlineDepth, 1, 6);
|
||||
OutlineWidth = double.IsFinite(OutlineWidth) ? Math.Clamp(OutlineWidth, 140, 500) : 220;
|
||||
Frontmatter = FrontmatterKind.ToString();
|
||||
if(string.IsNullOrWhiteSpace(DocumentTheme) || DocumentTheme.Length > 80 || DocumentTheme.Any(char.IsControl))
|
||||
{
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace MarkdownViewr.Windows.Core.Documents;
|
||||
|
||||
/// <summary>A passive heading label and host-generated numeric identity for native navigation.</summary>
|
||||
public sealed record DocumentHeading(int Index, int Level, string Title);
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Globalization;
|
||||
using AngleSharp.Dom;
|
||||
using AngleSharp.Html.Parser;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.Core.Documents;
|
||||
|
||||
/// <summary>Builds native heading data and nested passive sections only after Markdown has been sanitized.</summary>
|
||||
public sealed class DocumentOutline
|
||||
{
|
||||
private readonly List<DocumentHeading> _headings = [];
|
||||
private readonly Dictionary<IElement, DocumentHeading> _elements = [];
|
||||
|
||||
|
||||
|
||||
/// <summary>Sanitizes first; authored identifiers cannot impersonate the generated navigation targets.</summary>
|
||||
public DocumentOutline(string markdown, FrontmatterMode frontmatter = FrontmatterMode.Hide)
|
||||
{
|
||||
using AngleSharp.Html.Dom.IHtmlDocument document = new HtmlParser().ParseDocument(new MarkdownRenderer().Render(markdown, frontmatter));
|
||||
IElement body = document.Body!;
|
||||
var fragments = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach(IElement element in body.QuerySelectorAll("h1,h2,h3,h4,h5,h6"))
|
||||
{
|
||||
var heading = new DocumentHeading(_headings.Count, element.LocalName[1] - '0', element.TextContent.Trim());
|
||||
_headings.Add(heading);
|
||||
_elements.Add(element, heading);
|
||||
element.Id = "mv-heading-" + heading.Index.ToString(CultureInfo.InvariantCulture);
|
||||
string slug = Markdig.Helpers.LinkHelper.UrilizeAsGfm(heading.Title);
|
||||
string fragment = slug;
|
||||
for(int suffix = 1; fragments.ContainsKey(fragment); suffix++)
|
||||
{
|
||||
fragment = slug + "-" + suffix.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
fragments.Add(fragment, element.Id);
|
||||
}
|
||||
foreach(IElement link in body.QuerySelectorAll("a[href]"))
|
||||
{
|
||||
string href = link.GetAttribute("href")!;
|
||||
if(href.StartsWith('#') && fragments.TryGetValue(Uri.UnescapeDataString(href[1..]), out string? target))
|
||||
{
|
||||
link.SetAttribute("href", "#" + target);
|
||||
}
|
||||
}
|
||||
foreach(IElement element in body.QuerySelectorAll("*").Reverse())
|
||||
{
|
||||
if(!_elements.ContainsKey(element))
|
||||
{
|
||||
WrapSections(element, document);
|
||||
}
|
||||
}
|
||||
WrapSections(body, document);
|
||||
Html = body.InnerHtml;
|
||||
Headings = _headings.AsReadOnly();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Sanitized HTML plus only the host's generated identifiers and structural attributes.</summary>
|
||||
public string Html { get; }
|
||||
|
||||
|
||||
|
||||
/// <summary>Headings in document order, including duplicate labels and headings inside authored containers.</summary>
|
||||
public IReadOnlyList<DocumentHeading> Headings { get; }
|
||||
|
||||
|
||||
|
||||
/// <summary>Groups each heading until the next peer or ancestor, keeping nested section collapse independent.</summary>
|
||||
private void WrapSections(IElement parent, IDocument document)
|
||||
{
|
||||
INode[] children = parent.ChildNodes.ToArray();
|
||||
|
||||
parent.TextContent = "";
|
||||
var sections = new Stack<(int Level, IElement Content)>();
|
||||
foreach(INode child in children)
|
||||
{
|
||||
if(child is IElement element && _elements.TryGetValue(element, out DocumentHeading? heading))
|
||||
{
|
||||
while(sections.TryPeek(out (int Level, IElement Content) top) && top.Level >= heading.Level)
|
||||
{
|
||||
sections.Pop();
|
||||
}
|
||||
IElement section = document.CreateElement("section");
|
||||
IElement content = document.CreateElement("div");
|
||||
content.SetAttribute("data-mv-body", heading.Index.ToString(CultureInfo.InvariantCulture));
|
||||
section.AppendChild(element);
|
||||
section.AppendChild(content);
|
||||
(sections.Count > 0 ? sections.Peek().Content : parent).AppendChild(section);
|
||||
sections.Push((heading.Level, content));
|
||||
}
|
||||
else
|
||||
{
|
||||
(sections.Count > 0 ? sections.Peek().Content : parent).AppendChild(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
using System.Net;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using Microsoft.Web.WebView2.Core;
|
||||
|
||||
@@ -30,6 +31,8 @@ internal sealed class DocumentBrowser
|
||||
private Core.Themes.ReaderTheme _theme;
|
||||
private string _sanitizedHtml = "";
|
||||
private string _title = "MarkdownViewr";
|
||||
private IReadOnlyList<Core.Documents.DocumentHeading> _headings = [];
|
||||
private int[] _collapsedSections = [];
|
||||
|
||||
|
||||
|
||||
@@ -77,8 +80,14 @@ internal sealed class DocumentBrowser
|
||||
|
||||
|
||||
/// <summary>Replaces the document capability and navigates to an intercepted HTTPS response, with no HTTP server.</summary>
|
||||
public void Show(DocumentFiles? files, string sanitizedHtml, string title, Core.Documents.DocumentViewport viewport = default)
|
||||
public void Show(DocumentFiles? files, string sanitizedHtml, string title, Core.Documents.DocumentViewport viewport = default, IReadOnlyList<Core.Documents.DocumentHeading>? headings = null)
|
||||
{
|
||||
headings ??= [];
|
||||
if(!_headings.SequenceEqual(headings))
|
||||
{
|
||||
_collapsedSections = [];
|
||||
}
|
||||
_headings = headings;
|
||||
_files = files;
|
||||
_sanitizedHtml = sanitizedHtml;
|
||||
_title = title;
|
||||
@@ -99,7 +108,58 @@ internal sealed class DocumentBrowser
|
||||
_theme = theme;
|
||||
if(!_closed)
|
||||
{
|
||||
Show(_files, _sanitizedHtml, _title, viewport);
|
||||
Show(_files, _sanitizedHtml, _title, viewport, _headings);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// <summary>Reveals a heading's hidden ancestors before moving the actual reading viewport to it.</summary>
|
||||
public Task JumpToHeadingAsync(int index)
|
||||
{
|
||||
if(index < 0 || index >= _headings.Count)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
return ExecuteDocumentScriptAsync(FormattableString.Invariant($"(()=>{{const h=document.getElementById('mv-heading-{index}'); if(h){{ for(let p=h.parentElement;p;p=p.parentElement) p.hidden=false; h.scrollIntoView({{block:'start'}}); }}}})()"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Only a host-generated section body can be collapsed, leaving its own heading visible.</summary>
|
||||
public Task ToggleSectionAsync(int index)
|
||||
{
|
||||
if(index < 0 || index >= _headings.Count)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
return ExecuteDocumentScriptAsync(FormattableString.Invariant($"(()=>{{const e=document.querySelector('[data-mv-body=\"{index}\"]'); if(e) e.hidden=!e.hidden;}})()"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Applies an explicit native command to every generated section, including nested descendants.</summary>
|
||||
public Task SetSectionsAsync(bool collapsed)
|
||||
=> ExecuteDocumentScriptAsync("for(const e of document.querySelectorAll('[data-mv-body]')) e.hidden=" + (collapsed ? "true" : "false") + ";");
|
||||
|
||||
|
||||
|
||||
/// <summary>Fixed host operations run only on the ready intercepted document; content script execution remains disabled.</summary>
|
||||
private async Task ExecuteDocumentScriptAsync(string script)
|
||||
{
|
||||
if(_closed || !_pageReady || !IsCurrentDocument())
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
await _view.CoreWebView2.ExecuteScriptAsync(script);
|
||||
}
|
||||
catch(COMException)
|
||||
{
|
||||
if(!_closed)
|
||||
{
|
||||
_status("The document navigation could not be completed.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,6 +184,11 @@ internal sealed class DocumentBrowser
|
||||
if(!_closed && document == _policy.DocumentUri && navigation == _navigationId && IsCurrentDocument())
|
||||
{
|
||||
_viewport = Core.Documents.DocumentViewport.FromBrowserResult(result);
|
||||
string collapsed = await _view.CoreWebView2.ExecuteScriptAsync("Array.from(document.querySelectorAll('[data-mv-body]')).filter(e=>e.hidden).map(e=>Number(e.dataset.mvBody))");
|
||||
if(!_closed && document == _policy.DocumentUri && navigation == _navigationId && IsCurrentDocument())
|
||||
{
|
||||
_collapsedSections = (JsonSerializer.Deserialize<int[]>(collapsed) ?? []).Where(index => index >= 0 && index < _headings.Count).Distinct().ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
return _viewport;
|
||||
@@ -150,6 +215,8 @@ internal sealed class DocumentBrowser
|
||||
_restoreViewport = false;
|
||||
try
|
||||
{
|
||||
string collapsed = JsonSerializer.Serialize(_collapsedSections);
|
||||
await sender.ExecuteScriptAsync("for(const e of document.querySelectorAll('[data-mv-body]')) e.hidden=" + collapsed + ".includes(Number(e.dataset.mvBody));");
|
||||
string script = FormattableString.Invariant($"if(location.href.split('#')[0] === '{_policy.DocumentUri.AbsoluteUri}') window.scrollTo({_viewport.HorizontalFraction:R}*Math.max(0,document.documentElement.scrollWidth-innerWidth),{_viewport.VerticalFraction:R}*Math.max(0,document.documentElement.scrollHeight-innerHeight));");
|
||||
await sender.ExecuteScriptAsync(script);
|
||||
if(!_closed && args.NavigationId == _navigationId && IsCurrentDocument())
|
||||
@@ -190,6 +257,13 @@ internal sealed class DocumentBrowser
|
||||
string.Equals(uri.GetLeftPart(UriPartial.Path), _policy.DocumentUri.AbsoluteUri, StringComparison.Ordinal) &&
|
||||
uri.Query.Length == 0)
|
||||
{
|
||||
if(args.IsUserInitiated && uri.Fragment.StartsWith("#mv-heading-", StringComparison.Ordinal) &&
|
||||
int.TryParse(uri.Fragment.AsSpan(12), System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out int heading))
|
||||
{
|
||||
args.Cancel = true;
|
||||
await JumpToHeadingAsync(heading);
|
||||
return;
|
||||
}
|
||||
_navigationId = args.NavigationId;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using Microsoft.UI.Xaml.Controls.Primitives;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows;
|
||||
|
||||
/// <summary>Native outline controls route only bounded numeric heading operations to the passive browser.</summary>
|
||||
public sealed partial class MainWindow
|
||||
{
|
||||
private Core.Documents.DocumentOutline? _outline;
|
||||
private bool _navigationReady;
|
||||
private bool _refreshingOutline;
|
||||
|
||||
|
||||
|
||||
/// <summary>Applies persisted sidebar appearance after XAML event initialization has finished.</summary>
|
||||
private void InitializeOutline()
|
||||
{
|
||||
OutlineToggle.IsChecked = _settings.ShowOutline;
|
||||
OutlineDepth.SelectedIndex = _settings.OutlineDepth - 1;
|
||||
_navigationReady = true;
|
||||
ApplyOutlineVisibility();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A hidden sidebar relinquishes its width entirely to the reader.</summary>
|
||||
private void ApplyOutlineVisibility()
|
||||
{
|
||||
OutlinePanel.Visibility = _settings.ShowOutline ? Visibility.Visible : Visibility.Collapsed;
|
||||
OutlineColumn.Width = new GridLength(_settings.ShowOutline ? _settings.OutlineWidth : 0);
|
||||
OutlineDividerColumn.Width = new GridLength(_settings.ShowOutline ? 6 : 0);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The view menu saves a persistent sidebar choice.</summary>
|
||||
private void Outline_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
_settings.ShowOutline = OutlineToggle.IsChecked;
|
||||
Save();
|
||||
ApplyOutlineVisibility();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Depth selection filters headings without renumbering their document identities.</summary>
|
||||
private void OutlineDepth_Changed(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if(!_navigationReady || OutlineDepth.SelectedIndex < 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_settings.OutlineDepth = OutlineDepth.SelectedIndex + 1;
|
||||
Save();
|
||||
RefreshOutline();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Rebuilds native labels only after a complete sanitized snapshot has been committed.</summary>
|
||||
private void RefreshOutline()
|
||||
{
|
||||
var selected = (OutlineList.SelectedItem as ListViewItem)?.Tag as Core.Documents.DocumentHeading;
|
||||
_refreshingOutline = true;
|
||||
OutlineList.Items.Clear();
|
||||
ToggleSection.IsEnabled = false;
|
||||
if(_outline is not null)
|
||||
{
|
||||
foreach(Core.Documents.DocumentHeading heading in _outline.Headings.Where(heading => heading.Level <= _settings.OutlineDepth))
|
||||
{
|
||||
var label = new TextBlock { Text = heading.Title, TextWrapping = TextWrapping.Wrap, MaxLines = 2, Margin = new Thickness((heading.Level - 1) * 12, 0, 0, 0) };
|
||||
var item = new ListViewItem { Content = label, Tag = heading };
|
||||
Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId(item, "OutlineHeading:" + heading.Index.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
OutlineList.Items.Add(item);
|
||||
if(heading == selected)
|
||||
{
|
||||
OutlineList.SelectedItem = item;
|
||||
ToggleSection.IsEnabled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
_refreshingOutline = false;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Heading jumps reveal collapsed ancestors and target only the selected numeric identity.</summary>
|
||||
private async void OutlineSelection_Changed(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if(_refreshingOutline || OutlineList.SelectedItem is not ListViewItem { Tag: Core.Documents.DocumentHeading heading })
|
||||
{
|
||||
return;
|
||||
}
|
||||
ToggleSection.IsEnabled = true;
|
||||
await _browser.JumpToHeadingAsync(heading.Index);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The selected heading remains visible while its descendants toggle together.</summary>
|
||||
private async void ToggleSection_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if(OutlineList.SelectedItem is ListViewItem { Tag: Core.Documents.DocumentHeading heading })
|
||||
{
|
||||
await _browser.ToggleSectionAsync(heading.Index);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Collapses all sections while retaining their headings.</summary>
|
||||
private async void CollapseSections_Click(object sender, RoutedEventArgs e) => await _browser.SetSectionsAsync(collapsed: true);
|
||||
|
||||
|
||||
|
||||
/// <summary>Expands all levels, including children hidden before their parent was collapsed.</summary>
|
||||
private async void ExpandSections_Click(object sender, RoutedEventArgs e) => await _browser.SetSectionsAsync(collapsed: false);
|
||||
|
||||
|
||||
|
||||
/// <summary>Resizes within the validated reading range while the pointer drags the native divider.</summary>
|
||||
private void OutlineDivider_DragDelta(object sender, DragDeltaEventArgs e)
|
||||
{
|
||||
_settings.OutlineWidth = Math.Clamp(OutlineColumn.ActualWidth + e.HorizontalChange, 140, 500);
|
||||
OutlineColumn.Width = new GridLength(_settings.OutlineWidth);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Writes only the completed resize to AppKit's atomic preferences.</summary>
|
||||
private void OutlineDivider_DragCompleted(object sender, DragCompletedEventArgs e) => Save();
|
||||
}
|
||||
@@ -2,7 +2,8 @@
|
||||
<Window
|
||||
x:Class="MarkdownViewr.Windows.MainWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:primitives="using:Microsoft.UI.Xaml.Controls.Primitives">
|
||||
|
||||
<Window.SystemBackdrop>
|
||||
<MicaBackdrop />
|
||||
@@ -27,6 +28,8 @@
|
||||
<MenuFlyoutItem x:Name="ExitMenuItem" Text="Exit" Click="Exit_Click" />
|
||||
</MenuBarItem>
|
||||
<MenuBarItem Title="View">
|
||||
<ToggleMenuFlyoutItem x:Name="OutlineToggle" Text="Table of contents" Click="Outline_Click" />
|
||||
<MenuFlyoutSeparator />
|
||||
<MenuFlyoutSubItem x:Name="DocumentThemesMenu" Text="Document theme" />
|
||||
<MenuFlyoutItem x:Name="EditDocumentThemeMenu" Text="Edit document theme..." Click="EditDocumentTheme_Click" />
|
||||
<MenuFlyoutItem x:Name="RestoreDocumentThemesMenu" Text="Restore built-in themes" Click="RestoreDocumentThemes_Click" />
|
||||
@@ -53,6 +56,38 @@
|
||||
</MenuBar>
|
||||
|
||||
<TextBlock x:Name="StatusText" Grid.Row="2" Margin="16,8" Text="Starting the document surface..." TextWrapping="Wrap" Foreground="{ThemeResource TextFillColorSecondaryBrush}" AutomationProperties.AutomationId="StatusText" AutomationProperties.Name="{Binding Text, ElementName=StatusText}" AutomationProperties.AccessibilityView="Content" AutomationProperties.LiveSetting="Polite" />
|
||||
<WebView2 x:Name="DocumentView" Grid.Row="1" Loaded="DocumentView_Loaded" AutomationProperties.Name="Markdown document" />
|
||||
<Grid Grid.Row="1">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition x:Name="OutlineColumn" Width="0" />
|
||||
<ColumnDefinition x:Name="OutlineDividerColumn" Width="0" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid x:Name="OutlinePanel" Visibility="Collapsed" Padding="8">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="Table of contents" Style="{ThemeResource SubtitleTextBlockStyle}" />
|
||||
<ComboBox x:Name="OutlineDepth" Header="Heading depth" HorizontalAlignment="Stretch" SelectionChanged="OutlineDepth_Changed" AutomationProperties.AutomationId="OutlineDepth">
|
||||
<ComboBoxItem Content="Level 1" />
|
||||
<ComboBoxItem Content="Levels 1–2" />
|
||||
<ComboBoxItem Content="Levels 1–3" />
|
||||
<ComboBoxItem Content="Levels 1–4" />
|
||||
<ComboBoxItem Content="Levels 1–5" />
|
||||
<ComboBoxItem Content="Levels 1–6" />
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
<ListView x:Name="OutlineList" Grid.Row="1" Margin="0,8" SelectionChanged="OutlineSelection_Changed" AutomationProperties.Name="Document headings" />
|
||||
<StackPanel Grid.Row="2" Spacing="8">
|
||||
<Button x:Name="ToggleSection" Content="Collapse / expand section" HorizontalAlignment="Stretch" Click="ToggleSection_Click" IsEnabled="False" />
|
||||
<Button x:Name="CollapseSections" Content="Collapse all" HorizontalAlignment="Stretch" Click="CollapseSections_Click" />
|
||||
<Button x:Name="ExpandSections" Content="Expand all" HorizontalAlignment="Stretch" Click="ExpandSections_Click" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<primitives:Thumb x:Name="OutlineDivider" Grid.Column="1" Background="{ThemeResource DividerStrokeColorDefaultBrush}" DragDelta="OutlineDivider_DragDelta" DragCompleted="OutlineDivider_DragCompleted" AutomationProperties.Name="Resize table of contents" />
|
||||
<WebView2 x:Name="DocumentView" Grid.Column="2" Loaded="DocumentView_Loaded" AutomationProperties.Name="Markdown document" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -39,6 +39,7 @@ public sealed partial class MainWindow : Window, IDisposable
|
||||
_themes = Core.Themes.ThemeCatalog.Combine(_settings.UserThemes.Select(Core.Themes.ReaderTheme.Parse));
|
||||
_browser = new DocumentBrowser(DocumentView, message => StatusText.Text = message, ActiveDocumentTheme());
|
||||
CreateDocumentThemeMenu();
|
||||
InitializeOutline();
|
||||
(_settings.FrontmatterKind switch { Core.Documents.FrontmatterMode.Metadata => FrontmatterMetadata, Core.Documents.FrontmatterMode.Markdown => FrontmatterMarkdown, _ => FrontmatterHide }).IsChecked = true;
|
||||
Title = AppIdentity.Info.DisplayName;
|
||||
FitInitialWindow();
|
||||
@@ -179,12 +180,12 @@ public sealed partial class MainWindow : Window, IDisposable
|
||||
_browserInitialization ??= _browser.InitializeAsync();
|
||||
await _browserInitialization;
|
||||
Core.Documents.DocumentViewport viewport = reload ? await _browser.CaptureViewportAsync() : default;
|
||||
(DocumentFiles files, string html) = await ReadDocumentAsync(path, _settings.FrontmatterKind);
|
||||
(DocumentFiles files, Core.Documents.DocumentOutline outline) = await ReadDocumentAsync(path, _settings.FrontmatterKind);
|
||||
if(_closed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
ShowDocument(files, html, path, viewport, reload);
|
||||
ShowDocument(files, outline, path, viewport, reload);
|
||||
}
|
||||
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException or ArgumentException or System.Runtime.InteropServices.COMException)
|
||||
{
|
||||
@@ -254,7 +255,7 @@ public sealed partial class MainWindow : Window, IDisposable
|
||||
|
||||
|
||||
/// <summary>Retries only transient missing/sharing violations from an editor's replace/save operation.</summary>
|
||||
private static async Task<(DocumentFiles Files, string Html)> ReadDocumentAsync(string path, Core.Documents.FrontmatterMode frontmatter)
|
||||
private static async Task<(DocumentFiles Files, Core.Documents.DocumentOutline Outline)> ReadDocumentAsync(string path, Core.Documents.FrontmatterMode frontmatter)
|
||||
{
|
||||
for(int attempt = 0; ; attempt++)
|
||||
{
|
||||
@@ -263,7 +264,7 @@ public sealed partial class MainWindow : Window, IDisposable
|
||||
return await Task.Run(() =>
|
||||
{
|
||||
var document = DocumentFiles.Open(path);
|
||||
return (document, new Core.Documents.MarkdownRenderer().Render(document.Markdown, frontmatter));
|
||||
return (document, new Core.Documents.DocumentOutline(document.Markdown, frontmatter));
|
||||
});
|
||||
}
|
||||
catch(IOException exception) when(attempt < 3 && (exception.HResult & 0xFFFF) is 2 or 3 or 32 or 33)
|
||||
@@ -276,9 +277,11 @@ public sealed partial class MainWindow : Window, IDisposable
|
||||
|
||||
|
||||
/// <summary>Commits a fully parsed snapshot and changes the watcher only when the selected document changes.</summary>
|
||||
private void ShowDocument(DocumentFiles files, string html, string path, Core.Documents.DocumentViewport viewport, bool reload)
|
||||
private void ShowDocument(DocumentFiles files, Core.Documents.DocumentOutline outline, string path, Core.Documents.DocumentViewport viewport, bool reload)
|
||||
{
|
||||
_browser.Show(files, html, Path.GetFileName(path), viewport);
|
||||
_browser.Show(files, outline.Html, Path.GetFileName(path), viewport, outline.Headings);
|
||||
_outline = outline;
|
||||
RefreshOutline();
|
||||
_documentPath = Path.GetFullPath(path);
|
||||
Title = Path.GetFileName(path) + " — " + AppIdentity.Info.DisplayName;
|
||||
StatusText.Text = (reload ? "Reloaded " : "Opened ") + Path.GetFileName(path);
|
||||
|
||||
@@ -61,6 +61,7 @@ public sealed class DocumentFrontmatterTests
|
||||
Assert.DoesNotContain("<script", metadata, StringComparison.Ordinal);
|
||||
Assert.Contains("<h1>Body heading</h1>", metadata, StringComparison.Ordinal);
|
||||
string rendered = renderer.Render(Markdown, FrontmatterMode.Markdown);
|
||||
Assert.DoesNotContain("<table", rendered, StringComparison.Ordinal);
|
||||
Assert.Contains("<strong>Visible value</strong>", rendered, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("<script", rendered, StringComparison.Ordinal);
|
||||
Assert.Contains("<h1>Body heading</h1>", rendered, StringComparison.Ordinal);
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using AngleSharp.Html.Parser;
|
||||
using MarkdownViewr.Windows.Core.Documents;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.Core.Tests;
|
||||
|
||||
/// <summary>Proves section boundaries and labels without giving authored markup native identities.</summary>
|
||||
public sealed class DocumentOutlineTests
|
||||
{
|
||||
/// <summary>Peer headings close their predecessor; ancestors close nested descendants.</summary>
|
||||
[Fact]
|
||||
public void Create_NestedAndDuplicateHeadings_GroupsCorrectContent()
|
||||
{
|
||||
var outline = new DocumentOutline("Intro\n\n# Parent\n\nBody one\n\n### Child\n\nNested body\n\n## Peer\n\nPeer body\n\n# Parent\n\nSecond parent");
|
||||
Assert.Equal(new[] { new DocumentHeading(0, 1, "Parent"), new DocumentHeading(1, 3, "Child"), new DocumentHeading(2, 2, "Peer"), new DocumentHeading(3, 1, "Parent") }, outline.Headings);
|
||||
using AngleSharp.Html.Dom.IHtmlDocument document = new HtmlParser().ParseDocument(outline.Html);
|
||||
Assert.Contains("Intro", document.Body!.FirstElementChild!.TextContent, StringComparison.Ordinal);
|
||||
Assert.Contains("Nested body", document.QuerySelector("[data-mv-body='0'] [data-mv-body='1']")!.TextContent, StringComparison.Ordinal);
|
||||
Assert.NotNull(document.QuerySelector("[data-mv-body='0'] [data-mv-body='2']"));
|
||||
Assert.DoesNotContain("Second parent", document.QuerySelector("[data-mv-body='0']")!.TextContent, StringComparison.Ordinal);
|
||||
Assert.Contains("Second parent", document.QuerySelector("[data-mv-body='3']")!.TextContent, StringComparison.Ordinal);
|
||||
Assert.Equal(4, document.QuerySelectorAll("[id^='mv-heading-']").Length);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>All heading levels and nested raw containers retain semantic heading text.</summary>
|
||||
[Fact]
|
||||
public void Create_AuthoredContainers_UsesSanitizedHostIdentities()
|
||||
{
|
||||
var outline = new DocumentOutline("<div id='mv-heading-0'><h4 onclick='evil()'>A & <em>B</em></h4><p data-mv-body='0'>Nested</p><h5>Five</h5><h6>Six</h6></div>\n\n<h2 id='native'>Two</h2>\n\n<script>evil()</script>");
|
||||
Assert.Equal(new[] { 4, 5, 6, 2 }, outline.Headings.Select(heading => heading.Level));
|
||||
Assert.Equal("A & B", outline.Headings[0].Title);
|
||||
using AngleSharp.Html.Dom.IHtmlDocument document = new HtmlParser().ParseDocument(outline.Html);
|
||||
Assert.Single(document.QuerySelectorAll("#mv-heading-0"));
|
||||
Assert.Null(document.QuerySelector("script,[onclick],#native"));
|
||||
Assert.Contains("Nested", document.QuerySelector("[data-mv-body='0']")!.TextContent, StringComparison.Ordinal);
|
||||
Assert.Null(document.QuerySelector("p[data-mv-body]"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Ordinary text, empty documents and hidden metadata do not manufacture headings.</summary>
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("Text **only**")]
|
||||
[InlineData("---\n# Metadata\n---\nBody")]
|
||||
public void Create_NoHeadings_RetainsBody(string markdown)
|
||||
{
|
||||
var outline = new DocumentOutline(markdown);
|
||||
Assert.Empty(outline.Headings);
|
||||
Assert.DoesNotContain("data-mv-body", outline.Html, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Rendering frontmatter as Markdown participates in the same sanitized outline.</summary>
|
||||
[Fact]
|
||||
public void Create_MarkdownMetadata_IncludesVisibleHeadings()
|
||||
{
|
||||
var outline = new DocumentOutline("---\n# Metadata\n---\n# Body", FrontmatterMode.Markdown);
|
||||
Assert.Equal(new[] { "Metadata", "Body" }, outline.Headings.Select(heading => heading.Title), StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Corrupt saved sizes and depth cannot create an unusable native sidebar.</summary>
|
||||
[Theory]
|
||||
[InlineData(-1, double.NaN, 1, 220)]
|
||||
[InlineData(99, double.PositiveInfinity, 6, 220)]
|
||||
[InlineData(3, 0, 3, 140)]
|
||||
[InlineData(4, 900, 4, 500)]
|
||||
[InlineData(2, 240, 2, 240)]
|
||||
public void Sanitize_OutlinePreferences_ClampsSupportedRange(int depth, double width, int expectedDepth, double expectedWidth)
|
||||
{
|
||||
var settings = new AppSettings { ShowOutline = true, OutlineDepth = depth, OutlineWidth = width };
|
||||
settings.Sanitize();
|
||||
Assert.True(settings.ShowOutline);
|
||||
Assert.Equal(expectedDepth, settings.OutlineDepth);
|
||||
Assert.Equal(expectedWidth, settings.OutlineWidth);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>GFM fragments resolve duplicate and Unicode headings without retaining authored identifiers.</summary>
|
||||
[Fact]
|
||||
public void Create_FragmentLinks_ResolveGeneratedTargets()
|
||||
{
|
||||
var outline = new DocumentOutline("# Hello **world**!\n\n# Hello world!\n\n# Café\n\n[first](#hello-world) [duplicate](#hello-world-1) [unicode](#caf%C3%A9) [unknown](#unknown) [web](https://example.com)");
|
||||
using AngleSharp.Html.Dom.IHtmlDocument document = new HtmlParser().ParseDocument(outline.Html);
|
||||
Assert.Equal(new[] { "#mv-heading-0", "#mv-heading-1", "#mv-heading-2", "#unknown", "https://example.com" }, document.QuerySelectorAll("a").Select(link => link.GetAttribute("href")), StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
@@ -186,11 +186,18 @@ public class AppTests
|
||||
session.WaitFor(() => session.FindInApp(cf => cf.ByControlType(FlaUI.Core.Definitions.ControlType.Table)), "the frontmatter table");
|
||||
session.InvokeMenuItem("View", "FrontmatterMenu");
|
||||
session.ClickElement("FrontmatterMarkdown");
|
||||
AppSession.WaitUntilGone(() => session.FindInApp(cf => cf.ByControlType(FlaUI.Core.Definitions.ControlType.Table)), "the metadata table after switching to Markdown");
|
||||
try
|
||||
{
|
||||
session.WaitFor(
|
||||
() => session.ReadSettings().Contains("\"Frontmatter\": \"Markdown\"", StringComparison.Ordinal) ? session.MainWindow : null,
|
||||
"the Markdown frontmatter choice to be saved");
|
||||
AppSession.WaitUntilGone(() => session.FindInApp(cf => cf.ByControlType(FlaUI.Core.Definitions.ControlType.Table)), "the metadata table after switching to Markdown");
|
||||
}
|
||||
catch(TimeoutException exception)
|
||||
{
|
||||
throw new TimeoutException($"{exception.Message} Settings: {session.ReadSettings()} Logs: {session.ReadLogs()} Native tree: {session.DescribeDocumentStatus()}", exception);
|
||||
}
|
||||
session.WaitFor(() => session.FindInApp(cf => cf.ByName("Visible metadata")), "the rendered frontmatter heading");
|
||||
session.WaitFor(
|
||||
() => session.ReadSettings().Contains("\"Frontmatter\": \"Markdown\"", StringComparison.Ordinal) ? session.MainWindow : null,
|
||||
"the Markdown frontmatter choice to be saved");
|
||||
session.InvokeMenuItem("View", "FrontmatterMenu");
|
||||
session.ClickElement("FrontmatterHide");
|
||||
session.WaitFor(() => session.FindInApp(cf => cf.ByName("Reloaded document.md")), "the hidden-mode reload status");
|
||||
@@ -199,6 +206,101 @@ public class AppTests
|
||||
}
|
||||
|
||||
|
||||
/// <summary>The native depth choice filters exact headings and a real divider drag saves its new width.</summary>
|
||||
[Fact]
|
||||
public void Outline_DepthAndResize_PersistsNativeChoices()
|
||||
{
|
||||
using var session = new AppSession(document: "# Root outline\n\n## Child outline\n\n#### Deep outline");
|
||||
session.WaitFor(() => session.FindInApp(cf => cf.ByName("Root outline")), "the document heading", TimeSpan.FromSeconds(30));
|
||||
session.InvokeMenuItem("View", "OutlineToggle");
|
||||
session.WaitForElement("OutlineHeading:1");
|
||||
Assert.Null(session.FindInApp(cf => cf.ByAutomationId("OutlineHeading:2")));
|
||||
session.WaitForElement("OutlineDepth").AsComboBox().Select(0);
|
||||
session.WaitForElementGone("OutlineHeading:1");
|
||||
session.WaitForElement("OutlineDepth").AsComboBox().Select(3);
|
||||
session.WaitForElement("OutlineHeading:2");
|
||||
AutomationElement divider = session.WaitForElement("OutlineDivider");
|
||||
session.MainWindow.SetForeground();
|
||||
Assert.True(divider.TryGetClickablePoint(out System.Drawing.Point start));
|
||||
Assert.Equal(session.App.ProcessId, session.Automation.FromPoint(start).Properties.ProcessId.ValueOrDefault);
|
||||
FlaUI.Core.Input.Mouse.DragHorizontally(start, 70);
|
||||
session.WaitFor(
|
||||
() =>
|
||||
{
|
||||
using var settings = JsonDocument.Parse(session.ReadSettings());
|
||||
return settings.RootElement.GetProperty("OutlineWidth").GetDouble() > 240 ? session.MainWindow : null;
|
||||
},
|
||||
"the resized outline width to be saved");
|
||||
using var saved = JsonDocument.Parse(session.ReadSettings());
|
||||
Assert.True(saved.RootElement.GetProperty("ShowOutline").GetBoolean());
|
||||
Assert.Equal(4, saved.RootElement.GetProperty("OutlineDepth").GetInt32());
|
||||
session.InvokeMenuItem("View", "OutlineToggle");
|
||||
session.WaitForElementGone("OutlineHeading:0");
|
||||
_output.WriteLine(session.DescribeDocumentStatus());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A heading selection moves the actual browser viewport and all-section operations change displayed content.</summary>
|
||||
[Fact]
|
||||
public void Outline_HeadingJumpAndCollapse_UpdatesActualDocument()
|
||||
{
|
||||
string paragraphs = string.Join("\n\n", Enumerable.Range(0, 100).Select(index => "Long outline paragraph " + index));
|
||||
using var session = new AppSession(document: "# Start outline\n\n" + paragraphs + "\n\n## Target outline\n\nTarget section content\n\n## Peer outline\n\nPeer section content");
|
||||
session.WaitFor(() => session.FindInApp(cf => cf.ByName("Start outline")), "the initial heading", TimeSpan.FromSeconds(30));
|
||||
session.InvokeMenuItem("View", "OutlineToggle");
|
||||
session.ClickElement("OutlineHeading:1");
|
||||
session.WaitFor(
|
||||
() =>
|
||||
{
|
||||
AutomationElement scroller = FindDocumentScroller(session);
|
||||
return scroller.Patterns.Scroll.Pattern.VerticalScrollPercent.Value > 70 ? scroller : null;
|
||||
},
|
||||
"the actual browser to jump to the target heading");
|
||||
session.ClickElement("ToggleSection");
|
||||
AppSession.WaitUntilGone(() => session.FindInApp(cf => cf.ByName("Target section content")), "the collapsed target body");
|
||||
Assert.NotNull(session.FindInApp(cf => cf.ByName("Peer section content")));
|
||||
session.ClickElement("ToggleSection");
|
||||
session.WaitFor(() => session.FindInApp(cf => cf.ByName("Target section content")), "the expanded target body");
|
||||
session.ClickElement("CollapseSections");
|
||||
AppSession.WaitUntilGone(() => session.FindInApp(cf => cf.ByName("Peer section content")), "all collapsed section content");
|
||||
session.ClickElement("ExpandSections");
|
||||
session.WaitFor(() => session.FindInApp(cf => cf.ByName("Target section content")), "all expanded sections");
|
||||
session.WaitFor(() => session.FindInApp(cf => cf.ByName("Peer section content")), "the expanded peer section");
|
||||
_output.WriteLine(session.DescribeDocumentStatus());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Expanding a parent retains a collapsed child, and theme/reload retain the same hierarchy's collapse state.</summary>
|
||||
[Fact]
|
||||
public void Outline_NestedCollapse_RetainsStateAcrossThemeAndReload()
|
||||
{
|
||||
using var session = new AppSession(document: "# Parent outline\n\nParent body\n\n## Child outline\n\nHidden child body\n\n## Peer outline\n\nVisible peer body");
|
||||
session.WaitFor(() => session.FindInApp(cf => cf.ByName("Parent body")), "the original body", TimeSpan.FromSeconds(30));
|
||||
session.InvokeMenuItem("View", "OutlineToggle");
|
||||
session.ClickElement("OutlineHeading:1");
|
||||
session.ClickElement("ToggleSection");
|
||||
AppSession.WaitUntilGone(() => session.FindInApp(cf => cf.ByName("Hidden child body")), "the collapsed child");
|
||||
session.ClickElement("OutlineHeading:0");
|
||||
session.ClickElement("ToggleSection");
|
||||
AppSession.WaitUntilGone(() => session.FindInApp(cf => cf.ByName("Visible peer body")), "the collapsed parent");
|
||||
session.ClickElement("ToggleSection");
|
||||
session.WaitFor(() => session.FindInApp(cf => cf.ByName("Visible peer body")), "the expanded parent");
|
||||
Assert.Null(session.FindInApp(cf => cf.ByName("Hidden child body")));
|
||||
session.InvokeMenuItem("View", "DocumentThemesMenu");
|
||||
session.ClickElement("DocumentTheme:GitHub Light");
|
||||
WaitForDocumentColor(session, System.Drawing.Color.White);
|
||||
Assert.Null(session.FindInApp(cf => cf.ByName("Hidden child body")));
|
||||
File.WriteAllText(Path.Combine(session.DataDirectory, "document.md"), "# Parent outline\n\nParent body\n\n## Child outline\n\nUpdated child body\n\n## Peer outline\n\nUpdated peer body");
|
||||
session.WaitFor(() => session.FindInApp(cf => cf.ByName("Updated peer body")), "the new snapshot");
|
||||
Assert.Null(session.FindInApp(cf => cf.ByName("Updated child body")));
|
||||
session.ClickElement("OutlineHeading:1");
|
||||
session.ClickElement("ToggleSection");
|
||||
session.WaitFor(() => session.FindInApp(cf => cf.ByName("Updated child body")), "the expanded reloaded child");
|
||||
}
|
||||
|
||||
|
||||
/// <summary>Updates: a build that was not installed by Velopack says updates need an install.</summary>
|
||||
[Fact]
|
||||
public void CheckForUpdates_InALooseBuild_SaysUpdatesNeedAnInstall()
|
||||
|
||||
Reference in new issue
Block a user