|
@@ -0,0 +1,285 @@
|
|
|
|
|
+// 完整 DropCapText 组件,支持 DropCapPosition.bottomRight,支持 \n 换行符自动避让右下角图像
|
|
|
|
|
+
|
|
|
|
|
+import 'dart:math';
|
|
|
|
|
+import 'package:flutter/material.dart';
|
|
|
|
|
+
|
|
|
|
|
+enum DropCapMode {
|
|
|
|
|
+ inside,
|
|
|
|
|
+ upwards,
|
|
|
|
|
+ aside,
|
|
|
|
|
+ baseline,
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+enum DropCapPosition {
|
|
|
|
|
+ start,
|
|
|
|
|
+ end,
|
|
|
|
|
+ bottomRight,
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+class DropCap extends StatelessWidget {
|
|
|
|
|
+ final Widget child;
|
|
|
|
|
+ final double width, height;
|
|
|
|
|
+
|
|
|
|
|
+ const DropCap(
|
|
|
|
|
+ {super.key,
|
|
|
|
|
+ required this.child,
|
|
|
|
|
+ required this.width,
|
|
|
|
|
+ required this.height});
|
|
|
|
|
+
|
|
|
|
|
+ @override
|
|
|
|
|
+ Widget build(BuildContext context) =>
|
|
|
|
|
+ SizedBox(width: width, height: height, child: child);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+class DropCapText extends StatelessWidget {
|
|
|
|
|
+ final String data;
|
|
|
|
|
+ final DropCapMode mode;
|
|
|
|
|
+ final TextStyle? style, dropCapStyle;
|
|
|
|
|
+ final TextAlign textAlign;
|
|
|
|
|
+ final DropCap? dropCap;
|
|
|
|
|
+ final EdgeInsets dropCapPadding;
|
|
|
|
|
+ final Offset indentation;
|
|
|
|
|
+ final bool forceNoDescent, parseInlineMarkdown;
|
|
|
|
|
+ final TextDirection textDirection;
|
|
|
|
|
+ final DropCapPosition? dropCapPosition;
|
|
|
|
|
+ final int dropCapChars;
|
|
|
|
|
+ final int? maxLines;
|
|
|
|
|
+ final TextOverflow overflow;
|
|
|
|
|
+
|
|
|
|
|
+ const DropCapText(
|
|
|
|
|
+ this.data, {
|
|
|
|
|
+ super.key,
|
|
|
|
|
+ this.mode = DropCapMode.inside,
|
|
|
|
|
+ this.style,
|
|
|
|
|
+ this.dropCapStyle,
|
|
|
|
|
+ this.textAlign = TextAlign.start,
|
|
|
|
|
+ this.dropCap,
|
|
|
|
|
+ this.dropCapPadding = EdgeInsets.zero,
|
|
|
|
|
+ this.indentation = Offset.zero,
|
|
|
|
|
+ this.dropCapChars = 1,
|
|
|
|
|
+ this.forceNoDescent = false,
|
|
|
|
|
+ this.parseInlineMarkdown = false,
|
|
|
|
|
+ this.textDirection = TextDirection.ltr,
|
|
|
|
|
+ this.overflow = TextOverflow.clip,
|
|
|
|
|
+ this.maxLines,
|
|
|
|
|
+ this.dropCapPosition,
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ @override
|
|
|
|
|
+ Widget build(BuildContext context) {
|
|
|
|
|
+ final textStyle = const TextStyle(fontSize: 14, height: 1.3).merge(style);
|
|
|
|
|
+ final capStyle = TextStyle(
|
|
|
|
|
+ color: textStyle.color,
|
|
|
|
|
+ fontSize: textStyle.fontSize! * 5.5,
|
|
|
|
|
+ fontFamily: textStyle.fontFamily,
|
|
|
|
|
+ fontWeight: textStyle.fontWeight,
|
|
|
|
|
+ fontStyle: textStyle.fontStyle,
|
|
|
|
|
+ height: 1,
|
|
|
|
|
+ ).merge(dropCapStyle);
|
|
|
|
|
+
|
|
|
|
|
+ final mdData = parseInlineMarkdown ? MarkdownParser(data) : null;
|
|
|
|
|
+ final dropCapStr = (mdData?.plainText ?? data)
|
|
|
|
|
+ .substring(0, dropCap != null ? 0 : dropCapChars);
|
|
|
|
|
+ final mdRest = parseInlineMarkdown ? mdData!.subchars(dropCapChars) : null;
|
|
|
|
|
+ final restData = data.substring(dropCap != null ? 0 : dropCapChars);
|
|
|
|
|
+
|
|
|
|
|
+ double capWidth, capHeight;
|
|
|
|
|
+ if (dropCap != null) {
|
|
|
|
|
+ capWidth = dropCap!.width;
|
|
|
|
|
+ capHeight = dropCap!.height;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ final capPainter = TextPainter(
|
|
|
|
|
+ text: TextSpan(text: dropCapStr, style: capStyle),
|
|
|
|
|
+ textDirection: textDirection,
|
|
|
|
|
+ )..layout();
|
|
|
|
|
+ capWidth = capPainter.width;
|
|
|
|
|
+ capHeight = capPainter.height;
|
|
|
|
|
+ if (forceNoDescent) {
|
|
|
|
|
+ final metrics = capPainter.computeLineMetrics();
|
|
|
|
|
+ if (metrics.isNotEmpty) capHeight -= metrics[0].descent * 0.95;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ capWidth += dropCapPadding.horizontal;
|
|
|
|
|
+ capHeight += dropCapPadding.vertical;
|
|
|
|
|
+
|
|
|
|
|
+ return LayoutBuilder(builder: (context, constraints) {
|
|
|
|
|
+ final restParagraphs = restData.split('\n');
|
|
|
|
|
+ final children = <Widget>[];
|
|
|
|
|
+
|
|
|
|
|
+ for (int p = 0; p < restParagraphs.length; p++) {
|
|
|
|
|
+ final para = restParagraphs[p];
|
|
|
|
|
+ final span = TextSpan(text: para, style: textStyle);
|
|
|
|
|
+ final tp = TextPainter(
|
|
|
|
|
+ text: span,
|
|
|
|
|
+ textDirection: textDirection,
|
|
|
|
|
+ textAlign: textAlign,
|
|
|
|
|
+ )..layout(maxWidth: constraints.maxWidth);
|
|
|
|
|
+
|
|
|
|
|
+ final lines = tp.computeLineMetrics();
|
|
|
|
|
+ final lineHeight = tp.preferredLineHeight;
|
|
|
|
|
+
|
|
|
|
|
+ if (dropCapPosition == DropCapPosition.bottomRight &&
|
|
|
|
|
+ p == restParagraphs.length - 1) {
|
|
|
|
|
+ final dropCapLines = (capHeight / lineHeight).ceil();
|
|
|
|
|
+ final splitLine = max(0, lines.length - dropCapLines);
|
|
|
|
|
+
|
|
|
|
|
+ int charSplit = 0;
|
|
|
|
|
+ double yPos = 0;
|
|
|
|
|
+ for (int i = 0; i < lines.length; i++) {
|
|
|
|
|
+ if (i == splitLine) break;
|
|
|
|
|
+ yPos += lines[i].height;
|
|
|
|
|
+ final pos = tp.getPositionForOffset(
|
|
|
|
|
+ Offset(tp.width - 1, yPos - lines[i].descent));
|
|
|
|
|
+ charSplit = pos.offset;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ final beforeText = para.substring(0, min(charSplit, para.length));
|
|
|
|
|
+ final afterText = para.substring(min(charSplit, para.length));
|
|
|
|
|
+
|
|
|
|
|
+ if (beforeText.isNotEmpty) {
|
|
|
|
|
+ children.add(RichText(
|
|
|
|
|
+ text: TextSpan(text: beforeText, style: textStyle),
|
|
|
|
|
+ textDirection: textDirection,
|
|
|
|
|
+ textAlign: textAlign,
|
|
|
|
|
+ ));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ children.add(Row(
|
|
|
|
|
+ crossAxisAlignment: lines.length > 1
|
|
|
|
|
+ ? CrossAxisAlignment.end
|
|
|
|
|
+ : CrossAxisAlignment.start,
|
|
|
|
|
+ children: [
|
|
|
|
|
+ Expanded(
|
|
|
|
|
+ child: RichText(
|
|
|
|
|
+ text: TextSpan(text: afterText, style: textStyle),
|
|
|
|
|
+ textDirection: textDirection,
|
|
|
|
|
+ textAlign: textAlign,
|
|
|
|
|
+ ),
|
|
|
|
|
+ ),
|
|
|
|
|
+ Padding(
|
|
|
|
|
+ padding: dropCapPadding,
|
|
|
|
|
+ child: dropCap ??
|
|
|
|
|
+ RichText(
|
|
|
|
|
+ textDirection: textDirection,
|
|
|
|
|
+ textAlign: textAlign,
|
|
|
|
|
+ text: TextSpan(text: dropCapStr, style: capStyle),
|
|
|
|
|
+ ),
|
|
|
|
|
+ ),
|
|
|
|
|
+ ],
|
|
|
|
|
+ ));
|
|
|
|
|
+ } else {
|
|
|
|
|
+ children.add(RichText(
|
|
|
|
|
+ text: span,
|
|
|
|
|
+ textDirection: textDirection,
|
|
|
|
|
+ textAlign: textAlign,
|
|
|
|
|
+ ));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (p < restParagraphs.length - 1) {
|
|
|
|
|
+ children.add(const SizedBox(height: 4));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return Column(
|
|
|
|
|
+ crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
+ mainAxisSize: MainAxisSize.min,
|
|
|
|
|
+ children: children,
|
|
|
|
|
+ );
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 以下 MarkdownParser 与 MarkdownSpan 与 Markup 类保持原样即可
|
|
|
|
|
+class MarkdownParser {
|
|
|
|
|
+ final String data;
|
|
|
|
|
+ late List<MarkdownSpan> spans;
|
|
|
|
|
+ String plainText = '';
|
|
|
|
|
+
|
|
|
|
|
+ List<TextSpan> toTextSpanList() => spans.map((s) => s.toTextSpan()).toList();
|
|
|
|
|
+
|
|
|
|
|
+ MarkdownParser subchars(int startIndex, [int? endIndex]) {
|
|
|
|
|
+ final subspans = <MarkdownSpan>[];
|
|
|
|
|
+ int skip = startIndex;
|
|
|
|
|
+ for (var span in spans) {
|
|
|
|
|
+ if (skip <= 0) {
|
|
|
|
|
+ subspans.add(span);
|
|
|
|
|
+ } else if (span.text.length < skip) {
|
|
|
|
|
+ skip -= span.text.length;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ subspans.add(MarkdownSpan(
|
|
|
|
|
+ style: span.style,
|
|
|
|
|
+ markups: span.markups,
|
|
|
|
|
+ text: span.text.substring(skip),
|
|
|
|
|
+ ));
|
|
|
|
|
+ skip = 0;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return MarkdownParser(subspans.map((e) => e.text).join());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ MarkdownParser(this.data) {
|
|
|
|
|
+ plainText = '';
|
|
|
|
|
+ spans = [MarkdownSpan(text: '', markups: [], style: const TextStyle())];
|
|
|
|
|
+
|
|
|
|
|
+ bool bold = false, italic = false, underline = false;
|
|
|
|
|
+ const b = '**', i = '_', u = '++';
|
|
|
|
|
+
|
|
|
|
|
+ void addSpan(String markup, bool isOpening) {
|
|
|
|
|
+ final markups = [Markup(markup, isOpening)];
|
|
|
|
|
+ if (bold && markup != b) markups.add(Markup(b, true));
|
|
|
|
|
+ if (italic && markup != i) markups.add(Markup(i, true));
|
|
|
|
|
+ if (underline && markup != u) markups.add(Markup(u, true));
|
|
|
|
|
+
|
|
|
|
|
+ spans.add(MarkdownSpan(
|
|
|
|
|
+ text: '',
|
|
|
|
|
+ markups: markups,
|
|
|
|
|
+ style: TextStyle(
|
|
|
|
|
+ fontWeight: bold ? FontWeight.bold : null,
|
|
|
|
|
+ fontStyle: italic ? FontStyle.italic : null,
|
|
|
|
|
+ decoration: underline ? TextDecoration.underline : null,
|
|
|
|
|
+ ),
|
|
|
|
|
+ ));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ bool check(int i, String m) =>
|
|
|
|
|
+ data.substring(i, min(i + m.length, data.length)) == m;
|
|
|
|
|
+
|
|
|
|
|
+ for (int c = 0; c < data.length; c++) {
|
|
|
|
|
+ if (check(c, b)) {
|
|
|
|
|
+ bold = !bold;
|
|
|
|
|
+ addSpan(b, bold);
|
|
|
|
|
+ c += b.length - 1;
|
|
|
|
|
+ } else if (check(c, i)) {
|
|
|
|
|
+ italic = !italic;
|
|
|
|
|
+ addSpan(i, italic);
|
|
|
|
|
+ c += i.length - 1;
|
|
|
|
|
+ } else if (check(c, u)) {
|
|
|
|
|
+ underline = !underline;
|
|
|
|
|
+ addSpan(u, underline);
|
|
|
|
|
+ c += u.length - 1;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ spans.last.text += data[c];
|
|
|
|
|
+ plainText += data[c];
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+class MarkdownSpan {
|
|
|
|
|
+ final TextStyle style;
|
|
|
|
|
+ final List<Markup> markups;
|
|
|
|
|
+ String text;
|
|
|
|
|
+
|
|
|
|
|
+ MarkdownSpan(
|
|
|
|
|
+ {required this.text, required this.style, required this.markups});
|
|
|
|
|
+
|
|
|
|
|
+ TextSpan toTextSpan() => TextSpan(text: text, style: style);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+class Markup {
|
|
|
|
|
+ final String code;
|
|
|
|
|
+ final bool isActive;
|
|
|
|
|
+
|
|
|
|
|
+ Markup(this.code, this.isActive);
|
|
|
|
|
+}
|