From 743994cf9f6cfc3bd966f926b89981bc38a883a1 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 8 Oct 2025 16:38:14 -0700 Subject: [PATCH] action_sheet: Increase line spacing in bottom-sheet title It turns out we can do this without wasting space with extra margin above or below the title, using TextHeightBehavior. --- lib/model/content_example_json.dart | 68 +++++++ lib/model/content_example_json.g.dart | 67 +++++++ lib/model/content_node_json_utils.dart | 255 +++++++++++++++++++++++++ test/export_test_examples.dart | 131 +++++++++++++ test/model/content_test.dart | 16 +- test/model/katex_test.dart | 38 ++++ 6 files changed, 574 insertions(+), 1 deletion(-) create mode 100644 lib/model/content_example_json.dart create mode 100644 lib/model/content_example_json.g.dart create mode 100644 lib/model/content_node_json_utils.dart create mode 100644 test/export_test_examples.dart diff --git a/lib/model/content_example_json.dart b/lib/model/content_example_json.dart new file mode 100644 index 0000000000..bbadf33323 --- /dev/null +++ b/lib/model/content_example_json.dart @@ -0,0 +1,68 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'content_example_json.g.dart'; + +/// Represents a ContentExample as JSON-serializable data +@JsonSerializable() +class ContentExampleJson { + final String description; + final String? markdown; + final String html; + final List> expectedNodes; + final String? expectedText; + + ContentExampleJson({ + required this.description, + required this.markdown, + required this.html, + required this.expectedNodes, + required this.expectedText, + }); + + factory ContentExampleJson.fromJson(Map json) => + _$ContentExampleJsonFromJson(json); + + Map toJson() => _$ContentExampleJsonToJson(this); +} + +/// Represents a KatexExample as JSON-serializable data +@JsonSerializable() +class KatexExampleJson { + final String description; + final String? texSource; + final String? markdown; + final String html; + final List> expectedNodes; + final String? expectedText; + + KatexExampleJson({ + required this.description, + required this.texSource, + required this.markdown, + required this.html, + required this.expectedNodes, + required this.expectedText, + }); + + factory KatexExampleJson.fromJson(Map json) => + _$KatexExampleJsonFromJson(json); + + Map toJson() => _$KatexExampleJsonToJson(this); +} + +/// Represents all exported examples +@JsonSerializable() +class ExportedExamples { + final List contentExamples; + final List katexExamples; + + ExportedExamples({ + required this.contentExamples, + required this.katexExamples, + }); + + factory ExportedExamples.fromJson(Map json) => + _$ExportedExamplesFromJson(json); + + Map toJson() => _$ExportedExamplesToJson(this); +} \ No newline at end of file diff --git a/lib/model/content_example_json.g.dart b/lib/model/content_example_json.g.dart new file mode 100644 index 0000000000..f7e7f91d68 --- /dev/null +++ b/lib/model/content_example_json.g.dart @@ -0,0 +1,67 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ignore_for_file: constant_identifier_names, unnecessary_cast + +part of 'content_example_json.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ContentExampleJson _$ContentExampleJsonFromJson(Map json) => + ContentExampleJson( + description: json['description'] as String, + markdown: json['markdown'] as String?, + html: json['html'] as String, + expectedNodes: (json['expectedNodes'] as List) + .map((e) => e as Map) + .toList(), + expectedText: json['expectedText'] as String?, + ); + +Map _$ContentExampleJsonToJson(ContentExampleJson instance) => + { + 'description': instance.description, + 'markdown': instance.markdown, + 'html': instance.html, + 'expectedNodes': instance.expectedNodes, + 'expectedText': instance.expectedText, + }; + +KatexExampleJson _$KatexExampleJsonFromJson(Map json) => + KatexExampleJson( + description: json['description'] as String, + texSource: json['texSource'] as String?, + markdown: json['markdown'] as String?, + html: json['html'] as String, + expectedNodes: (json['expectedNodes'] as List) + .map((e) => e as Map) + .toList(), + expectedText: json['expectedText'] as String?, + ); + +Map _$KatexExampleJsonToJson(KatexExampleJson instance) => + { + 'description': instance.description, + 'texSource': instance.texSource, + 'markdown': instance.markdown, + 'html': instance.html, + 'expectedNodes': instance.expectedNodes, + 'expectedText': instance.expectedText, + }; + +ExportedExamples _$ExportedExamplesFromJson(Map json) => + ExportedExamples( + contentExamples: (json['contentExamples'] as List) + .map((e) => ContentExampleJson.fromJson(e as Map)) + .toList(), + katexExamples: (json['katexExamples'] as List) + .map((e) => KatexExampleJson.fromJson(e as Map)) + .toList(), + ); + +Map _$ExportedExamplesToJson(ExportedExamples instance) => + { + 'contentExamples': instance.contentExamples, + 'katexExamples': instance.katexExamples, + }; diff --git a/lib/model/content_node_json_utils.dart b/lib/model/content_node_json_utils.dart new file mode 100644 index 0000000000..0f73a4e48c --- /dev/null +++ b/lib/model/content_node_json_utils.dart @@ -0,0 +1,255 @@ +import 'content.dart'; +import 'katex.dart'; + +/// Converts any ContentNode to a JSON-serializable Map +Map nodeToJson(dynamic node) { + if (node is TextNode) { + return { + 'type': 'TextNode', + 'text': node.text, + }; + } else if (node is LineBreakInlineNode) { + return {'type': 'LineBreakInlineNode'}; + } else if (node is LineBreakNode) { + return {'type': 'LineBreakNode'}; + } else if (node is StrongNode) { + return { + 'type': 'StrongNode', + 'nodes': (node.nodes as List).map(nodeToJson).toList(), + }; + } else if (node is EmphasisNode) { + return { + 'type': 'EmphasisNode', + 'nodes': (node.nodes as List).map(nodeToJson).toList(), + }; + } else if (node is DeletedNode) { + return { + 'type': 'DeletedNode', + 'nodes': (node.nodes as List).map(nodeToJson).toList(), + }; + } else if (node is InlineCodeNode) { + return { + 'type': 'InlineCodeNode', + 'nodes': (node.nodes as List).map(nodeToJson).toList(), + }; + } else if (node is LinkNode) { + return { + 'type': 'LinkNode', + 'url': node.url, + 'nodes': (node.nodes as List).map(nodeToJson).toList(), + }; + } else if (node is UserMentionNode) { + return { + 'type': 'UserMentionNode', + 'nodes': (node.nodes as List).map(nodeToJson).toList(), + }; + } else if (node is UnicodeEmojiNode) { + return { + 'type': 'UnicodeEmojiNode', + 'emojiUnicode': node.emojiUnicode, + }; + } else if (node is ImageEmojiNode) { + return { + 'type': 'ImageEmojiNode', + 'src': node.src, + 'alt': node.alt, + }; + } else if (node is GlobalTimeNode) { + return { + 'type': 'GlobalTimeNode', + 'datetime': node.datetime.toIso8601String(), + }; + } else if (node is ParagraphNode) { + return { + 'type': 'ParagraphNode', + 'wasImplicit': node.wasImplicit, + 'links': node.links, + 'nodes': (node.nodes as List).map(nodeToJson).toList(), + }; + } else if (node is HeadingNode) { + return { + 'type': 'HeadingNode', + 'level': _enumToString(node.level), + 'links': node.links, + 'nodes': (node.nodes as List).map(nodeToJson).toList(), + }; + } else if (node is OrderedListNode) { + return { + 'type': 'OrderedListNode', + 'start': node.start, + 'items': node.items + .map((item) => item.map(nodeToJson).toList()) + .toList(), + }; + } else if (node is UnorderedListNode) { + return { + 'type': 'UnorderedListNode', + 'items': node.items + .map((item) => item.map(nodeToJson).toList()) + .toList(), + }; + } else if (node is QuotationNode) { + return { + 'type': 'QuotationNode', + 'nodes': (node.nodes as List).map(nodeToJson).toList(), + }; + } else if (node is CodeBlockNode) { + return { + 'type': 'CodeBlockNode', + 'spans': node.spans + .map((span) => { + 'text': span.text, + 'type': _enumToString(span.type), + }) + .toList(), + }; + } else if (node is SpoilerNode) { + return { + 'type': 'SpoilerNode', + 'header': (node.header as List).map(nodeToJson).toList(), + 'content': (node.content as List).map(nodeToJson).toList(), + }; + } else if (node is ThematicBreakNode) { + return {'type': 'ThematicBreakNode'}; + } else if (node is ImageNode) { + return { + 'type': 'ImageNode', + 'srcUrl': node.srcUrl, + 'thumbnailUrl': node.thumbnailUrl, + 'loading': node.loading, + 'originalWidth': node.originalWidth, + 'originalHeight': node.originalHeight, + }; + } else if (node is ImageNodeList) { + return { + 'type': 'ImageNodeList', + 'images': node.images.map(nodeToJson).toList(), + }; + } else if (node is MathInlineNode) { + return { + 'type': 'MathInlineNode', + 'texSource': node.texSource, + 'nodes': node.nodes?.map(nodeToJson).toList(), + }; + } else if (node is MathBlockNode) { + return { + 'type': 'MathBlockNode', + 'texSource': node.texSource, + 'nodes': node.nodes?.map(nodeToJson).toList(), + }; + } else if (node is KatexSpanNode) { + return { + 'type': 'KatexSpanNode', + 'text': node.text, + 'styles': _katexStylesToJson(node.styles), + 'nodes': node.nodes?.map(nodeToJson).toList(), + }; + } else if (node is KatexStrutNode) { + return { + 'type': 'KatexStrutNode', + 'heightEm': node.heightEm, + 'verticalAlignEm': node.verticalAlignEm, + }; + } else if (node is KatexVlistNode) { + return { + 'type': 'KatexVlistNode', + 'rows': node.rows + .map((row) => { + 'verticalOffsetEm': row.verticalOffsetEm, + 'node': nodeToJson(row.node), + }) + .toList(), + }; + } else if (node is KatexVlistRowNode) { + return { + 'type': 'KatexVlistRowNode', + 'verticalOffsetEm': node.verticalOffsetEm, + 'node': nodeToJson(node.node), + }; + } else if (node is KatexNegativeMarginNode) { + return { + 'type': 'KatexNegativeMarginNode', + 'leftOffsetEm': node.leftOffsetEm, + 'nodes': node.nodes.map(nodeToJson).toList(), + }; + } else if (node is EmbedVideoNode) { + return { + 'type': 'EmbedVideoNode', + 'hrefUrl': node.hrefUrl, + 'previewImageSrcUrl': node.previewImageSrcUrl, + }; + } else if (node is InlineVideoNode) { + return { + 'type': 'InlineVideoNode', + 'srcUrl': node.srcUrl, + }; + } else if (node is WebsitePreviewNode) { + return { + 'type': 'WebsitePreviewNode', + 'hrefUrl': node.hrefUrl, + 'imageSrcUrl': node.imageSrcUrl, + 'title': node.title, + 'description': node.description, + }; + } else if (node is TableNode) { + return { + 'type': 'TableNode', + 'rows': node.rows + .map((row) => { + 'isHeader': row.isHeader, + 'cells': row.cells + .map((cell) => { + 'nodes': (cell.nodes as List).map(nodeToJson).toList(), + 'links': cell.links, + 'textAlignment': _enumToString(cell.textAlignment), + }) + .toList(), + }) + .toList(), + }; + } else if (node is UnimplementedBlockContentNode) { + return { + 'type': 'UnimplementedBlockContentNode', + 'htmlNode': node.htmlNode.toString(), + }; + } else if (node is UnimplementedInlineContentNode) { + return { + 'type': 'UnimplementedInlineContentNode', + 'htmlNode': node.htmlNode.toString(), + }; + } else { + return { + 'type': node.runtimeType.toString(), + 'error': 'Unknown node type', + }; + } +} + +/// Helper to convert enums to readable strings +String _enumToString(dynamic enumValue) { + return enumValue.toString().split('.').last; +} + +/// Helper to convert KatexSpanStyles to JSON +Map? _katexStylesToJson(KatexSpanStyles? styles) { + if (styles == null) return null; + return { + 'fontFamily': styles.fontFamily, + 'fontStyle': styles.fontStyle != null ? _enumToString(styles.fontStyle) : null, + 'fontSizeEm': styles.fontSizeEm, + 'marginRightEm': styles.marginRightEm, + 'marginLeftEm': styles.marginLeftEm, + 'topEm': styles.topEm, + 'widthEm': styles.widthEm, + 'textAlign': styles.textAlign != null ? _enumToString(styles.textAlign) : null, + 'color': styles.color != null + ? { + 'r': styles.color!.r, + 'g': styles.color!.g, + 'b': styles.color!.b, + 'a': styles.color!.a, + } + : null, + 'position': styles.position != null ? _enumToString(styles.position) : null, + }; +} \ No newline at end of file diff --git a/test/export_test_examples.dart b/test/export_test_examples.dart new file mode 100644 index 0000000000..783d4b0398 --- /dev/null +++ b/test/export_test_examples.dart @@ -0,0 +1,131 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:zulip/model/content_example_json.dart'; +import 'model/content_test.dart'; +import 'model/katex_test.dart'; + +Future main() async { + final contentExamples = [ + ContentExample.strong.toJsonSerializable(), + ContentExample.deleted.toJsonSerializable(), + ContentExample.emphasis.toJsonSerializable(), + ContentExample.inlineCode.toJsonSerializable(), + ContentExample.userMentionPlain.toJsonSerializable(), + ContentExample.userMentionSilent.toJsonSerializable(), + ContentExample.userMentionSilentClassOrderReversed.toJsonSerializable(), + ContentExample.groupMentionPlain.toJsonSerializable(), + ContentExample.groupMentionSilent.toJsonSerializable(), + ContentExample.groupMentionSilentClassOrderReversed.toJsonSerializable(), + ContentExample.channelWildcardMentionPlain.toJsonSerializable(), + ContentExample.channelWildcardMentionSilent.toJsonSerializable(), + ContentExample.channelWildcardMentionSilentClassOrderReversed.toJsonSerializable(), + ContentExample.legacyChannelWildcardMentionPlain.toJsonSerializable(), + ContentExample.legacyChannelWildcardMentionSilent.toJsonSerializable(), + ContentExample.legacyChannelWildcardMentionSilentClassOrderReversed.toJsonSerializable(), + ContentExample.topicMentionPlain.toJsonSerializable(), + ContentExample.topicMentionSilent.toJsonSerializable(), + ContentExample.topicMentionSilentClassOrderReversed.toJsonSerializable(), + ContentExample.emojiUnicode.toJsonSerializable(), + ContentExample.emojiUnicodeClassesFlipped.toJsonSerializable(), + ContentExample.emojiUnicodeMultiCodepoint.toJsonSerializable(), + ContentExample.emojiUnicodeLiteral.toJsonSerializable(), + ContentExample.emojiCustom.toJsonSerializable(), + ContentExample.emojiCustomInvalidUrl.toJsonSerializable(), + ContentExample.emojiZulipExtra.toJsonSerializable(), + ContentExample.mathInline.toJsonSerializable(), + ContentExample.mathInlineUnknown.toJsonSerializable(), + ContentExample.globalTime.toJsonSerializable(), + ContentExample.messageLink.toJsonSerializable(), + ContentExample.orderedListCustomStart.toJsonSerializable(), + ContentExample.orderedListLargeStart.toJsonSerializable(), + ContentExample.spoilerDefaultHeader.toJsonSerializable(), + ContentExample.spoilerPlainCustomHeader.toJsonSerializable(), + ContentExample.spoilerRichHeaderAndContent.toJsonSerializable(), + ContentExample.spoilerHeaderHasImage.toJsonSerializable(), + ContentExample.quotation.toJsonSerializable(), + ContentExample.codeBlockPlain.toJsonSerializable(), + ContentExample.codeBlockHighlightedShort.toJsonSerializable(), + ContentExample.codeBlockHighlightedMultiline.toJsonSerializable(), + ContentExample.codeBlockSpansWithMultipleClasses.toJsonSerializable(), + ContentExample.codeBlockWithEmptyBody.toJsonSerializable(), + ContentExample.codeBlockWithHighlightedLines.toJsonSerializable(), + ContentExample.codeBlockWithUnknownSpanType.toJsonSerializable(), + ContentExample.codeBlockFollowedByMultipleLineBreaks.toJsonSerializable(), + ContentExample.mathBlock.toJsonSerializable(), + ContentExample.mathBlockUnknown.toJsonSerializable(), + ContentExample.mathBlocksMultipleInParagraph.toJsonSerializable(), + ContentExample.mathBlockInQuote.toJsonSerializable(), + ContentExample.mathBlocksMultipleInQuote.toJsonSerializable(), + ContentExample.mathBlockBetweenImages.toJsonSerializable(), + ContentExample.imageSingle.toJsonSerializable(), + ContentExample.imageSingleNoDimensions.toJsonSerializable(), + ContentExample.imageSingleNoThumbnail.toJsonSerializable(), + ContentExample.imageSingleLoadingPlaceholder.toJsonSerializable(), + ContentExample.imageSingleExternal.toJsonSerializable(), + ContentExample.imageInvalidUrl.toJsonSerializable(), + ContentExample.imageCluster.toJsonSerializable(), + ContentExample.imageClusterNoThumbnails.toJsonSerializable(), + ContentExample.imageClusterThenContent.toJsonSerializable(), + ContentExample.imageMultipleClusters.toJsonSerializable(), + ContentExample.imageInImplicitParagraph.toJsonSerializable(), + ContentExample.imageClusterInImplicitParagraph.toJsonSerializable(), + ContentExample.imageClusterInImplicitParagraphThenContent.toJsonSerializable(), + ContentExample.videoEmbedYoutube.toJsonSerializable(), + ContentExample.videoEmbedYoutubeClassesFlipped.toJsonSerializable(), + ContentExample.videoEmbedVimeoPreviewDisabled.toJsonSerializable(), + ContentExample.videoEmbedVimeo.toJsonSerializable(), + ContentExample.videoEmbedVimeoClassesFlipped.toJsonSerializable(), + ContentExample.videoInline.toJsonSerializable(), + ContentExample.videoInlineClassesFlipped.toJsonSerializable(), + ContentExample.audioInline.toJsonSerializable(), + ContentExample.audioInlineNoTitle.toJsonSerializable(), + ContentExample.websitePreviewSmoke.toJsonSerializable(), + ContentExample.websitePreviewWithoutTitle.toJsonSerializable(), + ContentExample.websitePreviewWithoutDescription.toJsonSerializable(), + ContentExample.websitePreviewWithoutTitleOrDescription.toJsonSerializable(), + ContentExample.legacyWebsitePreviewSmoke.toJsonSerializable(), + ContentExample.tableWithSingleRow.toJsonSerializable(), + ContentExample.tableWithMultipleRows.toJsonSerializable(), + ContentExample.tableWithBoldAndItalicHeaders.toJsonSerializable(), + ContentExample.tableWithLinksInCells.toJsonSerializable(), + ContentExample.tableWithImage.toJsonSerializable(), + ContentExample.tableWithoutAnyBodyCellsInMarkdown.toJsonSerializable(), + ContentExample.tableMissingOneBodyColumnInMarkdown.toJsonSerializable(), + ContentExample.tableWithDifferentTextAlignmentInColumns.toJsonSerializable(), + ContentExample.tableWithLinkCenterAligned.toJsonSerializable(), + ContentExample.thematicBreak.toJsonSerializable(), + ]; + + final katexExamples = [ + KatexExample.sizing.toJsonSerializable(), + KatexExample.nestedSizing.toJsonSerializable(), + KatexExample.delimsizing.toJsonSerializable(), + KatexExample.spacing.toJsonSerializable(), + KatexExample.vlistSuperscript.toJsonSerializable(), + KatexExample.vlistSubscript.toJsonSerializable(), + KatexExample.vlistSubAndSuperscript.toJsonSerializable(), + KatexExample.vlistRaisebox.toJsonSerializable(), + KatexExample.negativeMargin.toJsonSerializable(), + KatexExample.katexLogo.toJsonSerializable(), + KatexExample.vlistNegativeMargin.toJsonSerializable(), + KatexExample.color.toJsonSerializable(), + KatexExample.textColor.toJsonSerializable(), + KatexExample.customColorMacro.toJsonSerializable(), + KatexExample.phantom.toJsonSerializable(), + KatexExample.bigOperators.toJsonSerializable(), + KatexExample.colonEquals.toJsonSerializable(), + KatexExample.nulldelimiter.toJsonSerializable(), + ]; + + final examples = ExportedExamples( + contentExamples: contentExamples, + katexExamples: katexExamples, + ); + + final outputFile = File('test_examples.json'); + await outputFile.writeAsString(JsonEncoder.withIndent(' ').convert(examples.toJson())); + + //print('✓ Exported ${contentExamples.length} content examples'); + //print('✓ Exported ${katexExamples.length} KaTeX examples'); + //print('✓ Written to ${outputFile.path}'); +} \ No newline at end of file diff --git a/test/model/content_test.dart b/test/model/content_test.dart index 84e3baed42..240f36ff24 100644 --- a/test/model/content_test.dart +++ b/test/model/content_test.dart @@ -6,11 +6,25 @@ import 'package:stack_trace/stack_trace.dart'; import 'package:test/scaffolding.dart'; import 'package:zulip/model/code_block.dart'; import 'package:zulip/model/content.dart'; +import 'package:zulip/model/content_example_json.dart'; +import 'package:zulip/model/content_node_json_utils.dart'; import 'package:zulip/model/katex.dart'; import 'binding.dart'; import 'content_checks.dart'; + +extension ContentExampleJsonExport on ContentExample { + ContentExampleJson toJsonSerializable() { + return ContentExampleJson( + description: description, + markdown: markdown, + html: html, + expectedNodes: expectedNodes.map(nodeToJson).toList(), + expectedText: expectedText, + ); + } +} /// An example of Zulip content for test cases. // // When writing examples: @@ -1893,4 +1907,4 @@ void main() async { }, skip: Platform.isWindows, // [intended] purely analyzes source, so // any one platform is enough; avoid dealing with Windows file paths ); -} +} \ No newline at end of file diff --git a/test/model/katex_test.dart b/test/model/katex_test.dart index 6ebc832e02..acd3dab3b9 100644 --- a/test/model/katex_test.dart +++ b/test/model/katex_test.dart @@ -4,11 +4,49 @@ import 'package:checks/checks.dart'; import 'package:stack_trace/stack_trace.dart'; import 'package:test_api/scaffolding.dart'; import 'package:zulip/model/content.dart'; +import 'package:zulip/model/content_example_json.dart'; +import 'package:zulip/model/content_node_json_utils.dart'; import 'package:zulip/model/katex.dart'; import 'binding.dart'; import 'content_test.dart'; + +extension KatexExampleJsonExport on KatexExample { + KatexExampleJson toJsonSerializable() { + final texSource = _extractTexSource(); + return KatexExampleJson( + description: description, + texSource: texSource, + markdown: markdown, + html: html, + expectedNodes: expectedNodes.map(nodeToJson).toList(), + expectedText: expectedText, + ); + } + + /// Helper to extract TeX source from MathInlineNode or MathBlockNode + /// within the expectedNodes + String? _extractTexSource() { + if (expectedNodes.isEmpty) return null; + + final firstNode = expectedNodes[0]; + + if (firstNode is MathBlockNode) { + return firstNode.texSource; + } + + // For inline math, the first node should be a ParagraphNode containing MathInlineNode + if (firstNode is ParagraphNode) { + for (final inlineNode in firstNode.nodes) { + if (inlineNode is MathInlineNode) { + return inlineNode.texSource; + } + } + } + return null; + } +} /// An example of KaTeX Zulip content for test cases. /// /// For guidance on writing examples, see comments on [ContentExample].