Skip to content

Multiple Images #486

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Feb 13, 2024
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
55 changes: 49 additions & 6 deletions lib/model/content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,17 @@ class MathBlockNode extends BlockContentNode {
}
}

class ImageNodeList extends BlockContentNode {
const ImageNodeList(this.images, {super.debugHtmlNode});

final List<ImageNode> images;

@override
List<DiagnosticsNode> debugDescribeChildren() {
return images.map((node) => node.toDiagnosticsNode()).toList();
}
}

class ImageNode extends BlockContentNode {
const ImageNode({super.debugHtmlNode, required this.srcUrl});

Expand Down Expand Up @@ -1005,6 +1016,7 @@ class _ZulipContentParser {
assert(_debugParserContext == _ParserContext.block);
final List<BlockContentNode> result = [];
final List<dom.Node> currentParagraph = [];
List<ImageNode> imageNodes = [];
void consumeParagraph() {
final parsed = parseBlockInline(currentParagraph);
result.add(ParagraphNode(
Expand All @@ -1018,26 +1030,57 @@ class _ZulipContentParser {
if (node is dom.Text && (node.text == '\n')) continue;

if (_isPossibleInlineNode(node)) {
if (imageNodes.isNotEmpty) {
result.add(ImageNodeList(imageNodes));
imageNodes = [];
// In a context where paragraphs are implicit it should be impossible
// to have more paragraph content after image previews.
result.add(UnimplementedBlockContentNode(htmlNode: node));
continue;
}
currentParagraph.add(node);
continue;
}
if (currentParagraph.isNotEmpty) consumeParagraph();
result.add(parseBlockContent(node));
final block = parseBlockContent(node);
if (block is ImageNode) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This logic should get tests too — i.e. something with images nested in a list.

imageNodes.add(block);
continue;
}
if (imageNodes.isNotEmpty) {
result.add(ImageNodeList(imageNodes));
imageNodes = [];
}
result.add(block);
}
if (currentParagraph.isNotEmpty) consumeParagraph();
if (imageNodes.isNotEmpty) result.add(ImageNodeList(imageNodes));

return result;
}

List<BlockContentNode> parseBlockContentList(dom.NodeList nodes) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So the other call site of parseBlockContent is above, in parseImplicitParagraphBlockContentList. What happens if images appear in a context where that gets called? I.e., at the top level of an element of a list.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The images ended up treated as BlockContentNodes in the list. I've added another commit to handle these cases, which improves how images render inside of lists.

assert(_debugParserContext == _ParserContext.block);
final acceptedNodes = nodes.where((node) {
final List<BlockContentNode> result = [];
List<ImageNode> imageNodes = [];
for (final node in nodes) {
// We get a bunch of newline Text nodes between paragraphs.
// A browser seems to ignore these; let's do the same.
if (node is dom.Text && (node.text == '\n')) return false;
return true;
});
return acceptedNodes.map(parseBlockContent).toList(growable: false);
if (node is dom.Text && (node.text == '\n')) continue;

final block = parseBlockContent(node);
if (block is ImageNode) {
imageNodes.add(block);
continue;
}
if (imageNodes.isNotEmpty) {
result.add(ImageNodeList(imageNodes));
imageNodes = [];
}
result.add(block);
}
if (imageNodes.isNotEmpty) result.add(ImageNodeList(imageNodes));
return result;
}

ZulipContent parse(String html) {
Expand Down
47 changes: 32 additions & 15 deletions lib/widgets/content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,13 @@ class BlockContentList extends StatelessWidget {
return CodeBlock(node: node);
} else if (node is MathBlockNode) {
return MathBlock(node: node);
} else if (node is ImageNodeList) {
return MessageImageList(node: node);
} else if (node is ImageNode) {
assert(false,
"[ImageNode] not allowed in [BlockContentList]. "
"It should be wrapped in [ImageNodeList]."
);
return MessageImage(node: node);
} else if (node is UnimplementedBlockContentNode) {
return Text.rich(_errorUnimplemented(node));
Expand Down Expand Up @@ -230,6 +236,18 @@ class ListItemWidget extends StatelessWidget {
}
}

class MessageImageList extends StatelessWidget {
const MessageImageList({super.key, required this.node});

final ImageNodeList node;

@override
Widget build(BuildContext context) {
return Wrap(
children: node.images.map((imageNode) => MessageImage(node: imageNode)).toList());
}
}

class MessageImage extends StatelessWidget {
const MessageImage({super.key, required this.node});

Expand All @@ -239,7 +257,6 @@ class MessageImage extends StatelessWidget {
Widget build(BuildContext context) {
final message = InheritedMessage.of(context);

// TODO(#193) multiple images in a row
// TODO image hover animation
final src = node.srcUrl;

Expand All @@ -251,25 +268,25 @@ class MessageImage extends StatelessWidget {
Navigator.of(context).push(getLightboxRoute(
context: context, message: message, src: resolvedSrc));
},
child: Align(
child: UnconstrainedBox(
alignment: Alignment.centerLeft,
child: Padding(
// TODO clean up this padding by imitating web less precisely;
// in particular, avoid adding loose whitespace at end of message.
// The corresponding element on web has a 5px two-sided margin…
// and then a 1px transparent border all around.
padding: const EdgeInsets.fromLTRB(1, 1, 6, 6),
child: Container(
height: 100,
width: 150,
alignment: Alignment.center,
padding: const EdgeInsets.only(right: 5, bottom: 5),
child: ColoredBox(
color: const Color.fromRGBO(0, 0, 0, 0.03),
child: LightboxHero(
message: message,
src: resolvedSrc,
child: RealmContentNetworkImage(
resolvedSrc,
filterQuality: FilterQuality.medium))))));
child: Padding(
padding: const EdgeInsets.all(1),
child: SizedBox(
height: 100,
width: 150,
child: LightboxHero(
message: message,
src: resolvedSrc,
child: RealmContentNetworkImage(
resolvedSrc,
filterQuality: FilterQuality.medium))))))));
}
}

Expand Down
189 changes: 181 additions & 8 deletions test/model/content_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,180 @@ class ContentExample {
'<span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.6944em;"></span><span class="mord mathnormal">λ</span></span></span></span></span>'
'<br>\n</p>\n</blockquote>',
[QuotationNode([MathBlockNode(texSource: r'\lambda')])]);

static const imageSingle = ContentExample(
'single image',
"https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3",
'<div class="message_inline_image">'
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3">'
'<img src="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3"></a></div>', [
ImageNodeList([
ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3'),
]),
]);

static const imageCluster = ContentExample(
'multiple images',
"https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3\nhttps://chat.zulip.org/user_avatars/2/realm/icon.png?version=4",
'<p>'
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3">https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3</a><br>\n'
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=4">https://chat.zulip.org/user_avatars/2/realm/icon.png?version=4</a></p>\n'
'<div class="message_inline_image">'
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3">'
'<img src="https://uploads.zulipusercontent.net/f535ba07f95b99a83aa48e44fd62bbb6c6cf6615/68747470733a2f2f636861742e7a756c69702e6f72672f757365725f617661746172732f322f7265616c6d2f69636f6e2e706e673f76657273696f6e3d33"></a></div>'
'<div class="message_inline_image">'
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=4">'
'<img src="https://uploads.zulipusercontent.net/8f63bc2632a0e41be3f457d86c077e61b4a03e7e/68747470733a2f2f636861742e7a756c69702e6f72672f757365725f617661746172732f322f7265616c6d2f69636f6e2e706e673f76657273696f6e3d34"></a></div>', [
ParagraphNode(links: null, nodes: [
LinkNode(url: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3', nodes: [TextNode('https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3')]),
LineBreakInlineNode(),
TextNode('\n'),
LinkNode(url: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=4', nodes: [TextNode('https://chat.zulip.org/user_avatars/2/realm/icon.png?version=4')]),
]),
ImageNodeList([
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/f535ba07f95b99a83aa48e44fd62bbb6c6cf6615/68747470733a2f2f636861742e7a756c69702e6f72672f757365725f617661746172732f322f7265616c6d2f69636f6e2e706e673f76657273696f6e3d33'),
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/8f63bc2632a0e41be3f457d86c077e61b4a03e7e/68747470733a2f2f636861742e7a756c69702e6f72672f757365725f617661746172732f322f7265616c6d2f69636f6e2e706e673f76657273696f6e3d34'),
]),
]);

static const imageClusterThenContent = ContentExample(
'content after image cluster',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(But it's fine and good for these description strings to remain in more natural English syntax, like this is.)

"https://chat.zulip.org/user_avatars/2/realm/icon.png\nhttps://chat.zulip.org/user_avatars/2/realm/icon.png?version=2\n\nmore content",
'<p>content '
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png">icon.png</a> '
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=2">icon.png</a></p>\n'
'<div class="message_inline_image">'
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png" title="icon.png">'
'<img src="https://chat.zulip.org/user_avatars/2/realm/icon.png"></a></div>'
'<div class="message_inline_image">'
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=2" title="icon.png">'
'<img src="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=2"></a></div>'
'<p>more content</p>', [
ParagraphNode(links: null, nodes: [
TextNode('content '),
LinkNode(url: 'https://chat.zulip.org/user_avatars/2/realm/icon.png', nodes: [TextNode('icon.png')]),
TextNode(' '),
LinkNode(url: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=2', nodes: [TextNode('icon.png')]),
]),
ImageNodeList([
ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png'),
ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=2'),
]),
ParagraphNode(links: null, nodes: [
TextNode('more content'),
]),
]);

static const imageMultipleClusters = ContentExample(
'multiple clusters of images',
"https://en.wikipedia.org/static/images/icons/wikipedia.png\nhttps://en.wikipedia.org/static/images/icons/wikipedia.png?v=1\n\nTest\n\nhttps://en.wikipedia.org/static/images/icons/wikipedia.png?v=2\nhttps://en.wikipedia.org/static/images/icons/wikipedia.png?v=3",
'<p>'
'<a href="https://en.wikipedia.org/static/images/icons/wikipedia.png">https://en.wikipedia.org/static/images/icons/wikipedia.png</a><br>\n' '<a href="https://en.wikipedia.org/static/images/icons/wikipedia.png?v=1">https://en.wikipedia.org/static/images/icons/wikipedia.png?v=1</a></p>\n'
'<div class="message_inline_image">'
'<a href="https://en.wikipedia.org/static/images/icons/wikipedia.png">'
'<img src="https://uploads.zulipusercontent.net/34b2695ca83af76204b0b25a8f2019ee35ec38fa/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e67"></a></div>'
'<div class="message_inline_image">'
'<a href="https://en.wikipedia.org/static/images/icons/wikipedia.png?v=1">'
'<img src="https://uploads.zulipusercontent.net/d200fb112aaccbff9df767373a201fa59601f362/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d31"></a></div>'
'<p>Test</p>\n'
'<p>'
'<a href="https://en.wikipedia.org/static/images/icons/wikipedia.png?v=2">https://en.wikipedia.org/static/images/icons/wikipedia.png?v=2</a><br>\n'
'<a href="https://en.wikipedia.org/static/images/icons/wikipedia.png?v=3">https://en.wikipedia.org/static/images/icons/wikipedia.png?v=3</a></p>\n'
'<div class="message_inline_image">'
'<a href="https://en.wikipedia.org/static/images/icons/wikipedia.png?v=2">'
'<img src="https://uploads.zulipusercontent.net/c4db87e81348dac94eacaa966b46d968b34029cc/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d32"></a></div>'
'<div class="message_inline_image">'
'<a href="https://en.wikipedia.org/static/images/icons/wikipedia.png?v=3">'
'<img src="https://uploads.zulipusercontent.net/51b70540cf6a5b3c8a0b919c893b8abddd447e88/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d33"></a></div>', [
ParagraphNode(links: null, nodes: [
LinkNode(url: 'https://en.wikipedia.org/static/images/icons/wikipedia.png', nodes: [TextNode('https://en.wikipedia.org/static/images/icons/wikipedia.png')]),
LineBreakInlineNode(),
TextNode('\n'),
LinkNode(url: 'https://en.wikipedia.org/static/images/icons/wikipedia.png?v=1', nodes: [TextNode('https://en.wikipedia.org/static/images/icons/wikipedia.png?v=1')]),
]),
ImageNodeList([
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/34b2695ca83af76204b0b25a8f2019ee35ec38fa/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e67'),
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/d200fb112aaccbff9df767373a201fa59601f362/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d31'),
]),
ParagraphNode(links: null, nodes: [
TextNode('Test'),
]),
ParagraphNode(links: null, nodes: [
LinkNode(url: 'https://en.wikipedia.org/static/images/icons/wikipedia.png?v=2', nodes: [TextNode('https://en.wikipedia.org/static/images/icons/wikipedia.png?v=2')]),
LineBreakInlineNode(),
TextNode('\n'),
LinkNode(url: 'https://en.wikipedia.org/static/images/icons/wikipedia.png?v=3', nodes: [TextNode('https://en.wikipedia.org/static/images/icons/wikipedia.png?v=3')]),
]),
ImageNodeList([
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/c4db87e81348dac94eacaa966b46d968b34029cc/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d32'),
ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/51b70540cf6a5b3c8a0b919c893b8abddd447e88/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d33'),
]),
]);

static const imageInImplicitParagraph = ContentExample(
'image as immediate child in implicit paragraph',
"* https://chat.zulip.org/user_avatars/2/realm/icon.png",
'<ul>\n'
'<li>'
'<div class="message_inline_image">'
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png">'
'<img src="https://chat.zulip.org/user_avatars/2/realm/icon.png"></a></div></li>\n</ul>', [
ListNode(ListStyle.unordered, [[
ImageNodeList([
ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png'),
]),
]]),
]);

static const imageClusterInImplicitParagraph = ContentExample(
'image cluster in implicit paragraph',
"* [icon.png](https://chat.zulip.org/user_avatars/2/realm/icon.png) [icon.png](https://chat.zulip.org/user_avatars/2/realm/icon.png?version=2)",
'<ul>\n'
'<li>'
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png">icon.png</a> '
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=2">icon.png</a>'
'<div class="message_inline_image">'
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png" title="icon.png">'
'<img src="https://chat.zulip.org/user_avatars/2/realm/icon.png"></a></div>'
'<div class="message_inline_image">'
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=2" title="icon.png">'
'<img src="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=2"></a></div></li>\n</ul>', [
ListNode(ListStyle.unordered, [[
ParagraphNode(wasImplicit: true, links: null, nodes: [
LinkNode(url: 'https://chat.zulip.org/user_avatars/2/realm/icon.png', nodes: [TextNode('icon.png')]),
TextNode(' '),
LinkNode(url: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=2', nodes: [TextNode('icon.png')]),
]),
ImageNodeList([
ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png'),
ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=2'),
]),
]]),
]);

static final imageClusterInImplicitParagraphThenContent = ContentExample(
'impossible content after image cluster in implicit paragraph',
// Image previews are always inserted at the end of the paragraph
// so it would be impossible to have content after.
null,
'<ul>\n'
'<li>'
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png">icon.png</a> '
'<div class="message_inline_image">'
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png" title="icon.png">'
'<img src="https://chat.zulip.org/user_avatars/2/realm/icon.png"></a></div>'
'more text</li>\n</ul>', [
ListNode(ListStyle.unordered, [[
const ParagraphNode(wasImplicit: true, links: null, nodes: [
LinkNode(url: 'https://chat.zulip.org/user_avatars/2/realm/icon.png', nodes: [TextNode('icon.png')]),
TextNode(' '),
]),
const ImageNodeList([
ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png'),
]),
blockUnimplemented('more text'),
]]),
]);
}

UnimplementedBlockContentNode blockUnimplemented(String html) {
Expand Down Expand Up @@ -576,14 +750,13 @@ void main() {
testParseExample(ContentExample.mathBlock);
testParseExample(ContentExample.mathBlockInQuote);

testParse('parse image',
// "https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3"
'<div class="message_inline_image">'
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3">'
'<img src="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3">'
'</a></div>', const [
ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3'),
]);
testParseExample(ContentExample.imageSingle);
testParseExample(ContentExample.imageCluster);
testParseExample(ContentExample.imageClusterThenContent);
testParseExample(ContentExample.imageMultipleClusters);
testParseExample(ContentExample.imageInImplicitParagraph);
testParseExample(ContentExample.imageClusterInImplicitParagraph);
testParseExample(ContentExample.imageClusterInImplicitParagraphThenContent);

testParse('parse nested lists, quotes, headings, code blocks',
// "1. > ###### two\n > * three\n\n four"
Expand Down
Loading