Browse Source

ImageBlock for StreamField (rebase of #11791) (#12416)

Co-authored-by: Chiemezuo <chiemezuoakujobi@gmail.com>
Co-authored-by: Thibaud Colas <thibaudcolas@gmail.com>
Matt Westcott 5 months ago
parent
commit
93f8600c31

+ 1 - 0
CHANGELOG.txt

@@ -5,6 +5,7 @@ Changelog
 ~~~~~~~~~~~~~~~~
 
  * Add formal support for Django 5.1 (Matt Westcott)
+ * Add `ImageBlock` with alt text support (Chiemezuo Akujobi for Google Summer of Code, mentored by Storm Heg, Saptak Sengupta, Thibaud Colas and Matt Westcott)
  * Formalize support for MariaDB (Sage Abdullah, Daniel Black)
  * Redirect to the last viewed listing page after deleting form submissions (Matthias Brück)
  * Provide `getTextLabel` method on date / time StreamField blocks (Vaughn Dickson)

+ 25 - 0
client/src/entrypoints/images/image-block.js

@@ -0,0 +1,25 @@
+class ImageBlockDefinition extends window.wagtailStreamField.blocks
+  .StructBlockDefinition {
+  render(placeholder, prefix, initialState, initialError) {
+    const block = super.render(placeholder, prefix, initialState, initialError);
+
+    const altTextField = document.getElementById(`${prefix}-alt_text`);
+    const isDecorativeField = document.getElementById(`${prefix}-decorative`);
+    const updateStateInput = () => {
+      if (isDecorativeField.checked) {
+        altTextField.setAttribute('disabled', true);
+      } else {
+        altTextField.removeAttribute('disabled');
+      }
+    };
+    updateStateInput();
+    isDecorativeField.addEventListener('change', updateStateInput);
+
+    return block;
+  }
+}
+
+window.telepath.register(
+  'wagtail.images.blocks.ImageBlock',
+  ImageBlockDefinition,
+);

+ 1 - 0
client/webpack.config.js

@@ -60,6 +60,7 @@ module.exports = function exports(env, argv) {
       'image-chooser',
       'image-chooser-modal',
       'image-chooser-telepath',
+      'image-block',
     ],
     'documents': [
       'document-chooser',

+ 17 - 6
docs/reference/streamfield/blocks.md

@@ -25,7 +25,7 @@ This document details the block types provided by Wagtail for use in [StreamFiel
 body = StreamField([
     ('heading', blocks.CharBlock(form_classname="title")),
     ('paragraph', blocks.RichTextBlock()),
-    ('image', ImageChooserBlock()),
+    ('image', ImageBlock()),
 ], block_counts={
     'heading': {'min_num': 1},
     'image': {'max_num': 5},
@@ -338,7 +338,18 @@ All block definitions accept the following optional keyword arguments:
     A control to allow the editor to select an existing document object, or upload a new one. The following additional keyword argument is accepted:
 
     :param required: If true (the default), the field cannot be left blank.
+```
+
+(streamfield_imageblock)=
+
+```{eval-rst}
 
+.. autoclass:: wagtail.images.blocks.ImageBlock
+    :show-inheritance:
+
+    An accessibility-focused control to allow the editor to select an existing image, or upload a new one. This has provision for adding alt text, indicating whether images are purely decorative, and is the Wagtail-recommended approach to uploading images. The following additional keyword argument is accepted:
+
+    :param required: If true (the default), the field cannot be left blank.
 
 .. autoclass:: wagtail.images.blocks.ImageChooserBlock
     :show-inheritance:
@@ -412,7 +423,7 @@ All block definitions accept the following optional keyword arguments:
            ('person', blocks.StructBlock([
                ('first_name', blocks.CharBlock()),
                ('surname', blocks.CharBlock()),
-               ('photo', ImageChooserBlock(required=False)),
+               ('photo', ImageBlock(required=False)),
                ('biography', blocks.RichTextBlock()),
            ], icon='user')),
        ])
@@ -426,7 +437,7 @@ All block definitions accept the following optional keyword arguments:
        class PersonBlock(blocks.StructBlock):
            first_name = blocks.CharBlock()
            surname = blocks.CharBlock()
-           photo = ImageChooserBlock(required=False)
+           photo = ImageBlock(required=False)
            biography = blocks.RichTextBlock()
 
            class Meta:
@@ -442,7 +453,7 @@ All block definitions accept the following optional keyword arguments:
        body = StreamField([
            ('heading', blocks.CharBlock(form_classname="title")),
            ('paragraph', blocks.RichTextBlock()),
-           ('image', ImageChooserBlock()),
+           ('image', ImageBlock()),
            ('person', PersonBlock()),
        ])
 
@@ -504,7 +515,7 @@ All block definitions accept the following optional keyword arguments:
            # ...
            ('carousel', blocks.StreamBlock(
                [
-                   ('image', ImageChooserBlock()),
+                   ('image', ImageBlock()),
                    ('quotation', blocks.StructBlock([
                        ('text', blocks.TextBlock()),
                        ('author', blocks.CharBlock()),
@@ -521,7 +532,7 @@ All block definitions accept the following optional keyword arguments:
     .. code-block:: python
 
        class CarouselBlock(blocks.StreamBlock):
-           image = ImageChooserBlock()
+           image = ImageBlock()
            quotation = blocks.StructBlock([
                ('text', blocks.TextBlock()),
                ('author', blocks.CharBlock()),

+ 4 - 0
docs/releases/6.3.md

@@ -19,6 +19,10 @@ This release adds formal support for Python 3.13.
 
 This release adds formal support for Django 5.1.
 
+### `ImageBlock` with alt text support
+
+This release introduces a new block type [`ImageBlock`](streamfield_imageblock), which improves upon `ImageChooserBlock` by allowing editors to specify alt text tailored to the context in which the image is used. This can be used as a direct replacement for `ImageChooserBlock`, and is the new recommended block type for all images that are not purely decorative. This feature was developed by Chiemezuo Akujobi as part of the Google Summer of Code program with mentoring support from Storm Heg, Saptak Sengupta, Thibaud Colas and Matt Westcott.
+
 ### Incremental dashboard enhancements
 
 The Wagtail dashboard design evolves towards providing more information and navigation features. Mobile support is much improved.

+ 21 - 21
docs/topics/streamfield.md

@@ -19,7 +19,7 @@ from wagtail.models import Page
 from wagtail.fields import StreamField
 from wagtail import blocks
 from wagtail.admin.panels import FieldPanel
-from wagtail.images.blocks import ImageChooserBlock
+from wagtail.images.blocks import ImageBlock
 
 class BlogPage(Page):
     author = models.CharField(max_length=255)
@@ -27,7 +27,7 @@ class BlogPage(Page):
     body = StreamField([
         ('heading', blocks.CharBlock(form_classname="title")),
         ('paragraph', blocks.RichTextBlock()),
-        ('image', ImageChooserBlock()),
+        ('image', ImageBlock()),
     ])
 
     content_panels = Page.content_panels + [
@@ -118,12 +118,12 @@ body = StreamField([
     ('person', blocks.StructBlock([
         ('first_name', blocks.CharBlock()),
         ('surname', blocks.CharBlock()),
-        ('photo', ImageChooserBlock(required=False)),
+        ('photo', ImageBlock(required=False)),
         ('biography', blocks.RichTextBlock()),
     ])),
     ('heading', blocks.CharBlock(form_classname="title")),
     ('paragraph', blocks.RichTextBlock()),
-    ('image', ImageChooserBlock()),
+    ('image', ImageBlock()),
 ])
 ```
 
@@ -153,7 +153,7 @@ Placing a StructBlock's list of child blocks inside a `StreamField` definition c
 class PersonBlock(blocks.StructBlock):
     first_name = blocks.CharBlock()
     surname = blocks.CharBlock()
-    photo = ImageChooserBlock(required=False)
+    photo = ImageBlock(required=False)
     biography = blocks.RichTextBlock()
 ```
 
@@ -164,7 +164,7 @@ body = StreamField([
     ('person', PersonBlock()),
     ('heading', blocks.CharBlock(form_classname="title")),
     ('paragraph', blocks.RichTextBlock()),
-    ('image', ImageChooserBlock()),
+    ('image', ImageBlock()),
 ])
 ```
 
@@ -181,12 +181,12 @@ body = StreamField([
     ('person', blocks.StructBlock([
         ('first_name', blocks.CharBlock()),
         ('surname', blocks.CharBlock()),
-        ('photo', ImageChooserBlock(required=False)),
+        ('photo', ImageBlock(required=False)),
         ('biography', blocks.RichTextBlock()),
     ], icon='user')),
     ('heading', blocks.CharBlock(form_classname="title")),
     ('paragraph', blocks.RichTextBlock()),
-    ('image', ImageChooserBlock()),
+    ('image', ImageBlock()),
 ])
 ```
 
@@ -196,7 +196,7 @@ body = StreamField([
 class PersonBlock(blocks.StructBlock):
     first_name = blocks.CharBlock()
     surname = blocks.CharBlock()
-    photo = ImageChooserBlock(required=False)
+    photo = ImageBlock(required=False)
     biography = blocks.RichTextBlock()
 
     class Meta:
@@ -213,10 +213,10 @@ For a list of icons available out of the box, see our [icons overview](icons). P
 :emphasize-lines: 2
 
 body = StreamField([
-    ('gallery', blocks.ListBlock(ImageChooserBlock())),
+    ('gallery', blocks.ListBlock(ImageBlock())),
     ('heading', blocks.CharBlock(form_classname="title")),
     ('paragraph', blocks.RichTextBlock()),
-    ('image', ImageChooserBlock()),
+    ('image', ImageBlock()),
 ])
 ```
 
@@ -247,12 +247,12 @@ When reading back the content of a StreamField (such as when rendering a templat
 
 body = StreamField([
     ('carousel', blocks.StreamBlock([
-        ('image', ImageChooserBlock()),
+        ('image', ImageBlock()),
         ('video', EmbedBlock()),
     ])),
     ('heading', blocks.CharBlock(form_classname="title")),
     ('paragraph', blocks.RichTextBlock()),
-    ('image', ImageChooserBlock()),
+    ('image', ImageBlock()),
 ])
 ```
 
@@ -260,7 +260,7 @@ body = StreamField([
 
 ```python
 class CarouselBlock(blocks.StreamBlock):
-    image = ImageChooserBlock()
+    image = ImageBlock()
     video = EmbedBlock()
 
     class Meta:
@@ -273,7 +273,7 @@ A StreamBlock subclass defined in this way can also be passed to a `StreamField`
 class CommonContentBlock(blocks.StreamBlock):
     heading = blocks.CharBlock(form_classname="title")
     paragraph = blocks.RichTextBlock()
-    image = ImageChooserBlock()
+    image = ImageBlock()
 
 
 class BlogPage(Page):
@@ -310,7 +310,7 @@ By default, a StreamField can contain an unlimited number of blocks. The `min_nu
 body = StreamField([
     ('heading', blocks.CharBlock(form_classname="title")),
     ('paragraph', blocks.RichTextBlock()),
-    ('image', ImageChooserBlock()),
+    ('image', ImageBlock()),
 ], min_num=2, max_num=5)
 ```
 
@@ -320,7 +320,7 @@ Or equivalently:
 class CommonContentBlock(blocks.StreamBlock):
     heading = blocks.CharBlock(form_classname="title")
     paragraph = blocks.RichTextBlock()
-    image = ImageChooserBlock()
+    image = ImageBlock()
 
     class Meta:
         min_num = 2
@@ -333,7 +333,7 @@ The `block_counts` option can be used to set a minimum or maximum count for spec
 body = StreamField([
     ('heading', blocks.CharBlock(form_classname="title")),
     ('paragraph', blocks.RichTextBlock()),
-    ('image', ImageChooserBlock()),
+    ('image', ImageBlock()),
 ], block_counts={
     'heading': {'min_num': 1, 'max_num': 3},
 })
@@ -345,7 +345,7 @@ Or equivalently:
 class CommonContentBlock(blocks.StreamBlock):
     heading = blocks.CharBlock(form_classname="title")
     paragraph = blocks.RichTextBlock()
-    image = ImageChooserBlock()
+    image = ImageBlock()
 
     class Meta:
         block_counts = {
@@ -364,7 +364,7 @@ By default, each block is rendered using simple, minimal HTML markup, or no mark
     [
         ('first_name', blocks.CharBlock()),
         ('surname', blocks.CharBlock()),
-        ('photo', ImageChooserBlock(required=False)),
+        ('photo', ImageBlock(required=False)),
         ('biography', blocks.RichTextBlock()),
     ],
     template='myapp/blocks/person.html',
@@ -378,7 +378,7 @@ Or, when defined as a subclass of StructBlock:
 class PersonBlock(blocks.StructBlock):
     first_name = blocks.CharBlock()
     surname = blocks.CharBlock()
-    photo = ImageChooserBlock(required=False)
+    photo = ImageBlock(required=False)
     biography = blocks.RichTextBlock()
 
     class Meta:

+ 19 - 19
docs/tutorial/create_portfolio_page.md

@@ -39,17 +39,17 @@ from wagtail.blocks import (
     StructBlock,
 )
 from wagtail.embeds.blocks import EmbedBlock
-from wagtail.images.blocks import ImageChooserBlock
+from wagtail.images.blocks import ImageBlock
 
 
-class ImageBlock(StructBlock):
-    image = ImageChooserBlock(required=True)
+class CaptionedImageBlock(StructBlock):
+    image = ImageBlock(required=True)
     caption = CharBlock(required=False)
     attribution = CharBlock(required=False)
 
     class Meta:
         icon = "image"
-        template = "base/blocks/image_block.html"
+        template = "base/blocks/captioned_image_block.html"
 
 
 class HeadingBlock(StructBlock):
@@ -73,7 +73,7 @@ class HeadingBlock(StructBlock):
 class BaseStreamBlock(StreamBlock):
     heading_block = HeadingBlock()
     paragraph_block = RichTextBlock(icon="pilcrow")
-    image_block = ImageBlock()
+    image_block = CaptionedImageBlock()
     embed_block = EmbedBlock(
         help_text="Insert a URL to embed. For example, https://www.youtube.com/watch?v=SGJFWirQ3ks",
         icon="media",
@@ -82,21 +82,21 @@ class BaseStreamBlock(StreamBlock):
 
 In the preceding code, you created reusable Wagtail custom blocks for different content types in your general-purpose app. You can use these blocks across your site in any order. Let's take a closer look at each of these blocks.
 
-First, `ImageBlock` is a block that editors can use to add images to a StreamField section.
+First, `CaptionedImageBlock` is a block that editors can use to add images to a StreamField section.
 
 ```python
-class ImageBlock(StructBlock):
-    image = ImageChooserBlock(required=True)
+class CaptionedImageBlock(StructBlock):
+    image = ImageBlock(required=True)
     caption = CharBlock(required=False)
     attribution = CharBlock(required=False)
     class Meta:
         icon = "image"
-        template = "base/blocks/image_block.html"
+        template = "base/blocks/captioned_image_block.html"
 ```
 
-`ImageBlock` inherits from `StructBlock`. With `StructBlock`, you can group several child blocks together under a single parent block. Your `ImageBlock` has three child blocks. The first child block, `Image`, uses the `ImageChooserBlock` field block type. With `ImageChooserBlock`, editors can select an existing image or upload a new one. Its `required` argument has a value of `true`, which means that you must provide an image for the block to work. The `caption` and `attribution` child blocks use the `CharBlock` field block type, which provides single-line text inputs for adding captions and attributions to your images. Your `caption` and `attribution` child blocks have their `required` attributes set to `false`. That means you can leave them empty in your [admin interface](https://guide.wagtail.org/en-latest/concepts/wagtail-interfaces/#admin-interface) if you want to.
+`CaptionedImageBlock` inherits from `StructBlock`. With `StructBlock`, you can group several child blocks together under a single parent block. Your `CaptionedImageBlock` has three child blocks. The first child block, `Image`, uses the `ImageBlock` field block type. With `ImageBlock`, editors can select an existing image or upload a new one. Its `required` argument has a value of `true`, which means that you must provide an image for the block to work. The `caption` and `attribution` child blocks use the `CharBlock` field block type, which provides single-line text inputs for adding captions and attributions to your images. Your `caption` and `attribution` child blocks have their `required` attributes set to `false`. That means you can leave them empty in your [admin interface](https://guide.wagtail.org/en-latest/concepts/wagtail-interfaces/#admin-interface) if you want to.
 
-Just like `ImageBlock`, your `HeadingBlock` also inherits from `StructBlock`. It has two child blocks. Let's look at those.
+Just like `CaptionedImageBlock`, your `HeadingBlock` also inherits from `StructBlock`. It has two child blocks. Let's look at those.
 
 ```python
 class HeadingBlock(StructBlock):
@@ -124,24 +124,24 @@ Your `BaseStreamBlock` class inherits from `StreamBlock`. `StreamBlock` defines
 class BaseStreamBlock(StreamBlock):
     heading_block = HeadingBlock()
     paragraph_block = RichTextBlock(icon="pilcrow")
-    image_block = ImageBlock()
+    image_block = CaptionedImageBlock()
     embed_block = EmbedBlock(
         help_text="Insert a URL to embed. For example, https://www.youtube.com/watch?v=SGJFWirQ3ks",
         icon="media",
     )
 ```
 
-Your `BaseStreamBlock` has four child blocks. The `heading_block` uses the previously defined `HeadingBlock`. `paragraph_block` uses `RichTextBlock`, which provides a WYSIWYG editor for creating formatted text. `image_block` uses the previously defined `ImageBlock` class. `embed_block` is a block for embedding external content like videos. It uses the Wagtail `EmbedBlock`. To discover more field block types that you can use, read the [documentation on Field block types](field_block_types).
+Your `BaseStreamBlock` has four child blocks. The `heading_block` uses the previously defined `HeadingBlock`. `paragraph_block` uses `RichTextBlock`, which provides a WYSIWYG editor for creating formatted text. `image_block` uses the previously defined `CaptionedImageBlock` class. `embed_block` is a block for embedding external content like videos. It uses the Wagtail `EmbedBlock`. To discover more field block types that you can use, read the [documentation on Field block types](field_block_types).
 
-Also, you defined a `Meta` class within your `ImageBlock` and `HeadingBlock` blocks. The `Meta` classes provide metadata for the blocks, including icons to visually represent them in the admin interface. The `Meta` classes also include custom templates for rendering your `ImageBlock` and `HeadingBlock` blocks.
+Also, you defined a `Meta` class within your `CaptionedImageBlock` and `HeadingBlock` blocks. The `Meta` classes provide metadata for the blocks, including icons to visually represent them in the admin interface. The `Meta` classes also include custom templates for rendering your `CaptionedImageBlock` and `HeadingBlock` blocks.
 
 ```{note}
 Wagtail provides built-in templates to render each block. However, you can override the built-in template with a custom template.
 ```
 
-Finally, you must add the custom templates that you defined in the `Meta` classes of your `ImageBlock` and `HeadingBlock` blocks.
+Finally, you must add the custom templates that you defined in the `Meta` classes of your `CaptionedImageBlock` and `HeadingBlock` blocks.
 
-To add the custom template of your `ImageBlock`, create a `base/templates/base/blocks/image_block.html` file and add the following to it:
+To add the custom template of your `CaptionedImageBlock`, create a `base/templates/base/blocks/captioned_image_block.html` file and add the following to it:
 
 ```html+django
 {% load wagtailimages_tags %}
@@ -245,8 +245,8 @@ from wagtail.blocks import (
     StructBlock,
 )
 
-# import ImageChooserBlock:
-from wagtail.images.blocks import ImageChooserBlock
+# import ImageBlock:
+from wagtail.images.blocks import ImageBlock
 
 from base.blocks import BaseStreamBlock
 
@@ -254,7 +254,7 @@ from base.blocks import BaseStreamBlock
 class CardBlock(StructBlock):
     heading = CharBlock()
     text = RichTextBlock(features=["bold", "italic", "link"])
-    image = ImageChooserBlock(required=False)
+    image = ImageBlock(required=False)
 
     class Meta:
         icon = "form"

+ 15 - 2
wagtail/api/v2/tests/test_pages.py

@@ -1822,7 +1822,7 @@ class TestPageDetailWithStreamField(TestCase):
         self.assertEqual(content["body"][0]["value"], "foo")
         self.assertTrue(content["body"][0]["id"])
 
-    def test_image_block(self):
+    def test_image_chooser_block(self):
         stream_page = self.make_stream_page('[{"type": "image", "value": 1}]')
 
         response_url = reverse("wagtailapi_v2:pages:detail", args=(stream_page.id,))
@@ -1833,7 +1833,7 @@ class TestPageDetailWithStreamField(TestCase):
         self.assertEqual(content["body"][0]["type"], "image")
         self.assertEqual(content["body"][0]["value"], 1)
 
-    def test_image_block_with_custom_get_api_representation(self):
+    def test_image_chooser_block_with_custom_get_api_representation(self):
         stream_page = self.make_stream_page('[{"type": "image", "value": 1}]')
 
         response_url = "{}?extended=1".format(
@@ -1848,6 +1848,19 @@ class TestPageDetailWithStreamField(TestCase):
             content["body"][0]["value"], {"id": 1, "title": "A missing image"}
         )
 
+    def test_image_block(self):
+        stream_page = self.make_stream_page(
+            '[{"type": "image_with_alt", "value": {"image": 1, "alt_text": "Some alt text", "decorative": false}}]'
+        )
+
+        response_url = reverse("wagtailapi_v2:pages:detail", args=(stream_page.id,))
+        response = self.client.get(response_url)
+        content = json.loads(response.content.decode("utf-8"))
+
+        self.assertEqual(content["body"][0]["type"], "image_with_alt")
+        self.assertEqual(content["body"][0]["value"]["image"], 1)
+        self.assertEqual(content["body"][0]["value"]["alt_text"], "Some alt text")
+
 
 @override_settings(
     WAGTAILFRONTENDCACHE={

+ 15 - 3
wagtail/blocks/field_block.py

@@ -1,3 +1,4 @@
+import copy
 import datetime
 from decimal import Decimal
 
@@ -817,11 +818,22 @@ class ChooserBlock(FieldBlock):
         """Return the model instances for the given list of primary keys.
 
         The instances must be returned in the same order as the values and keep None values.
+        If the same ID appears multiple times, a distinct object instance is created for each one.
         """
         objects = self.model_class.objects.in_bulk(values)
-        return [
-            objects.get(id) for id in values
-        ]  # Keeps the ordering the same as in values.
+        seen_ids = set()
+        result = []
+
+        for id in values:
+            obj = objects.get(id)
+            if obj is not None and id in seen_ids:
+                # this object is already in the result list, so we need to make a copy
+                obj = copy.copy(obj)
+
+            result.append(obj)
+            seen_ids.add(id)
+
+        return result
 
     def get_prep_value(self, value):
         # the native value (a model instance or None) should serialise to a PK or None

+ 230 - 2
wagtail/images/blocks.py

@@ -1,8 +1,14 @@
+from django import forms
+from django.core.exceptions import ValidationError
 from django.template.loader import render_to_string
 from django.utils.functional import cached_property
+from django.utils.translation import gettext_lazy as _
 
-from wagtail.admin.compare import BlockComparison
-from wagtail.blocks import ChooserBlock
+from wagtail.admin.compare import BlockComparison, StructBlockComparison
+from wagtail.blocks import BooleanBlock, CharBlock, ChooserBlock, StructBlock
+from wagtail.blocks.struct_block import StructBlockAdapter, StructBlockValidationError
+from wagtail.images.models import AbstractImage
+from wagtail.telepath import register
 
 from .shortcuts import get_rendition_or_not_found
 
@@ -51,3 +57,225 @@ class ImageChooserBlockComparison(BlockComparison):
                 "image_b": self.val_b,
             },
         )
+
+
+class ImageBlock(StructBlock):
+    """
+    An usage of ImageChooserBlock with support for alt text.
+    For backward compatibility, this block overrides necessary methods to change
+    the StructValue to be an Image model instance, making it compatible in
+    places where ImageChooserBlock was used.
+    """
+
+    image = ImageChooserBlock(required=True)
+    decorative = BooleanBlock(
+        default=False, required=False, label=_("Image is decorative")
+    )
+    alt_text = CharBlock(required=False, label=_("Alt text"))
+
+    def __init__(self, required=True, **kwargs):
+        super().__init__(
+            [
+                ("image", ImageChooserBlock(required=required)),
+                (
+                    "decorative",
+                    BooleanBlock(
+                        default=False, required=False, label=_("Image is decorative")
+                    ),
+                ),
+                ("alt_text", CharBlock(required=False, label=_("Alt text"))),
+            ],
+            **kwargs,
+        )
+
+    def deconstruct(self):
+        """
+        For typical StructBlock subclasses, it makes sense for the deconstructed block object to be a basic StructBlock
+        with the child blocks passed to the constructor (because this is largely functionally identical to the
+        subclass, and avoids embedding a reference to a potentially-volatile custom class in migrations).
+
+        This is not the case for ImageBlock, as it overrides enough of StructBlock's behaviour that a basic StructBlock
+        is not a suitable substitute - and also has an incompatible constructor signature (as we don't want to support
+        passing child blocks to it).
+
+        Therefore, we opt out of the standard StructBlock deconstruction behaviour here, and always
+        deconstruct an ImageBlock as an ImageBlock.
+        """
+        return ("wagtail.images.blocks.ImageBlock", [], self._constructor_kwargs)
+
+    def deconstruct_with_lookup(self, lookup):
+        return self.deconstruct()
+
+    @classmethod
+    def construct_from_lookup(cls, lookup, *args, **kwargs):
+        return cls(**kwargs)
+
+    def get_searchable_content(self, value):
+        if not self.search_index or not value:
+            return []
+
+        return self.child_blocks["alt_text"].get_searchable_content(
+            value.contextual_alt_text
+        )
+
+    def _struct_value_to_image(self, struct_value):
+        image = struct_value.get("image")
+        decorative = struct_value.get("decorative")
+        if image:
+            # If the image is decorative, set alt_text to an empty string
+            image.contextual_alt_text = (
+                "" if decorative else struct_value.get("alt_text")
+            )
+            image.decorative = decorative
+        return image
+
+    def _image_to_struct_value(self, image):
+        return {
+            "image": image,
+            "alt_text": image and image.contextual_alt_text,
+            "decorative": image and image.decorative,
+        }
+
+    def to_python(self, value):
+        # For backward compatibility with ImageChooserBlock
+        if isinstance(value, int):
+            image = self.child_blocks["image"].to_python(value)
+            struct_value = {"image": image, "decorative": False, "alt_text": None}
+        else:
+            struct_value = super().to_python(value)
+        return self._struct_value_to_image(struct_value)
+
+    def bulk_to_python(self, values):
+        values = list(values)
+
+        if not values:
+            return []
+
+        if isinstance(values[0], int):
+            # `values` is a list of image IDs (as we might encounter if an ImageChooserBlock has been
+            # changed to an ImageBlock with no data migration)
+            image_values = self.child_blocks["image"].bulk_to_python(values)
+
+            struct_values = [
+                {
+                    "image": image,
+                    "decorative": False,
+                    "alt_text": None,
+                }
+                for image in image_values
+            ]
+
+        else:
+            # assume `values` is a list of dicts containing `image`, `decorative` and `alt_text` keys
+            # to be handled by the StructBlock superclass
+            struct_values = super().bulk_to_python(values)
+
+        return [
+            self._struct_value_to_image(struct_value) for struct_value in struct_values
+        ]
+
+    def value_from_datadict(self, data, files, prefix):
+        struct_value = super().value_from_datadict(data, files, prefix)
+        return self._struct_value_to_image(struct_value)
+
+    def clean(self, value):
+        try:
+            self.child_blocks["image"].clean(value)
+        except ValidationError as e:
+            raise StructBlockValidationError(
+                block_errors={"image": e},
+            )
+
+        if value and not value.contextual_alt_text and not value.decorative:
+            raise StructBlockValidationError(
+                block_errors={
+                    "alt_text": ValidationError(
+                        _(
+                            "Please add some alt text for your image or mark it as decorative"
+                        )
+                    )
+                }
+            )
+
+        return value
+
+    def normalize(self, value):
+        if value is None or isinstance(value, AbstractImage):
+            return value
+        else:
+            struct_value = super().normalize(value)
+            return self._struct_value_to_image(struct_value)
+
+    def get_form_context(self, value, prefix="", errors=None):
+        dict_value = {
+            "image": value,
+            "alt_text": value and value.contextual_alt_text,
+            "decorative": value and value.decorative,
+        }
+        context = super().get_form_context(dict_value, prefix=prefix, errors=errors)
+        context["suggested_alt_text"] = value
+        return context
+
+    def get_form_state(self, value):
+        return {
+            "image": self.child_blocks["image"].get_form_state(value),
+            "alt_text": value and value.contextual_alt_text,
+            "decorative": value and value.decorative,
+        }
+
+    def get_prep_value(self, value):
+        return {
+            "image": self.child_blocks["image"].get_prep_value(value),
+            "alt_text": value and value.contextual_alt_text,
+            "decorative": value and value.decorative,
+        }
+
+    def extract_references(self, value):
+        return self.child_blocks["image"].extract_references(value)
+
+    def get_comparison_class(self):
+        return ImageBlockComparison
+
+    def get_api_representation(self, value, context=None):
+        return super().get_api_representation(
+            self._image_to_struct_value(value), context=context
+        )
+
+    def render_basic(self, value, context=None):
+        return self.child_blocks["image"].render_basic(value, context=context)
+
+    class Meta:
+        icon = "image"
+        template = "wagtailimages/widgets/image.html"
+
+
+class ImageBlockAdapter(StructBlockAdapter):
+    js_constructor = "wagtail.images.blocks.ImageBlock"
+
+    @cached_property
+    def media(self):
+        structblock_media = super().media
+        return forms.Media(
+            js=structblock_media._js + ["wagtailimages/js/image-block.js"],
+            css=structblock_media._css,
+        )
+
+
+register(ImageBlockAdapter(), ImageBlock)
+
+
+class ImageBlockComparison(StructBlockComparison):
+    def __init__(self, block, exists_a, exists_b, val_a, val_b):
+        super().__init__(
+            block,
+            exists_a,
+            exists_b,
+            block._image_to_struct_value(val_a),
+            block._image_to_struct_value(val_b),
+        )
+
+    def htmlvalue(self, val):
+        if isinstance(val, AbstractImage):
+            return super().htmlvalue(self.block._image_to_struct_value(val))
+        else:
+            return super().htmlvalue(val)

+ 48 - 1
wagtail/images/models.py

@@ -299,6 +299,11 @@ class AbstractImage(ImageFileMixin, CollectionMember, index.Indexed, models.Mode
 
     objects = ImageQuerySet.as_manager()
 
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.decorative = False
+        self.contextual_alt_text = None
+
     def _set_file_hash(self):
         with self.open_file() as f:
             self.file_hash = hash_filelike(f)
@@ -372,6 +377,36 @@ class AbstractImage(ImageFileMixin, CollectionMember, index.Indexed, models.Mode
     def __str__(self):
         return self.title
 
+    def __eq__(self, other):
+        """
+        Customise the definition of equality so that two Image instances referring to the same
+        image but different contextual alt text or decorative status are considered different.
+        All other aspects are copied from Django's base `Model` implementation.
+        """
+        if not isinstance(other, models.Model):
+            return NotImplemented
+
+        if self._meta.concrete_model != other._meta.concrete_model:
+            return False
+
+        my_pk = self.pk
+        if my_pk is None:
+            return self is other
+
+        return (
+            my_pk == other.pk
+            and other.contextual_alt_text == self.contextual_alt_text
+            and other.decorative == self.decorative
+        )
+
+    def __hash__(self):
+        """
+        Match the semantics of the custom equality definition.
+        """
+        if self.pk is None:
+            raise TypeError("Model instances without primary key value are unhashable")
+        return hash((self.pk, self.contextual_alt_text, self.decorative))
+
     def get_rect(self):
         return Rect(0, 0, self.width, self.height)
 
@@ -626,6 +661,9 @@ class AbstractImage(ImageFileMixin, CollectionMember, index.Indexed, models.Mode
             ]
             for rendition in Rendition.cache_backend.get_many(cache_keys).values():
                 filter = filters_by_spec[rendition.filter_spec]
+                # The retrieved rendition needs to be associated with the current image instance, so that any
+                # locally-set properties such as contextual_alt_text are respected
+                rendition.image = self
                 found[filter] = rendition
 
             # For items not found in the cache, look in the database
@@ -1249,7 +1287,16 @@ class AbstractRendition(ImageFileMixin, models.Model):
 
     @property
     def alt(self):
-        return self.image.default_alt_text
+        # 'decorative' and 'contextual_alt_text' exist only for ImageBlock
+        if hasattr(self.image, "decorative") and self.image.decorative:
+            return ""
+        elif (
+            hasattr(self.image, "contextual_alt_text")
+            and self.image.contextual_alt_text
+        ):
+            return self.image.contextual_alt_text
+        else:
+            return self.image.default_alt_text
 
     @property
     def attrs(self):

+ 5 - 0
wagtail/images/templates/wagtailimages/widgets/image.html

@@ -0,0 +1,5 @@
+{% load wagtailimages_tags %}
+
+<figure>
+    {% image value fill-600x338 loading="lazy" %}
+</figure>

+ 367 - 1
wagtail/images/tests/test_blocks.py

@@ -2,8 +2,15 @@ import unittest.mock
 
 from django.apps import apps
 from django.test import TestCase
+from django.utils.safestring import SafeString
 
-from wagtail.images.blocks import ImageChooserBlock
+from wagtail.admin import compare
+from wagtail.blocks.stream_block import StreamValue
+from wagtail.blocks.struct_block import StructBlockValidationError
+from wagtail.images.blocks import ImageBlock, ImageChooserBlock
+from wagtail.telepath import JSContext
+from wagtail.test.testapp.models import StreamPage
+from wagtail.test.utils.wagtail_tests import WagtailTestUtils
 
 from .utils import (
     Image,
@@ -73,3 +80,362 @@ class TestImageChooserBlock(TestCase):
 
         # None should not yield any references
         self.assertListEqual(list(block.extract_references(None)), [])
+
+
+class TestImageChooserBlockComparison(TestCase):
+    comparison_class = compare.StreamFieldComparison
+
+    def setUp(self):
+        self.image_1 = Image.objects.create(
+            title="Test image 1",
+            file=get_test_image_file(),
+        )
+
+        self.image_2 = Image.objects.create(
+            title="Test image 2",
+            file=get_test_image_file(),
+        )
+
+        self.field = StreamPage._meta.get_field("body")
+
+    def test_hasnt_changed(self):
+        field = StreamPage._meta.get_field("body")
+
+        comparison = self.comparison_class(
+            field,
+            StreamPage(
+                body=StreamValue(
+                    field.stream_block,
+                    [
+                        ("image", self.image_1, "1"),
+                    ],
+                )
+            ),
+            StreamPage(
+                body=StreamValue(
+                    field.stream_block,
+                    [
+                        ("image", self.image_1, "1"),
+                    ],
+                )
+            ),
+        )
+
+        self.assertTrue(comparison.is_field)
+        self.assertFalse(comparison.is_child_relation)
+        self.assertEqual(comparison.field_label(), "Body")
+        htmldiff = comparison.htmldiff()
+        self.assertIsInstance(htmldiff, SafeString)
+        self.assertIn('class="comparison__child-object"', htmldiff)
+        self.assertIn('class="preview-image"', htmldiff)
+        self.assertNotIn("deletion", htmldiff)
+        self.assertNotIn("addition", htmldiff)
+        self.assertFalse(comparison.has_changed())
+
+    def test_has_changed(self):
+        field = StreamPage._meta.get_field("body")
+
+        comparison = self.comparison_class(
+            field,
+            StreamPage(
+                body=StreamValue(
+                    field.stream_block,
+                    [
+                        ("image", self.image_1, "1"),
+                    ],
+                )
+            ),
+            StreamPage(
+                body=StreamValue(
+                    field.stream_block,
+                    [
+                        ("image", self.image_2, "1"),
+                    ],
+                )
+            ),
+        )
+
+        self.assertTrue(comparison.is_field)
+        self.assertFalse(comparison.is_child_relation)
+        self.assertEqual(comparison.field_label(), "Body")
+        htmldiff = comparison.htmldiff()
+        self.assertIsInstance(htmldiff, SafeString)
+        self.assertIn('class="comparison__child-object"', htmldiff)
+        self.assertIn('class="preview-image deletion"', htmldiff)
+        self.assertIn('class="preview-image addition"', htmldiff)
+        self.assertTrue(comparison.has_changed())
+
+
+class TestImageBlock(TestImageChooserBlock):
+    def test_render(self):
+        block = ImageBlock()
+        value = {
+            "image": self.image.id,  # An id is expected
+            "alt_text": "Sample alt text",
+            "decorative": False,
+        }
+        html = block.render(block.to_python(value))
+        soup = WagtailTestUtils.get_soup(html)
+        img_tag = soup.find("img")
+
+        # check specific attributes
+        self.assertEqual(img_tag["alt"], value.get("alt_text"))
+        self.assertIn("/media/images/test", img_tag["src"])
+
+    def test_render_basic(self):
+        block = ImageBlock()
+        value = {
+            "image": self.image.id,  # An id is expected
+            "alt_text": "Sample alt text",
+            "decorative": False,
+        }
+        html = block.render_basic(block.to_python(value))
+        soup = WagtailTestUtils.get_soup(html)
+        img_tag = soup.find("img")
+
+        # check specific attributes
+        self.assertEqual(img_tag["alt"], value.get("alt_text"))
+        self.assertIn("/media/images/test", img_tag["src"])
+
+    def test_render_as_decorative(self):
+        block = ImageBlock()
+        value = {
+            "image": self.image.id,  # An id is expected
+            "alt_text": "Sample alt text",
+            "decorative": True,
+        }
+        html = block.render(block.to_python(value))
+        soup = WagtailTestUtils.get_soup(html)
+        img_tag = soup.find("img")
+
+        # check specific attributes
+        self.assertEqual(img_tag["alt"], "")
+        self.assertIn("/media/images/test", img_tag["src"])
+
+    def test_no_alt_text(self):
+        block = ImageBlock()
+        value = {
+            "image": self.image.id,  # An id is expected
+            "alt_text": None,  # No alt text passed
+            "decorative": False,
+        }
+
+        # Invalid state when no alt text is given, and image not marked as decorative
+        # Should raise a StructBlock validation error
+        with self.assertRaises(StructBlockValidationError) as context:
+            block.clean(block.to_python(value))
+
+        # Check the error message
+        self.assertIn(
+            "Please add some alt text for your image or mark it as decorative",
+            str(context.exception.block_errors["alt_text"]),
+        )
+
+    def test_to_python_with_int(self):
+        block = ImageBlock()
+        value = block.to_python(self.image.id)
+
+        self.assertEqual(value.id, self.image.id)
+        self.assertEqual(value.contextual_alt_text, None)
+        self.assertFalse(value.decorative)
+
+    def test_to_python_with_dict(self):
+        block = ImageBlock()
+        value = {"image": self.image.id, "alt_text": "Sample text", "decorative": False}
+        result = block.to_python(value)
+
+        self.assertEqual(result.id, self.image.id)
+        self.assertEqual(result.contextual_alt_text, "Sample text")
+        self.assertFalse(result.decorative)
+
+    def test_get_searchable_content(self):
+        block = ImageBlock()
+        value = {
+            "image": self.image.id,  # An id is expected
+            "alt_text": "Sample alt text",
+            "decorative": False,
+        }
+        result = block.get_searchable_content(block.to_python(value))
+
+        # check specific attributes
+        self.assertEqual(result, ["Sample alt text"])
+
+    def test_required_true(self):
+        block = ImageBlock()
+
+        # the inner ImageChooserBlock should appear as required
+        image_block_def = JSContext().pack(block)
+        image_chooser_block_def = image_block_def["_args"][1][0]
+        self.assertTrue(image_chooser_block_def["_args"][2]["required"])
+
+        value = block.to_python(
+            {
+                "image": None,
+                "alt_text": "",
+                "decorative": False,
+            }
+        )
+        with self.assertRaises(StructBlockValidationError) as context:
+            block.clean(value)
+
+        self.assertIn(
+            "This field is required",
+            str(context.exception.block_errors["image"]),
+        )
+
+    def test_required_false(self):
+        block = ImageBlock(required=False)
+
+        # the inner ImageChooserBlock should appear as non-required
+        image_block_def = JSContext().pack(block)
+        image_chooser_block_def = image_block_def["_args"][1][0]
+        self.assertFalse(image_chooser_block_def["_args"][2]["required"])
+
+        value = block.to_python(
+            {
+                "image": None,
+                "alt_text": "",
+                "decorative": False,
+            }
+        )
+        self.assertIsNone(block.clean(value))
+
+
+class TestImageBlockComparison(TestCase):
+    comparison_class = compare.StreamFieldComparison
+
+    def setUp(self):
+        self.image_1 = Image.objects.create(
+            title="Test image 1",
+            file=get_test_image_file(),
+        )
+
+        self.image_2 = Image.objects.create(
+            title="Test image 2",
+            file=get_test_image_file(),
+        )
+
+        self.field = StreamPage._meta.get_field("body")
+
+    def test_hasnt_changed(self):
+        field = StreamPage._meta.get_field("body")
+
+        page_1 = StreamPage()
+        page_1.body = [
+            {
+                "type": "image_with_alt",
+                "value": {
+                    "image": self.image_1.id,
+                    "decorative": False,
+                    "alt_text": "Some alt text",
+                },
+                "id": "1",
+            },
+        ]
+        page_2 = StreamPage()
+        page_2.body = [
+            {
+                "type": "image_with_alt",
+                "value": {
+                    "image": self.image_1.id,
+                    "decorative": False,
+                    "alt_text": "Some alt text",
+                },
+                "id": "1",
+            },
+        ]
+
+        comparison = self.comparison_class(field, page_1, page_2)
+
+        self.assertTrue(comparison.is_field)
+        self.assertFalse(comparison.is_child_relation)
+        self.assertEqual(comparison.field_label(), "Body")
+        htmldiff = comparison.htmldiff()
+        self.assertIsInstance(htmldiff, SafeString)
+        self.assertIn('class="comparison__child-object"', htmldiff)
+        self.assertIn('class="preview-image"', htmldiff)
+        self.assertNotIn("deletion", htmldiff)
+        self.assertNotIn("addition", htmldiff)
+        self.assertFalse(comparison.has_changed())
+
+    def test_has_changed(self):
+        field = StreamPage._meta.get_field("body")
+
+        page_1 = StreamPage()
+        page_1.body = [
+            {
+                "type": "image_with_alt",
+                "value": {
+                    "image": self.image_1.id,
+                    "decorative": False,
+                    "alt_text": "Some alt text",
+                },
+                "id": "1",
+            },
+        ]
+        page_2 = StreamPage()
+        page_2.body = [
+            {
+                "type": "image_with_alt",
+                "value": {
+                    "image": self.image_2.id,
+                    "decorative": False,
+                    "alt_text": "Some alt text",
+                },
+                "id": "1",
+            },
+        ]
+
+        comparison = self.comparison_class(field, page_1, page_2)
+
+        self.assertTrue(comparison.is_field)
+        self.assertFalse(comparison.is_child_relation)
+        self.assertEqual(comparison.field_label(), "Body")
+        htmldiff = comparison.htmldiff()
+        self.assertIsInstance(htmldiff, SafeString)
+        self.assertIn('class="comparison__child-object"', htmldiff)
+        self.assertIn('class="preview-image deletion"', htmldiff)
+        self.assertIn('class="preview-image addition"', htmldiff)
+        self.assertTrue(comparison.has_changed())
+
+    def test_alt_text_has_changed(self):
+        field = StreamPage._meta.get_field("body")
+
+        page_1 = StreamPage()
+        page_1.body = [
+            {
+                "type": "image_with_alt",
+                "value": {
+                    "image": self.image_1.id,
+                    "decorative": False,
+                    "alt_text": "a cat playing with some string",
+                },
+                "id": "1",
+            },
+        ]
+        page_2 = StreamPage()
+        page_2.body = [
+            {
+                "type": "image_with_alt",
+                "value": {
+                    "image": self.image_1.id,
+                    "decorative": False,
+                    "alt_text": "a kitten playing with some string",
+                },
+                "id": "1",
+            },
+        ]
+
+        comparison = self.comparison_class(field, page_1, page_2)
+
+        self.assertTrue(comparison.is_field)
+        self.assertFalse(comparison.is_child_relation)
+        self.assertEqual(comparison.field_label(), "Body")
+        htmldiff = comparison.htmldiff()
+        self.assertIsInstance(htmldiff, SafeString)
+        self.assertIn('class="comparison__child-object"', htmldiff)
+        self.assertIn(
+            '<dd>a <span class="deletion">cat</span><span class="addition">kitten</span> playing with some string</dd>',
+            htmldiff,
+        )
+        self.assertTrue(comparison.has_changed())

+ 3 - 0
wagtail/images/tests/test_models.py

@@ -804,6 +804,9 @@ class TestRenditions(TestCase):
             prefetched_rendition = fresh_image.get_rendition("width-500")
         self.assertFalse(hasattr(prefetched_rendition, "_mark"))
 
+        # Check that the image instance is the same as the retrieved rendition
+        self.assertIs(new_rendition.image, self.image)
+
         # changing the image file should invalidate the cache
         self.image.file = get_test_image_file(colour="green")
         self.image.save()

+ 52 - 0
wagtail/test/testapp/migrations/0045_alter_streampage_body.py

@@ -0,0 +1,52 @@
+# Generated by Django 5.1.1 on 2024-10-11 20:40
+
+import wagtail.fields
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("tests", "0044_custompreviewsizesmodel_custompreviewsizespage"),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name="streampage",
+            name="body",
+            field=wagtail.fields.StreamField(
+                [
+                    ("text", 0),
+                    ("rich_text", 1),
+                    ("image", 2),
+                    ("product", 3),
+                    ("raw_html", 4),
+                    ("books", 5),
+                    ("title_list", 6),
+                    ("image_with_alt", 7),
+                ],
+                block_lookup={
+                    0: ("wagtail.blocks.CharBlock", (), {}),
+                    1: ("wagtail.blocks.RichTextBlock", (), {}),
+                    2: (
+                        "wagtail.test.testapp.models.ExtendedImageChooserBlock",
+                        (),
+                        {},
+                    ),
+                    3: (
+                        "wagtail.blocks.StructBlock",
+                        [[("name", 0), ("price", 0)]],
+                        {},
+                    ),
+                    4: ("wagtail.blocks.RawHTMLBlock", (), {}),
+                    5: (
+                        "wagtail.blocks.StreamBlock",
+                        [[("title", 0), ("author", 0)]],
+                        {},
+                    ),
+                    6: ("wagtail.blocks.ListBlock", (0,), {}),
+                    7: ("wagtail.images.blocks.ImageBlock", [], {}),
+                },
+            ),
+        ),
+    ]

+ 2 - 1
wagtail/test/testapp/models.py

@@ -65,7 +65,7 @@ from wagtail.documents.blocks import DocumentChooserBlock
 from wagtail.documents.models import AbstractDocument, Document
 from wagtail.fields import RichTextField, StreamField
 from wagtail.images import get_image_model
-from wagtail.images.blocks import ImageChooserBlock
+from wagtail.images.blocks import ImageBlock, ImageChooserBlock
 from wagtail.images.models import AbstractImage, AbstractRendition, Image
 from wagtail.models import (
     DraftStateMixin,
@@ -1646,6 +1646,7 @@ class StreamPage(Page):
                 "title_list",
                 ListBlock(CharBlock()),
             ),
+            ("image_with_alt", ImageBlock()),
         ],
     )
 

+ 10 - 0
wagtail/tests/test_blocks.py

@@ -4889,6 +4889,16 @@ class TestPageChooserBlock(TestCase):
 
         self.assertSequenceEqual(pages, expected_pages)
 
+    def test_bulk_to_python_distinct_instances(self):
+        page_ids = [2, 2]
+        block = blocks.PageChooserBlock()
+
+        with self.assertNumQueries(1):
+            pages = block.bulk_to_python(page_ids)
+
+        # Ensure that the two retrieved pages are distinct instances
+        self.assertIsNot(pages[0], pages[1])
+
     def test_extract_references(self):
         block = blocks.PageChooserBlock()
         christmas_page = Page.objects.get(slug="christmas")