Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@ Public API is the barrel `lib/flutter_md.dart` (`export … show …`). See
offsets (highlight stays right, copied text drifts). Avoid with selection on.
- `MarkdownSelectionController.selectionColor` setter repaints surfaces directly
and must **not** `notifyListeners` (it's applied during build → would `setState`).
- `removeDocument` while a surface is mounted is deferred until `detachSurface`;
`putDocument` cancels a pending remove. Unregistered selection endpoints must
not paint via `-1` ordering — `rangeFor` returns null. Supply unique `order`
values for co-hosted bodies.
- Alert **title** is not selectable (body only). Keyboard word/line extension is
block-approximate. `MarkdownSelectedBlock.sourceRange` is currently always null.
- `__x__` = **underline**, not bold. Soft line breaks are preserved inside
Expand Down
141 changes: 141 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,144 @@
## Unreleased

### Quote / alert nested fenced code
- **FIXED**: Fenced code (` ```lang ` / `~~~`) inside a blockquote or GitHub
alert is re-parsed as nested `MD$Code` in `MD$Quote.blocks` /
`MD$Alert.blocks`, instead of being consumed as inline monospace by backtick
pairing (` `` ` + monospace + ` `` `).
- **ADDED**: Optional `blocks` on `MD$Quote` / `MD$Alert` (empty for leaf
inline bodies). Render and `markdownBlockRenderedText` walk nested children
when present.
- **FIXED**: Prose nested inside a quote (paragraphs/lists/headings beside a
fence) uses `quoteStyle` again; code/table chrome keeps the document theme.

### Glyph-tight hit testing
- **CHANGED**: Hover I-beam and link hit-testing use rendered **line/glyph
boxes**, not the full max-width layout of a paragraph. Empty horizontal
gutter beside a short line is no longer I-beam / click-to-open.
- **CHANGED**: Gesture *starts* (`hitsSelectableContent`) stay **surface-
bounds**. A press inside the document or bubble arms text selection.
Drag-extend may still clamp outside glyphs.
- **CHANGED**: Mouse single-click that misses selectable content while a
non-collapsed range is active clears the selection, instead of leaving
the old range.
- **ADDED**: `SelectableBlockPainter.hitsRenderedTextAt`,
`MarkdownSelectionSurface.hitsSelectableGlyphs`, and
`MarkdownSelectionController.hitsSelectableGlyphs`.

### Selection host gates
- **ADDED**: `MarkdownSelectionScope.canStartSelectionAt` — optional host gate
so enclosing UIs can refuse selection starts on chrome (links, code headers)
without the scope claiming the pointer.
- **ADDED**: `MarkdownSelectionScope.enableTouchGestures` — when false, touch /
stylus / trackpad selection recognizers stay off; mouse multi-click, handles,
toolbar, and keyboard remain. Focus loss also does not clear the range (host
viewport may steal focus).
- **ADDED**: `MarkdownSelectionScope.enableTouchConsecutiveTaps` — when false
(with `enableTouchGestures` true), touch long-press → word → drag-extend
still arms, but touch multi-tap / horizontal-drag selection does not. Chat
hosts use this so taps stay with the viewport while continuous text entry
works. Focus loss also does not clear the range in that mode.
- **ADDED**: `MarkdownSelectionScope.ownsSelectionChrome` — optional host gate
so only the scope that owns the selection’s document paints handles/toolbar
when several scopes share one controller (chat per-body mounts).
- **FIXED**: Disabled / non-owning sibling scopes no longer clear handle
leaders on every shared-controller surface. Clearing is scoped to leaders
that reference that scope’s own `LayerLink`s, so chat dual mounts keep
handles after non-collapsed range commits (including repeated handle-drag
settles).
- **ADDED**: `MarkdownSelectionSurface.hasSelectionHandleLeaders`,
`clearSelectionHandleLayersIfLinked`, and
`MarkdownSelectionScopeState.selectionHandleLeadersAttached` for observing
coherent handle-leader attachment across multi-scope hosts.
- **CHANGED**: Flipping `enabled` from false → true with an existing
non-collapsed range restores handles; when `toolbarWanted` is set, restores
the toolbar. While disabled, the scope stays inert for chrome (no toolbar /
handles) but keeps `toolbarWanted` for that restore path.

### Dynamic cursor resolution & span bounding boxes
- **ADDED**: `MarkdownThemeData.cursorResolver` and
`MarkdownWidget.cursorResolver` (`MarkdownCursorResolver`) — unopinionated
hook to dynamically resolve hover mouse cursors per local offset, block
index, and block model, falling back to default link/text/defer cursors when
returning null.
- **ADDED**: `MarkdownSelectionSurface.localBoxesForRange` and
`MarkdownPainter.localBoxesForRange` — fast query returning content-local
bounding boxes for an arbitrary character range within a block, directly from
cached block painters without re-layout.

### Line clamp
- **ADDED**: `clampMarkdownToLines` / `MarkdownLineClamp` — painter-measured
height for the first N visual text lines at a given width (line-boundary cut;
spacers/dividers add height without spending the line budget). Callers size a
clipped box; the renderer itself has no line budget.

### Registry / multi-body selection
- **FIXED**: `removeDocument` defers while a surface for that id is mounted
and flushes on `detachSurface`; a later `putDocument` cancels the pending
remove. Parent `State.dispose` can run before child detach — eager remove
left hittable surfaces with no registry entry (`rangeFor` / ordering broken).
- **FIXED**: `rangeFor` and endpoint ordering ignore unregistered document ids
instead of treating them as index `-1` (which painted every body from the
start of the registry through the other endpoint).
- **CHANGED**: `MarkdownRenderObject` heals with `putDocument` on attach /
when the controller is wired while already attached, so a mounted selectable
surface is never missing from the registry. Prefer explicit app registration
for unmounted docs and unique reading-order `order` values.

### Selection chrome (SelectionArea parity)
- **ADDED**: Read-only selection chrome on the controller /
`MarkdownSelectionScope` path brought up to Flutter `SelectableRegion` /
`SelectionArea` quality without remounting SelectionArea:
- Content-gated touch `TapAndHorizontalDragGestureRecognizer` + long-press
(consecutive taps, no `DoubleTapGestureRecognizer` arena delay); mouse
`TapAndPanGestureRecognizer`.
- Gesture **starts** require a hit on mounted selectable markdown (chrome /
empty space do not nearest-neighbor clamp); clamp remains for **extend**
across gaps.
- Soft-wrap affinity on handles; directed base/extent edges with reverse
handle types; coincident-caret separation; handle proxies when an endpoint
surface unmounts (virtualization).
- Native `SelectionOverlay` handles + magnifier on touch; LeaderLayer follow
across scroll / multi-widget hosts.
- Keyboard Copy / Select-all / Shift-extend / Esc; adaptive Copy / Select-all
toolbar with live re-anchoring.
- Word / block granular multi-tap and long-press; link hand cursor / I-beam on
selectable content.
- **CHANGED**: Desktop selection toolbar parity with stock Flutter:
- On desktop (`macOS`, `Linux`, `Windows`), mouse drags, double/triple clicks,
keyboard shortcuts (`Cmd/Ctrl+A`), and programmatic selection updates
do not pop up the toolbar.
- Right-click on desktop shows the context toolbar at the click coordinates
without mutating the active selection (no select-word / caret collapse).
- Desktop context menu preserves its right-click anchor when triggering
actions such as "Select all", rather than jumping to selection endpoints.
- Desktop context menu dismisses immediately upon scroll.
- On mobile (`Android`, `iOS`), touch gestures and programmatic selection
(`selectAll`, `selection = ...`) continue to present the adaptive toolbar
and handles with action items.

### Selection engine rework
- **ADDED**: Edge-zone autoscroll while dragging (body / handle / long-press)
near the padded viewport — host-union hard-stop, direction arming gate,
past-viewport max velocity while the union still allows that direction
(`MarkdownSelectionScope.autoscroll`, default `edgeZone: 48`).
- **CHANGED**: Toolbar hides while expanding (body or handle drag) and may
re-show on drag end; anchors recompute on selection change, host/ancestor
scroll, and mounted-surface layout change.
- **CHANGED**: Toolbar placement — both endpoints in clip use stock
above-preferring anchors; **bottom-only** endpoint prefers below that caret;
neither endpoint in clip (tall mid-viewport) top-pins so the below-fallback
cannot sink to the host bottom; empty intersection hides the overlay while
`toolbarWanted` restores on scroll-back / remount.
- **FIXED**: Selection highlight stays outside the glyph `Picture` cache —
under glyphs by default (sharp text), with a second pass **above** opaque
chrome (`selectionHighlightAboveCachedContent`) for code fences, table zebra
rows, and inline monospace / highlight backgrounds
(`markdownSpansPaintOpaqueBackground`).
- **FIXED**: Focus loss does not clear selection while a pointer drag is active
(list rebuilds during autoscroll); geometry walks defer off `performLayout`
(`sizeAccessAllowed`).

## 0.2.0

> **Upgrading from 0.0.x?** See the
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,9 @@ final MarkdownSelectedContent structured = controller.selectedContent();
- **Streaming stays anchored.** Call `controller.putDocument(id, newModel)` when
a message grows; the default `MarkdownReconciliationPolicy.contentAnchored`
keeps the selection (append fast-path, else relocate by content, else clamp).
Prefer unique reading-order `order` values. `removeDocument` waits for surface
detach when the body is still mounted; a remount `putDocument` cancels a
pending remove.
- **One selection at a time.** Share a `MarkdownSelectionGroup` between
controllers so selecting in one clears the others; call `group.clearExternal()`
when a plain `SelectableText`/`SelectionArea` starts its own selection.
Expand Down Expand Up @@ -510,7 +513,7 @@ class _MyWidgetState extends State<MyWidget> {
## 📊 Performance

`flutter_md` is built for speed: a single-pass, lookup-table parser (regex-free
hot path with a plain-text fast path) and a custom render object that lays the
hot path with a plain text fast path) and a custom render object that lays the
whole document into one cached `ui.Picture` instead of a deep tree of per-block
widgets.

Expand Down
4 changes: 2 additions & 2 deletions benchmark_compare/benchmark/parser_benchmark.dart
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,8 @@ class _MarkdownPkgBenchmark extends BenchmarkBase {
void run() {
// A fresh Document per parse — exactly how flutter_markdown uses the
// package on every rebuild (Document is single-use / stateful).
final nodes = md.Document(extensionSet: md.ExtensionSet.gitHubFlavored)
.parse(input);
final nodes =
md.Document(extensionSet: md.ExtensionSet.gitHubFlavored).parse(input);
_sink ^= nodes.length;
}
}
3 changes: 1 addition & 2 deletions benchmark_compare/integration_test/scroll_perf_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,7 @@ final Map<String, StyledFactory> _libraries = styledLibraries;
// and has no structural chrome, so any raster difference on it is due purely to
// the rendering model (single cached ui.Picture vs a widget tree), not layout
// density or code/table/list chrome.
const String _feedMode =
String.fromEnvironment('FEED', defaultValue: 'mixed');
const String _feedMode = String.fromEnvironment('FEED', defaultValue: 'mixed');

/// A realistic chat feed: representative documents, cycled. Excludes the huge
/// `complex_large` tier so many messages fit in a scrollable list.
Expand Down
9 changes: 7 additions & 2 deletions benchmark_compare/test/render_benchmark_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -152,8 +152,13 @@ void _printTables() {
final docs = renderCorpus.keys.toList();
final libs = styledLibraries.keys.toList();

_matrix('Render (end-to-end: parse + build + layout + paint), us/op — '
'lower is better', docs, libs, _results, (v) => v.toStringAsFixed(1));
_matrix(
'Render (end-to-end: parse + build + layout + paint), us/op — '
'lower is better',
docs,
libs,
_results,
(v) => v.toStringAsFixed(1));

// Relative-to-flutter_md view (how many times slower each library is).
const base = 'flutter_md';
Expand Down
3 changes: 1 addition & 2 deletions benchmark_compare/tool/summarize_timeline.dart
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,7 @@ void main() {
final row = StringBuffer('|');
for (final lib in _libs) {
final s = summaries[lib];
final v =
s == null ? null : _num(s, 'average_frame_build_time_millis');
final v = s == null ? null : _num(s, 'average_frame_build_time_millis');
final cell = v == null ? '-' : '${(v / baseBuild).toStringAsFixed(2)}x';
row.write(' ${cell.padLeft(16)} |');
}
Expand Down
7 changes: 4 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,10 @@ defaults), lays them out top-to-bottom recording each block's top-`y` in
For selection, the same widget opts in when given a `documentId` **and** a
resolvable `MarkdownSelectionController` (explicit or via an ambient
`MarkdownSelectionScope`). The render object then registers itself with the
controller as a `MarkdownSelectionSurface` and paints the selection highlight
(looked up from the controller) on top of the glyphs, so it stays visible over
opaque backgrounds. See [selection](selection.md).
controller as a `MarkdownSelectionSurface`, heals the document into the registry
on attach, and paints the selection highlight (looked up from the controller) on
top of the glyphs, so it stays visible over opaque backgrounds. Registry remove
is deferred while a surface is still mounted. See [selection](selection.md).

## Why the model is immutable and selection anchors to it

Expand Down
2 changes: 1 addition & 1 deletion docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ Base `package:flutter_lints/flutter.yaml`, plus `strict-casts`, `strict-raw-type

## Dependencies

- `dependencies`: `flutter` (sdk), `meta: ^1.16.0`.
- `dependencies`: `flutter` (sdk), `meta: any`.
- `dev_dependencies`: `flutter_test` (sdk), `flutter_lints: >=5.0.0 <7.0.0`,
`markdown: ^7.3.0` (benchmark comparison only), `benchmark_harness: ^2.3.1`.

Expand Down
12 changes: 8 additions & 4 deletions docs/parser.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,8 @@ signature — a breaking change to every override.
|---|---|---|
| `MD$Paragraph` | `paragraph` | `text`, `List<MD$Span> spans` |
| `MD$Heading` | `heading` | `int level` (1–6), `text`, `spans` |
| `MD$Quote` | `quote` | `int indent` (**always 1** — nested `>>` not modeled yet), `text`, `spans` |
| `MD$Alert` | `alert` | `MD$AlertType alert`, `text` (body), `spans` (body only) |
| `MD$Quote` | `quote` | `int indent` (**always 1** — nested `>>` not modeled yet), `text`, `spans` (leaf) **or** `blocks` (when the body contains a fence / nested structure; then `spans` is empty) |
| `MD$Alert` | `alert` | `MD$AlertType alert`, `text` (body), `spans` (body only) **or** `blocks` when the body contains a fence |
| `MD$Code` | `code` | `String? language` (may be `''`), `text` (raw, never span-parsed) |
| `MD$List` | `list` | `text` (raw slice), `List<MD$ListItem> items` |
| `MD$Divider` | `divider` | none; `text == '---'` |
Expand Down Expand Up @@ -131,7 +131,10 @@ Block-level:
- **Tables** with a delimiter row encoding per-column alignment (`:--`, `:-:`,
`--:`); malformed/ragged tables fall back to paragraph.
- **Alerts** — a blockquote whose first line is `[!NOTE|TIP|IMPORTANT|WARNING|CAUTION]`;
unknown markers fall back to a plain quote.
unknown markers fall back to a plain quote. Alert/quote bodies that contain a
fenced code line (` ``` ` / `~~~`) are **re-parsed as nested blocks**
(`MD$Quote.blocks` / `MD$Alert.blocks`) so fences stay `MD$Code` instead of
being misread as inline monospace by `_parseInlineSpans`.
- **Thematic breaks** `---`/`***`/`___` (3+ markers); **ATX headings** `#`–`######`
(`#hashtag` and 7+ `#` are paragraphs; trailing `#` stripped); **fenced code**
` ``` ` / `~~~` with a language label; unterminated fences run to EOF.
Expand All @@ -148,7 +151,8 @@ Block-level:
Tree assembly is a recursive `traverse` over a flat list with a shared mutable
offset closure (note the intermediate record misspells the field `intent`).
- `MD$Quote.indent` is hardcoded to 1; alert bodies are trimmed (`skip(1).join('\n').trim()`)
while quote bodies keep raw `join('\n')`.
while quote bodies keep raw `join('\n')`. Plain quotes/alerts stay leaf `spans`;
a body that contains a fence line is re-parsed into `blocks` (spans empty).
- `MD$Code.language` can be `''` (not null) for a bare fence; `MD$Code.text` is raw.
- `MD$Table.alignments` may be shorter than the column count — always use
`alignmentFor(i)`, never index directly.
Expand Down
28 changes: 19 additions & 9 deletions docs/rendering.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ use `size.width` for full-width backgrounds/rules and to bail when too narrow).
String get renderedText; // MUST equal markdownBlockRenderedText(block)
int offsetForLocalPosition(Offset local); // block-local → rendered-text index
List<Rect> boxesForRange(int start, int end);// block-local highlight rects for [start, end)
bool get selectionHighlightAboveCachedContent; // true ⇒ also paint highlight above Picture
```

Two mixins implement it for you:
Expand Down Expand Up @@ -189,6 +190,10 @@ Render-override hooks:
dropping text-bearing spans shifts the painter's offset space vs the model, so
the highlight stays right but copied text can misalign. Avoid dropping
text-bearing spans when selection is enabled.**
- **`cursorResolver`** `MouseCursor? Function(Offset, int?, MD$Block?)` —
dynamically resolves hover mouse cursors per local offset, hit block index,
and hit block model, falling back to default link/text/defer cursors when null.
Can also be supplied directly to `MarkdownWidget.cursorResolver`.

Styling: `textStyle`, per-level `h1Style..h6Style` (+ cached `headingStyleFor`),
`textStyleFor(MD$Style)` (cached mapping of the bitmask → bold/italic/underline/
Expand All @@ -211,12 +216,17 @@ $Spacer`). Internal (`@meta.internal`, only via `src/render.dart`):

- Glyphs cached in a `ui.Picture` keyed by size; reused on repaint; nulled only on
`update`/`invalidateLayout`.
- **Selection highlight is painted outside that cache, on top of the glyphs**
(`MarkdownRenderObject.paint` calls `_painter.paint(...)` then
`_painter.paintHighlight(...)`), so drags/streaming never rebuild the glyph
cache, and a translucent highlight stays visible over opaque block/inline
backgrounds (code fences, `inline code`, `==mark==`). Color =
`controller.selectionColor ?? _kSelectionColor` (`0x552196F3`).
- `isRepaintBoundary => controller != null`; `alwaysNeedsCompositing => false`.
- Handle `LeaderLayer`s are pushed in `paint` (only when a scope supplied
start/end `LayerLink` + local offset) so native handles follow scrolling content.
- **Selection highlight is painted outside the glyph [Picture] cache**, in two
passes matching `SelectableRegion` / `SelectionArea`: first **under** the
content picture (sharp text — no washed-out overlay), then **above** only for
blocks that paint opaque chrome (`selectionHighlightAboveCachedContent`, e.g.
code fences, table zebra rows, and blocks whose spans use monospace/highlight
backgrounds) so the tint stays visible there. Drags/streaming never rebuild
the glyph cache. Color = `controller.selectionColor ?? _kSelectionColor`
(`0x552196F3`).
- `isRepaintBoundary => controller != null`; `alwaysNeedsCompositing` when the
surface owns a start and/or end handle `LeaderLayer`.
- Handle `LeaderLayer`s are pushed in `paint` via `LayerHandle` (only when a
scope supplied start/end `LayerLink` + local offset) so native handles follow
scrolling content. Links clear on detach so remount / virtualization cannot
leave ghost leaders.
Loading