Skip to content

Commit c33f5c2

Browse files
content: Support negative right-margin on KaTeX spans
Negative margin spans on web render to the offset being applied to the specific span and all the adjacent spans, so mimic the same behaviour here.
1 parent e509efe commit c33f5c2

File tree

6 files changed

+426
-18
lines changed

6 files changed

+426
-18
lines changed

lib/model/content.dart

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,28 @@ class KatexVlistRowNode extends ContentNode {
455455
}
456456
}
457457

458+
class KatexNegativeMarginNode extends KatexNode {
459+
const KatexNegativeMarginNode({
460+
required this.marginRightEm,
461+
required this.nodes,
462+
super.debugHtmlNode,
463+
}) : assert(marginRightEm < 0);
464+
465+
final double marginRightEm;
466+
final List<KatexNode> nodes;
467+
468+
@override
469+
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
470+
super.debugFillProperties(properties);
471+
properties.add(DoubleProperty('marginRightEm', marginRightEm));
472+
}
473+
474+
@override
475+
List<DiagnosticsNode> debugDescribeChildren() {
476+
return nodes.map((node) => node.toDiagnosticsNode()).toList();
477+
}
478+
}
479+
458480
class MathBlockNode extends MathNode implements BlockContentNode {
459481
const MathBlockNode({
460482
super.debugHtmlNode,

lib/model/katex.dart

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import 'package:collection/collection.dart';
12
import 'package:csslib/parser.dart' as css_parser;
23
import 'package:csslib/visitor.dart' as css_visitor;
34
import 'package:flutter/foundation.dart';
@@ -118,13 +119,28 @@ class _KatexParser {
118119
}
119120

120121
List<KatexNode> _parseChildSpans(List<dom.Node> nodes) {
121-
return List.unmodifiable(nodes.map((node) {
122-
if (node case dom.Element(localName: 'span')) {
123-
return _parseSpan(node);
124-
} else {
122+
var resultSpans = QueueList<KatexNode>();
123+
for (final node in nodes.reversed) {
124+
if (node is! dom.Element || node.localName != 'span') {
125125
throw KatexHtmlParseError();
126126
}
127-
}));
127+
128+
final span = _parseSpan(node);
129+
130+
if (span is KatexSpanNode) {
131+
final marginRightEm = span.styles.marginRightEm;
132+
if (marginRightEm != null && marginRightEm.isNegative) {
133+
final previousSpans = resultSpans;
134+
resultSpans = QueueList<KatexNode>();
135+
resultSpans.addFirst(KatexNegativeMarginNode(
136+
marginRightEm: marginRightEm,
137+
nodes: previousSpans));
138+
}
139+
}
140+
141+
resultSpans.addFirst(span);
142+
}
143+
return resultSpans;
128144
}
129145

130146
static final _resetSizeClassRegExp = RegExp(r'^reset-size(\d\d?)$');
@@ -532,17 +548,11 @@ class _KatexParser {
532548

533549
case 'margin-right':
534550
marginRightEm = _getEm(expression);
535-
if (marginRightEm != null) {
536-
if (marginRightEm < 0) throw KatexHtmlParseError();
537-
continue;
538-
}
551+
if (marginRightEm != null) continue;
539552

540553
case 'margin-left':
541554
marginLeftEm = _getEm(expression);
542-
if (marginLeftEm != null) {
543-
if (marginLeftEm < 0) throw KatexHtmlParseError();
544-
continue;
545-
}
555+
if (marginLeftEm != null) continue;
546556
}
547557

548558
// TODO handle more CSS properties
@@ -596,8 +606,8 @@ enum KatexSpanTextAlign {
596606
@immutable
597607
class KatexSpanStyles {
598608
final double? heightEm;
599-
final double? verticalAlignEm;
600609

610+
final double? verticalAlignEm;
601611
final double? topEm;
602612

603613
final double? marginRightEm;

lib/widgets/content.dart

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import 'code_block.dart';
2020
import 'dialog.dart';
2121
import 'icons.dart';
2222
import 'inset_shadow.dart';
23+
import 'katex.dart';
2324
import 'lightbox.dart';
2425
import 'message_list.dart';
2526
import 'poll.dart';
@@ -881,6 +882,7 @@ class _KatexNodeList extends StatelessWidget {
881882
KatexSpanNode() => _KatexSpan(e),
882883
KatexStrutNode() => _KatexStrut(e),
883884
KatexVlistNode() => _KatexVlist(e),
885+
KatexNegativeMarginNode() => _KatexNegativeMargin(e),
884886
}));
885887
}))));
886888
}
@@ -965,12 +967,10 @@ class _KatexSpan extends StatelessWidget {
965967
EdgeInsets? margin;
966968
if (marginRight != null || marginLeft != null) {
967969
margin = EdgeInsets.zero;
968-
if (marginRight != null) {
969-
assert(marginRight >= 0);
970+
if (marginRight != null && marginRight >= 0) {
970971
margin += EdgeInsets.only(right: marginRight);
971972
}
972-
if (marginLeft != null) {
973-
assert(marginLeft >= 0);
973+
if (marginLeft != null && marginLeft >= 0) {
974974
margin += EdgeInsets.only(left: marginLeft);
975975
}
976976
}
@@ -1029,6 +1029,21 @@ class _KatexVlist extends StatelessWidget {
10291029
}
10301030
}
10311031

1032+
class _KatexNegativeMargin extends StatelessWidget {
1033+
const _KatexNegativeMargin(this.node);
1034+
1035+
final KatexNegativeMarginNode node;
1036+
1037+
@override
1038+
Widget build(BuildContext context) {
1039+
final em = DefaultTextStyle.of(context).style.fontSize!;
1040+
1041+
return NegativeLeftOffset(
1042+
leftOffset: node.marginRightEm * em,
1043+
child: _KatexNodeList(nodes: node.nodes));
1044+
}
1045+
}
1046+
10321047
class WebsitePreview extends StatelessWidget {
10331048
const WebsitePreview({super.key, required this.node});
10341049

lib/widgets/katex.dart

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import 'dart:math' as math;
2+
3+
import 'package:flutter/foundation.dart';
4+
import 'package:flutter/widgets.dart';
5+
import 'package:flutter/rendering.dart';
6+
7+
class NegativeLeftOffset extends SingleChildRenderObjectWidget {
8+
NegativeLeftOffset({super.key, required this.leftOffset, super.child})
9+
: assert(leftOffset.isNegative),
10+
_padding = EdgeInsets.only(left: leftOffset);
11+
12+
final double leftOffset;
13+
final EdgeInsetsGeometry _padding;
14+
15+
@override
16+
RenderNegativePadding createRenderObject(BuildContext context) {
17+
return RenderNegativePadding(
18+
padding: _padding,
19+
textDirection: Directionality.maybeOf(context));
20+
}
21+
22+
@override
23+
void updateRenderObject(
24+
BuildContext context,
25+
RenderNegativePadding renderObject,
26+
) {
27+
renderObject
28+
..padding = _padding
29+
..textDirection = Directionality.maybeOf(context);
30+
}
31+
32+
@override
33+
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
34+
super.debugFillProperties(properties);
35+
properties.add(DiagnosticsProperty<EdgeInsetsGeometry>('padding', _padding));
36+
}
37+
}
38+
39+
class RenderNegativePadding extends RenderShiftedBox {
40+
RenderNegativePadding({
41+
required EdgeInsetsGeometry padding,
42+
TextDirection? textDirection,
43+
RenderBox? child,
44+
}) : assert(padding.isNegative),
45+
_textDirection = textDirection,
46+
_padding = padding,
47+
super(child);
48+
49+
EdgeInsets? _resolvedPaddingCache;
50+
EdgeInsets get _resolvedPadding {
51+
final EdgeInsets returnValue = _resolvedPaddingCache ??= padding.resolve(textDirection);
52+
return returnValue;
53+
}
54+
55+
void _markNeedResolution() {
56+
_resolvedPaddingCache = null;
57+
markNeedsLayout();
58+
}
59+
60+
/// The amount to pad the child in each dimension.
61+
///
62+
/// If this is set to an [EdgeInsetsDirectional] object, then [textDirection]
63+
/// must not be null.
64+
EdgeInsetsGeometry get padding => _padding;
65+
EdgeInsetsGeometry _padding;
66+
set padding(EdgeInsetsGeometry value) {
67+
assert(value.isNegative);
68+
if (_padding == value) {
69+
return;
70+
}
71+
_padding = value;
72+
_markNeedResolution();
73+
}
74+
75+
/// The text direction with which to resolve [padding].
76+
///
77+
/// This may be changed to null, but only after the [padding] has been changed
78+
/// to a value that does not depend on the direction.
79+
TextDirection? get textDirection => _textDirection;
80+
TextDirection? _textDirection;
81+
set textDirection(TextDirection? value) {
82+
if (_textDirection == value) {
83+
return;
84+
}
85+
_textDirection = value;
86+
_markNeedResolution();
87+
}
88+
89+
@override
90+
double computeMinIntrinsicWidth(double height) {
91+
final EdgeInsets padding = _resolvedPadding;
92+
if (child != null) {
93+
// Relies on double.infinity absorption.
94+
return child!.getMinIntrinsicWidth(math.max(0.0, height - padding.vertical)) +
95+
padding.horizontal;
96+
}
97+
return padding.horizontal;
98+
}
99+
100+
@override
101+
double computeMaxIntrinsicWidth(double height) {
102+
final EdgeInsets padding = _resolvedPadding;
103+
if (child != null) {
104+
// Relies on double.infinity absorption.
105+
return child!.getMaxIntrinsicWidth(math.max(0.0, height - padding.vertical)) +
106+
padding.horizontal;
107+
}
108+
return padding.horizontal;
109+
}
110+
111+
@override
112+
double computeMinIntrinsicHeight(double width) {
113+
final EdgeInsets padding = _resolvedPadding;
114+
if (child != null) {
115+
// Relies on double.infinity absorption.
116+
return child!.getMinIntrinsicHeight(math.max(0.0, width - padding.horizontal)) +
117+
padding.vertical;
118+
}
119+
return padding.vertical;
120+
}
121+
122+
@override
123+
double computeMaxIntrinsicHeight(double width) {
124+
final EdgeInsets padding = _resolvedPadding;
125+
if (child != null) {
126+
// Relies on double.infinity absorption.
127+
return child!.getMaxIntrinsicHeight(math.max(0.0, width - padding.horizontal)) +
128+
padding.vertical;
129+
}
130+
return padding.vertical;
131+
}
132+
133+
@override
134+
@protected
135+
Size computeDryLayout(covariant BoxConstraints constraints) {
136+
final EdgeInsets padding = _resolvedPadding;
137+
if (child == null) {
138+
return constraints.constrain(Size(padding.horizontal, padding.vertical));
139+
}
140+
final BoxConstraints innerConstraints = constraints.deflate(padding);
141+
final Size childSize = child!.getDryLayout(innerConstraints);
142+
return constraints.constrain(
143+
Size(padding.horizontal + childSize.width, padding.vertical + childSize.height),
144+
);
145+
}
146+
147+
@override
148+
double? computeDryBaseline(covariant BoxConstraints constraints, TextBaseline baseline) {
149+
final RenderBox? child = this.child;
150+
if (child == null) {
151+
return null;
152+
}
153+
final EdgeInsets padding = _resolvedPadding;
154+
final BoxConstraints innerConstraints = constraints.deflate(padding);
155+
final BaselineOffset result =
156+
BaselineOffset(child.getDryBaseline(innerConstraints, baseline)) + padding.top;
157+
return result.offset;
158+
}
159+
160+
@override
161+
void performLayout() {
162+
final BoxConstraints constraints = this.constraints;
163+
final EdgeInsets padding = _resolvedPadding;
164+
if (child == null) {
165+
size = constraints.constrain(Size(padding.horizontal, padding.vertical));
166+
return;
167+
}
168+
final BoxConstraints innerConstraints = constraints.deflate(padding);
169+
child!.layout(innerConstraints, parentUsesSize: true);
170+
final BoxParentData childParentData = child!.parentData! as BoxParentData;
171+
childParentData.offset = Offset(padding.left, padding.top);
172+
size = constraints.constrain(
173+
Size(padding.horizontal + child!.size.width, padding.vertical + child!.size.height),
174+
);
175+
}
176+
177+
@override
178+
void debugPaintSize(PaintingContext context, Offset offset) {
179+
super.debugPaintSize(context, offset);
180+
assert(() {
181+
final Rect outerRect = offset & size;
182+
debugPaintPadding(
183+
context.canvas,
184+
outerRect,
185+
child != null ? _resolvedPaddingCache!.deflateRect(outerRect) : null,
186+
);
187+
return true;
188+
}());
189+
}
190+
191+
@override
192+
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
193+
super.debugFillProperties(properties);
194+
properties.add(DiagnosticsProperty<EdgeInsetsGeometry>('padding', padding));
195+
properties.add(EnumProperty<TextDirection>('textDirection', textDirection, defaultValue: null));
196+
}
197+
}
198+
199+
extension on EdgeInsetsGeometry {
200+
bool get isNegative => !isNonNegative;
201+
}

0 commit comments

Comments
 (0)