Skip to content
Merged
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
32 changes: 18 additions & 14 deletions packages/mixin_markdown_widget/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,16 @@ The public entrypoint is [packages/mixin_markdown_widget/lib/mixin_markdown_widg

- `src/widgets/markdown_widget.dart`: top-level widget API, fallback controller ownership, theme wiring, and document/selection attachment.
- `src/widgets/markdown_controller.dart`: document ownership, `setData`, `replaceAll`, `appendChunk`, `commitStream`, and plain-text copy helpers.
- `src/core/document.dart`: internal document model (`BlockNode`, `InlineNode`, selections, table cell ranges, image block metadata).
- `src/core/document.dart`: internal document model (`BlockNode`, `InlineNode`, selections, image block metadata).
- `src/parser/markdown_document_parser.dart`: Markdown AST to internal model conversion, including append-only incremental reparsing.
- `src/render/markdown_document_view.dart`: main state orchestrator and coordinator. Connects blocks, selections, gestures, and UI builders together without doing heavy layout math.
- `src/render/builder/`: contains `markdown_block_builder.dart` and `markdown_inline_builder.dart` which convert `BlockNode` and `InlineNode` respectively into actual visual `Widget`s or `TextSpan`s.
- `src/render/selection/`: contains `markdown_selection_resolver.dart` (heavy geometric rules for offsets/rects), `markdown_descriptor_extractor.dart` (converts visual blocks to selectable descriptors), and `markdown_selection_gesture_detector.dart` (drags, auto-scroll).
- `src/render/selection/`: contains `markdown_selectable_leaf.dart` (selectable text leaves, structural slots, and the per-block leaf registry), `markdown_selection_geometry.dart` (the one container algorithm for quotes, details, lists, footnotes, and tables), `markdown_block_keys_registry.dart` (top-level block keys, scroll controllers, viewport keys), `markdown_descriptor_extractor.dart` (rendered text and spans for definition lists, captions, and container text spans), and `markdown_selection_gesture_detector.dart` (drags, auto-scroll).
- `src/render/shortcuts/`: contains ContextMenu bindings and keyboard action Intents (like `SelectAllMarkdownIntent`).
- `src/render/markdown_block_widgets.dart`: shared block UI widgets for lists, quotes, tables, code blocks, and other visual shells.
- `src/render/pretext_text_block.dart`: pretext-backed inline layout and selection geometry for headings, paragraphs, list text, quote text, and table cells.
- `src/selection/selection_controller.dart`: authoritative selection state for text and table-cell selections.
- `src/render/pretext_text_block.dart`: pretext-backed inline layout and line geometry used by text leaves.
- `src/selection/selection_controller.dart`: authoritative selection state. Clamping and select-all delegate to `MarkdownPlainTextSerializer`.
- `src/selection/structured_block_selection.dart`: the structured selection model of each block (leaves, display/export text), cached per `BlockNode`.
- `src/clipboard/plain_text_serializer.dart`: canonical plain-text export semantics for whole documents, ranges, lists, quotes, footnotes, and tables.

## Non-Negotiable Invariants
Expand All @@ -51,27 +52,30 @@ The public entrypoint is [packages/mixin_markdown_widget/lib/mixin_markdown_widg

### Selection architecture

- Selection is model-based: `DocumentPosition`, `DocumentSelection`, `DocumentRange`, and `TableCellSelection` must stay coherent with what is rendered.
- `MarkdownPlainTextSerializer` defines copy semantics. If rendering or selection behavior changes, make sure serializer output still matches the logical selection.
- Table text selection and table-cell selection are separate behaviors. Do not collapse them into one mechanism.
- Selection is model-based: `DocumentPosition`, `DocumentSelection`, and `DocumentRange` must stay coherent with what is rendered.
- `MarkdownPlainTextSerializer` defines copy semantics and selection bounds. The selection controller clamps and selects all through it, so a serializer subclass changes both consistently. If rendering or selection behavior changes, make sure serializer output still matches the logical selection.
- A table is selected as text: cells joined with `\t` and rows with `\n`. Each non-empty cell is a leaf at path `[row, column]`; there is no separate table-cell selection type.
- `StructuredBlockSelection.forBlock` is cached per `BlockNode` and is the single source of block text for the serializer, clamping, select-all, and container geometry. Do not rebuild block text elsewhere.
- Composite blocks such as lists, quotes, tables, and footnotes now use a structured selection index: `DocumentPosition.path` identifies the semantic leaf while `textOffset` stays in block display-text space. Do not flatten these selections back to `PathInBlock([0])` just to make offset math easier.
- Range-copy serialization should use the structured visible-text space when selection offsets come from rendered interaction, and full-document export/select-all should come from the same structured model's export leaves.
- Structured blocks now also carry export-text leaves. Full-block export and `selectAll` should use those structured export leaves while keeping selection offsets in visible text space; do not depend on serialized-length mismatches to trigger fallback behavior.
- Recursive hit-test and selection-unit helpers for list/quote/table containers must return block-local displayed offsets or ranges; convert back to parent space exactly once at the parent boundary.
- Leaf-local and block display offsets are converted exactly once, through the `StructuredBlockSelectionLeaf` of the leaf (`displayedOffsetForLocalOffset` / `localOffsetForDisplayedOffset`). Containers never re-convert per nesting level.
- Double-click word selection and triple-click selection-unit resolution must stay aligned with the same offset mapping semantics used by drag selection.

### Pretext-backed text rules

- Headings, paragraphs, list item text, quote text, and table cell text share pretext-backed inline rendering in important paths.
- Pretext selection rectangles must use the rendered line box height (`lineHeight`) for top/bottom edges, not glyph box heights from `TextPainter`, or selection backgrounds will look vertically short.
- If selection looks horizontally correct but vertically off, inspect `pretext_text_block.dart` first.
- Pretext selection units should prefer the clicked visual line when local pointer geometry is available; if only a text offset is available, fall back to explicit newline boundaries from descriptor plain text.
- Pretext selection units should prefer the clicked visual line when local pointer geometry is available; if only a text offset is available, fall back to explicit newline boundaries of the leaf text.

### Nested quote and list geometry rules
### Selection geometry: leaves and containers

- Nested quote selection cannot be measured from a flattened synthetic descriptor alone. Geometry must follow the real rendered child block subtree using stable child keys.
- Nested list selection has the same constraint. Measure against the actual child blocks inside each list item, not just the flattened list descriptor.
- List marker/prefix selection height should align with the first rendered child block in the item, not with the full row height when nested children make the row taller.
- Every selectable piece of text is a `MarkdownSelectableLeaf`, registered by its `PathInBlock` in the `MarkdownSelectionLeafScope` of its `SelectableMarkdownBlock`. A leaf answers its own geometry in its box coordinates and local offsets: `rectsFor`, `offsetAt`, `lineRangeAt`, an optional `clipRect` (the viewport of a horizontally scrolling code block or table), and its origin in the block.
- Leaf kinds: text leaves (`MarkdownTextLeafGeometry`, reading the live `RenderParagraph` when the runs render as one rich text and pretext otherwise), and placeholder leaves (`MarkdownPlaceholderLeafGeometry`) for images, thematic breaks, standalone line breaks, and custom code block builders, which select as a whole.
- Paragraphs, headings, code blocks, definition lists, and image captions are one leaf at `[0]`. Quotes, details, lists, footnotes, and tables use `MarkdownContainerSelectionGeometry`, which only orders leaves and reads `MarkdownSelectionSlot` boxes (child blocks, list rows, list content). Containers do not measure text themselves and keep no per-child `GlobalKey`s.
- Container rules: in a gap between stacked children, split at the gap midpoint (within 24px of horizontal overlap); otherwise use the nearest child's start or end. On a list item, the marker selects the item start and the marker rect spans the marker extent, aligned to the first line of the item's first child. A leading paragraph or heading takes its marker along in line selection. A table resolves to the nearest non-empty cell, and its selection unit is the whole cell.
- Leaf paths must match `StructuredBlockSelection`: quote child `[..., i]`, details summary `[..., 0]` and children `[..., i + 1]`, list marker `[..., item, 0]` and item children `[..., item, c + 1]`, table cell `[..., row, column]`.

### Decorated blocks and overlays

Expand All @@ -91,7 +95,7 @@ The public entrypoint is [packages/mixin_markdown_widget/lib/mixin_markdown_widg

## High-Risk Change Areas

- `src/render/selection/markdown_selection_resolver.dart`: small geometry changes can break hit testing, copy semantics, nested selection, or link tapping.
- `src/render/selection/markdown_selection_geometry.dart` and `markdown_selectable_leaf.dart`: small geometry changes can break hit testing, copy semantics, nested selection, or link tapping.
- `markdown_document_parser.dart`: parser shape changes often require matching updates in rendering, serializer logic, and regression tests.
- `plain_text_serializer.dart`: changing output shape affects copy/paste, select-all, and many selection assertions.
- `pretext_text_block.dart`: selection height/offset bugs often originate here even when the visible issue appears in quotes or lists.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -461,7 +461,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 10.15;
MACOSX_DEPLOYMENT_TARGET = 12.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
Expand Down Expand Up @@ -543,7 +543,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 10.15;
MACOSX_DEPLOYMENT_TARGET = 12.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx;
Expand Down Expand Up @@ -593,7 +593,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 10.15;
MACOSX_DEPLOYMENT_TARGET = 12.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@ import 'copy_serializer.dart';
import '../core/document.dart';
import '../selection/structured_block_selection.dart';

/// Serializes markdown documents and selections as plain text.
///
/// This is also the single source for selection bounds: the selection
/// controller clamps and selects all through [clampSelection] and
/// [createFullDocumentSelection], so a subclass changes both copy output and
/// selection bounds consistently. Block text comes from the cached
/// [StructuredBlockSelection] of each block.
class MarkdownPlainTextSerializer extends MarkdownCopySerializer {
const MarkdownPlainTextSerializer();

Expand All @@ -12,8 +19,8 @@ class MarkdownPlainTextSerializer extends MarkdownCopySerializer {
@override
String serialize(MarkdownDocument document) {
final sections = <String>[];
for (final indexedBlock in _indexBlocks(document)) {
final section = indexedBlock.text.trimRight();
for (var index = 0; index < document.blocks.length; index++) {
final section = _indexedBlockAt(document, index).text.trimRight();
if (section.isNotEmpty) {
sections.add(section);
}
Expand All @@ -22,12 +29,11 @@ class MarkdownPlainTextSerializer extends MarkdownCopySerializer {
}

DocumentSelection? createFullDocumentSelection(MarkdownDocument document) {
final indexedBlocks = _indexBlocks(document);
if (indexedBlocks.isEmpty) {
final bounds = _boundsOf(document);
if (bounds == null) {
return null;
}
final first = indexedBlocks.first;
final last = indexedBlocks.last;
final (first, last) = bounds;
return DocumentSelection(
base: DocumentPosition(
blockIndex: first.blockIndex,
Expand All @@ -41,7 +47,7 @@ class MarkdownPlainTextSerializer extends MarkdownCopySerializer {
path: last.structure == null
? const PathInBlock(<int>[0])
: last.structure!.endPosition(blockIndex: last.blockIndex).path,
textOffset: last.structure?.plainText.length ?? last.text.length,
textOffset: last.visibleTextLength,
),
);
}
Expand All @@ -50,13 +56,13 @@ class MarkdownPlainTextSerializer extends MarkdownCopySerializer {
MarkdownDocument document,
DocumentSelection selection,
) {
final indexedBlocks = _indexBlocks(document);
if (indexedBlocks.isEmpty) {
final bounds = _boundsOf(document);
if (bounds == null) {
return null;
}
return DocumentSelection(
base: _clampPosition(indexedBlocks, selection.base),
extent: _clampPosition(indexedBlocks, selection.extent),
base: _clampPosition(document, bounds, selection.base),
extent: _clampPosition(document, bounds, selection.extent),
);
}

Expand All @@ -68,85 +74,93 @@ class MarkdownPlainTextSerializer extends MarkdownCopySerializer {
}

String serializeRange(MarkdownDocument document, DocumentRange range) {
final indexedBlocks = _indexBlocks(document);
if (indexedBlocks.isEmpty) {
final bounds = _boundsOf(document);
if (bounds == null) {
return '';
}
final start = _clampPosition(indexedBlocks, range.start);
final end = _clampPosition(indexedBlocks, range.end);
final start = _clampPosition(document, bounds, range.start);
final end = _clampPosition(document, bounds, range.end);
final normalizedRange = start.compareTo(end) <= 0
? DocumentRange(start: start, end: end)
: DocumentRange(start: end, end: start);

final sections = <String>[];
for (final indexedBlock in indexedBlocks) {
if (indexedBlock.blockIndex < normalizedRange.start.blockIndex ||
indexedBlock.blockIndex > normalizedRange.end.blockIndex) {
for (var blockIndex = normalizedRange.start.blockIndex;
blockIndex <= normalizedRange.end.blockIndex;
blockIndex++) {
final indexedBlock = _indexedBlockAt(document, blockIndex);
if (indexedBlock.text.isEmpty) {
continue;
}
final blockStart =
indexedBlock.blockIndex == normalizedRange.start.blockIndex
? normalizedRange.start.textOffset
: 0;
final structure = indexedBlock.structure;
final blockEnd = indexedBlock.blockIndex == normalizedRange.end.blockIndex
final blockStart = blockIndex == normalizedRange.start.blockIndex
? normalizedRange.start.textOffset
: 0;
final blockEnd = blockIndex == normalizedRange.end.blockIndex
? normalizedRange.end.textOffset
: indexedBlock.visibleTextLength;
final structure = indexedBlock.structure;
final section = structure != null
? structure.serializeDisplayedRange(
start: blockStart,
end: blockEnd,
)
: _sliceBlock(
indexedBlock,
start: blockStart,
end: blockEnd,
);
? structure.serializeDisplayedRange(start: blockStart, end: blockEnd)
: _sliceBlock(indexedBlock, start: blockStart, end: blockEnd);
if (section.isNotEmpty) {
sections.add(section);
}
}
return sections.join('\n\n');
}

List<_IndexedPlainTextBlock> _indexBlocks(MarkdownDocument document) {
return <_IndexedPlainTextBlock>[
for (var index = 0; index < document.blocks.length; index++)
_indexedBlockFor(document.blocks[index], index),
].where((block) => block.text.isNotEmpty).toList(growable: false);
}

_IndexedPlainTextBlock _indexedBlockFor(BlockNode block, int blockIndex) {
_IndexedPlainTextBlock _indexedBlockAt(
MarkdownDocument document,
int blockIndex,
) {
final block = document.blocks[blockIndex];
final structure = StructuredBlockSelection.forBlock(block);
if (!structure.isEmpty) {
return _IndexedPlainTextBlock(
blockIndex: blockIndex,
text: structure.serializedText,
structure: structure,
);
}
return _IndexedPlainTextBlock(
blockIndex: blockIndex,
text: StructuredBlockSelection.serializeBlockText(block),
structure: null,
structure: structure.isEmpty ? null : structure,
);
}

/// The first and last blocks that have text, or null when none has.
(_IndexedPlainTextBlock, _IndexedPlainTextBlock)? _boundsOf(
MarkdownDocument document,
) {
_IndexedPlainTextBlock? first;
for (var index = 0; index < document.blocks.length; index++) {
final block = _indexedBlockAt(document, index);
if (block.text.isNotEmpty) {
first = block;
break;
}
}
if (first == null) {
return null;
}
for (var index = document.blocks.length - 1;
index > first.blockIndex;
index--) {
final block = _indexedBlockAt(document, index);
if (block.text.isNotEmpty) {
return (first, block);
}
}
return (first, first);
}

DocumentPosition _clampPosition(
List<_IndexedPlainTextBlock> indexedBlocks,
MarkdownDocument document,
(_IndexedPlainTextBlock, _IndexedPlainTextBlock) bounds,
DocumentPosition position,
) {
final minBlockIndex = indexedBlocks.first.blockIndex;
final maxBlockIndex = indexedBlocks.last.blockIndex;
final targetBlockIndex = position.blockIndex < minBlockIndex
? minBlockIndex
: position.blockIndex > maxBlockIndex
? maxBlockIndex
final (first, last) = bounds;
final targetBlockIndex = position.blockIndex < first.blockIndex
? first.blockIndex
: position.blockIndex > last.blockIndex
? last.blockIndex
: position.blockIndex;
final block = indexedBlocks.firstWhere(
(item) => item.blockIndex == targetBlockIndex,
orElse: () => indexedBlocks.last,
);
final candidate = _indexedBlockAt(document, targetBlockIndex);
final block = candidate.text.isEmpty ? last : candidate;
final targetOffset = position.textOffset < 0
? 0
: position.textOffset > block.visibleTextLength
Expand Down
Loading
Loading