Skip to content
Open
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- Fixed vertical caret navigation (arrow up/down) landing on the wrong horizontal column:
- `TextAffinity` is now preserved while resolving the position above/below, so at soft line-wrap boundaries the caret no longer starts from (or lands on) the wrong visual line.
- `QuillVerticalCaretMovementRun` now remembers the goal column for the whole run, so crossing a short or empty line no longer permanently clamps the caret to that line's column.
- On the web, arrow up/down (with and without Shift) are now handled by the editor instead of being delegated to the browser, which moved the caret based on the hidden DOM input's unrelated text layout.
- On the web, the cached vertical movement run is now invalidated when the selection changes, so after repositioning the caret (e.g. with a mouse click) the next arrow key starts from the new position.

### Removed

- Removed the already-`@Deprecated` and `@internal` `linkPrefixes` constant from the public API surface (it is hidden from the `flutter_quill.dart` export). Use `LinkValidator.linkPrefixes` instead.
Expand Down
50 changes: 48 additions & 2 deletions lib/src/editor/editor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1546,8 +1546,11 @@ class RenderEditor extends RenderEditableContainerBox
@override
TextPosition getTextPositionAbove(TextPosition position) {
final child = childAtPosition(position);
// Keep the affinity: at a soft line wrap boundary it decides which visual
// line the caret is on, so dropping it would move from the wrong line.
final localPosition = TextPosition(
offset: position.offset - child.container.documentOffset,
affinity: position.affinity,
);

var newPosition = child.getPositionAbove(localPosition);
Expand All @@ -1568,11 +1571,13 @@ class RenderEditor extends RenderEditableContainerBox
final siblingPosition = sibling.getPositionForOffset(finalOffset);
newPosition = TextPosition(
offset: sibling.container.documentOffset + siblingPosition.offset,
affinity: siblingPosition.affinity,
);
}
} else {
newPosition = TextPosition(
offset: child.container.documentOffset + newPosition.offset,
affinity: newPosition.affinity,
);
}
return newPosition;
Expand All @@ -1585,8 +1590,11 @@ class RenderEditor extends RenderEditableContainerBox
@override
TextPosition getTextPositionBelow(TextPosition position) {
final child = childAtPosition(position);
// Keep the affinity: at a soft line wrap boundary it decides which visual
// line the caret is on, so dropping it would move from the wrong line.
final localPosition = TextPosition(
offset: position.offset - child.container.documentOffset,
affinity: position.affinity,
);

var newPosition = child.getPositionBelow(localPosition);
Expand All @@ -1606,11 +1614,13 @@ class RenderEditor extends RenderEditableContainerBox
final siblingPosition = sibling.getPositionForOffset(finalOffset);
newPosition = TextPosition(
offset: sibling.container.documentOffset + siblingPosition.offset,
affinity: siblingPosition.affinity,
);
}
} else {
newPosition = TextPosition(
offset: child.container.documentOffset + newPosition.offset,
affinity: newPosition.affinity,
);
}
return newPosition;
Expand Down Expand Up @@ -1638,19 +1648,55 @@ class QuillVerticalCaretMovementRun implements Iterator<TextPosition> {

final RenderEditor _editor;

/// The horizontal caret position (in the editor's coordinate space) this
/// run tries to stay on while moving vertically.
///
/// Captured from the starting position on the first move and kept for the
/// whole run — mirroring `VerticalCaretMovementRun` of Flutter's TextField —
/// so that crossing a short or empty line does not permanently clamp the
/// caret to that line's column.
double? _goalDx;

@override
TextPosition get current {
return _currentTextPosition;
}

/// Snaps [position] back to the goal column within its visual line.
///
/// [RenderEditor.getTextPositionAbove]/[RenderEditor.getTextPositionBelow]
/// compute the target column from the *current* caret position, which loses
/// the original column after crossing a shorter line. The vertical placement
/// of [position] is kept; only the horizontal component is re-resolved
/// against [_goalDx].
TextPosition _applyGoalColumn(TextPosition position) {
final caretOffset = _editor._getOffsetForCaret(position);
if ((caretOffset.dx - _goalDx!).abs() < 0.5) {
return position;
}
// Probe at mid line-height so the lookup stays within the visual line of
// `position` and only the column changes.
final lineHeight = _editor.preferredLineHeight(position);
final probe = _editor.localToGlobal(
Offset(_goalDx!, caretOffset.dy + lineHeight / 2),
);
return _editor.getPositionForOffset(probe);
}

@override
bool moveNext() {
_currentTextPosition = _editor.getTextPositionBelow(_currentTextPosition);
_goalDx ??= _editor._getOffsetForCaret(_currentTextPosition).dx;
_currentTextPosition = _applyGoalColumn(
_editor.getTextPositionBelow(_currentTextPosition),
);
return true;
}

bool movePrevious() {
_currentTextPosition = _editor.getTextPositionAbove(_currentTextPosition);
_goalDx ??= _editor._getOffsetForCaret(_currentTextPosition).dx;
_currentTextPosition = _applyGoalColumn(
_editor.getTextPositionAbove(_currentTextPosition),
);
return true;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:meta/meta.dart';
Expand Down Expand Up @@ -152,6 +153,42 @@ Map<SingleActivator, Intent> defaultSinlgeActivatorIntents() {
meta: _isDesktopMacOS,
): const OpenSearchIntent(),

// Vertical caret navigation on the web.
//
// On the web, the framework's DefaultTextEditingShortcuts maps the arrow
// keys to DoNothingAndStopPropagationTextIntent so that the *browser*
// performs the caret movement on the hidden DOM input. That is correct
// for DOM-rendered text fields, but the Quill editor is rendered by
// Flutter: the hidden element has a completely different text layout
// (width, font, wrapping), so the browser-computed caret lands on an
// arbitrary visual column. Binding the vertical movements here (this
// Shortcuts widget sits closer to the focused node, so it takes
// precedence over DefaultTextEditingShortcuts) makes them run through
// the editor's own geometry instead. Horizontal movements are left to
// the browser: they are plain character offsets and map 1:1.
if (kIsWeb) ...{
const SingleActivator(LogicalKeyboardKey.arrowUp):
const ExtendSelectionVerticallyToAdjacentLineIntent(
forward: false,
collapseSelection: true,
),
const SingleActivator(LogicalKeyboardKey.arrowDown):
const ExtendSelectionVerticallyToAdjacentLineIntent(
forward: true,
collapseSelection: true,
),
const SingleActivator(LogicalKeyboardKey.arrowUp, shift: true):
const ExtendSelectionVerticallyToAdjacentLineIntent(
forward: false,
collapseSelection: false,
),
const SingleActivator(LogicalKeyboardKey.arrowDown, shift: true):
const ExtendSelectionVerticallyToAdjacentLineIntent(
forward: true,
collapseSelection: false,
),
},

// Arrow key scrolling
SingleActivator(
LogicalKeyboardKey.arrowUp,
Expand Down
10 changes: 7 additions & 3 deletions lib/src/editor/raw_editor/raw_editor_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1003,6 +1003,13 @@ class QuillRawEditorState extends EditorState
}

void _didChangeTextEditingValue([bool ignoreFocus = false]) {
// Must run on every selection change, including on the web: a stale
// cached run would make the next arrow key continue the vertical
// movement from the previous caret position instead of the new one
// (e.g. after repositioning the caret with a mouse click).
_shortcutActionsManager.adjacentLineAction
.stopCurrentVerticalRunIfSelectionChanges();

if (kIsWeb) {
_onChangeTextEditingValue(ignoreFocus);
if (!ignoreFocus) {
Expand All @@ -1021,9 +1028,6 @@ class QuillRawEditorState extends EditorState
_markNeedsBuild();
}
}

_shortcutActionsManager.adjacentLineAction
.stopCurrentVerticalRunIfSelectionChanges();
}

void _onChangeTextEditingValue([bool ignoreCaret = false]) {
Expand Down
28 changes: 20 additions & 8 deletions lib/src/editor/widgets/text/text_block.dart
Original file line number Diff line number Diff line change
Expand Up @@ -528,12 +528,18 @@ class RenderEditableTextBlock extends RenderEditableContainerBox
assert(position.offset < container.length);

final child = childAtPosition(position);
// Keep the affinity: at a soft line wrap boundary it decides which visual
// line the caret is on, so dropping it would move from the wrong line.
final childLocalPosition = TextPosition(
offset: position.offset - child.container.offset,
affinity: position.affinity,
);
final result = child.getPositionAbove(childLocalPosition);
if (result != null) {
return TextPosition(offset: result.offset + child.container.offset);
return TextPosition(
offset: result.offset + child.container.offset,
affinity: result.affinity,
);
}

final sibling = childBefore(child);
Expand All @@ -545,10 +551,10 @@ class RenderEditableTextBlock extends RenderEditableContainerBox
final testPosition = TextPosition(offset: sibling.container.length - 1);
final testOffset = sibling.getOffsetForCaret(testPosition);
final finalOffset = Offset(caretOffset.dx, testOffset.dy);
final siblingPosition = sibling.getPositionForOffset(finalOffset);
return TextPosition(
offset:
sibling.container.offset +
sibling.getPositionForOffset(finalOffset).offset,
offset: sibling.container.offset + siblingPosition.offset,
affinity: siblingPosition.affinity,
);
}

Expand All @@ -557,12 +563,18 @@ class RenderEditableTextBlock extends RenderEditableContainerBox
assert(position.offset < container.length);

final child = childAtPosition(position);
// Keep the affinity: at a soft line wrap boundary it decides which visual
// line the caret is on, so dropping it would move from the wrong line.
final childLocalPosition = TextPosition(
offset: position.offset - child.container.offset,
affinity: position.affinity,
);
final result = child.getPositionBelow(childLocalPosition);
if (result != null) {
return TextPosition(offset: result.offset + child.container.offset);
return TextPosition(
offset: result.offset + child.container.offset,
affinity: result.affinity,
);
}

final sibling = childAfter(child);
Expand All @@ -573,10 +585,10 @@ class RenderEditableTextBlock extends RenderEditableContainerBox
final caretOffset = child.getOffsetForCaret(childLocalPosition);
final testOffset = sibling.getOffsetForCaret(const TextPosition(offset: 0));
final finalOffset = Offset(caretOffset.dx, testOffset.dy);
final siblingPosition = sibling.getPositionForOffset(finalOffset);
return TextPosition(
offset:
sibling.container.offset +
sibling.getPositionForOffset(finalOffset).offset,
offset: sibling.container.offset + siblingPosition.offset,
affinity: siblingPosition.affinity,
);
}

Expand Down
Loading