Skip to content

feat: Enhance Image Settings Modal with Extensibility Slots and classList Support#3053

Draft
xitij2000 wants to merge 8 commits into
masterfrom
kshitij/image-css-editor-2
Draft

feat: Enhance Image Settings Modal with Extensibility Slots and classList Support#3053
xitij2000 wants to merge 8 commits into
masterfrom
kshitij/image-css-editor-2

Conversation

@xitij2000
Copy link
Copy Markdown
Contributor

@xitij2000 xitij2000 commented May 8, 2026

Description

This set of changes improves the image editing experience in the authoring app by introducing extensibility points and support for custom CSS classes on images.

The changes are split across two primary areas:

  1. Extensibility (Plugin Slots):
    • Introduced ImageSettingsModalSlot and ImageAdditionalSettingsSlot. These slots allow developers to extend the Image Settings Modal by adding custom form fields or modifying the modal's behavior (e.g., custom validation or value processing) via the plugin framework.
    • Refactored ImageSettingsModal to use a more flexible Formik implementation, facilitating easier integration with these new slots.
  2. CSS Class Support (classList):
    • Added classList handling to ImageSettingsModal and ImageUploadModal.
    • Updated the TinyMCE integration hooks to correctly extract and preserve existing CSS classes when an image is opened for editing.
    • Ensures that when an image is saved from the modal, its CSS classes are correctly applied to the <img> tag in the editor.

These improvements enable use cases such as adding a "Showcase" checkbox that applies a specific CSS class to an image, as demonstrated in the newly added documentation.

Testing Instructions

To verify these changes, please follow these steps:

1. Verify classList Preservation:

  • Open the authoring app and go to an editor that uses TinyMCE (e.g., a HTML component).
  • Insert an image.
  • In the "Code View" (or via the browser inspector), manually add a class to the image tag (e.g., <img class="test-class" ... />).
  • Select the image and click the "Edit" (Image Settings) button.
  • Change another property (like Alt Text) and save.
  • Check the "Code View" or inspector again. Verify that test-class is still present on the image tag.

2. Verify Plugin Slot Functionality (Technical):

  • Create a local plugin that utilizes the org.openedx.frontend.authoring.image_additional_settings.v1 slot.
  • The plugin should render a simple input or checkbox that interacts with the classList in Formik context (similar to the example in src/plugin-slots/ImageAdditionalSettingsSlot/README.md).
  • Verify that your custom UI appears in the Image Settings Modal.
  • Verify that changes made in your custom UI are correctly reflected in the final image tag's classes when saved.

Other information

This includes changes from #2993

The functionality from this proposal can be tested using the following:

import { PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
import { Form } from '@openedx/paragon';
import { useFormikContext } from 'formik';
import React from 'react';

const ImageCSSEditor = () => {
  const formik = useFormikContext();
  const classList = formik.values.classList || [];

  const handleAdvancedChange = async (event) => {
    const newValue = event.target.value.split(' ').filter(c => c.trim() !== '');
    await formik.setFieldValue('classList', newValue);
  };

  const [showAdvanced, setShowAdvanced] = React.useState(false);

  return (
    <>
      <Form.Group>
        <Form.Label>
          <Form.Checkbox
            name="showAdvanced"
            checked={showAdvanced}
            onChange={() => setShowAdvanced(!showAdvanced)}
          />
          Advanced styling
        </Form.Label>
      </Form.Group>

      {showAdvanced && (
        <Form.Group>
          <Form.Control
            name="classList"
            floatingLabel="Custom CSS classes"
            type="input"
            onChange={handleAdvancedChange}
            value={classList.join(' ')}
          />
        </Form.Group>
      )}
    </>
  );
};

const useImageCssClassSelection = ({
  classes = null,
  defaultValue = 'none',
}) => {
  const formik = useFormikContext();
  const classList = formik.values.classList || [];
  const value = classes
    ? classList.find(c => classes.includes(c)) || defaultValue
    : classList.includes(defaultValue);
  const handleChange = React.useCallback(async (event) => {
    if (event.target.type === 'checkbox') {
      const newList = event.target.checked ? [...classList, defaultValue] : classList.filter(c => c !== defaultValue);
      await formik.setFieldValue('classList', newList);
    } else if (classes) {
      const filteredList = classList.filter(c => !classes.includes(c));
      const newList = event.target.value === defaultValue ? filteredList : [...filteredList, event.target.value];
      await formik.setFieldValue('classList', newList);
    }
  }, [classList, formik]);

  return {
    value,
    handleChange,
  };
};

const ImageShadowEditor = () => {
  const options = [
    {
      value: 'none',
      label: 'None',
    },
    {
      value: 'shadow-sm',
      label: 'Slight drop shadow',
    },
    {
      value: 'shadow-lg',
      label: 'Large drop shadow',
    },
  ];
  const classes = options.map(o => o.value).filter(v => v !== 'none');
  const {
    value,
    handleChange,
  } = useImageCssClassSelection({ classes });

  return (
    <Form.Group>
      <Form.Label>Shadow</Form.Label>
      <Form.Control
        as="select"
        value={value}
        onChange={handleChange}
      >
        {options.map(option => <option key={option.value} value={option.value}>{option.label}</option>)}
      </Form.Control>
    </Form.Group>
  );
};

const FullBleedImage = () => {
  const {
    value,
    handleChange,
  } = useImageCssClassSelection({ defaultValue: 'full-bleed' });
  return (
    <Form.Group>
      <Form.Label>
        <Form.Checkbox
          checked={value}
          onChange={handleChange}
        />
        Full Bleed
      </Form.Label>
    </Form.Group>
  );
};

const ImagePlacementEditor = () => {
  const options = [
    {
      value: 'float-left',
      label: 'Left of text',
    },
    {
      value: 'float-right',
      label: 'Right of text',
    },
    {
      value: 'none',
      label: 'Inline with text',
    },
  ];
  const classes = options.map(o => o.value).filter(v => v !== 'none');

  const {
    value,
    handleChange,
  } = useImageCssClassSelection({ classes });

  return (
    <Form.Group>
      <Form.Label>Placement</Form.Label>
      <Form.Control
        as="select"
        value={value}
        onChange={handleChange}
      >
        {options.map(option => <option key={option.value} value={option.value}>{option.label}</option>)}
      </Form.Control>
    </Form.Group>
  );
};

const pluginSlots = {
  'org.openedx.frontend.authoring.image_settings_modal.v1': {
    keepDefault: true,
    plugins: [
      {
        op: PLUGIN_OPERATIONS.Modify,
        widgetId: 'default_contents',
        fn: (widget) => {
          console.log('Modifying widget default_contents');
          // widget.content.initialValues.cssClasses = 'mewli';
          widget.content.processValues = (values) => {
            console.trace('Modifying widget default_contents processValues');
            // values.classList = values.cssClasses.split(" ");
            // delete values.cssClasses;
          };
          return widget;
        },
      },
    ],
  },
  'org.openedx.frontend.authoring.image_additional_settings.v1': {
    keepDefault: true,
    plugins: [
      {
        op: PLUGIN_OPERATIONS.Insert,
        widget: {
          id: 'css_placement',
          type: 'DIRECT_PLUGIN',
          RenderWidget: ImagePlacementEditor,
        },
      },
      {
        op: PLUGIN_OPERATIONS.Insert,
        widget: {
          id: 'css_shadow',
          type: 'DIRECT_PLUGIN',
          RenderWidget: ImageShadowEditor,
        },
      },
      {
        op: PLUGIN_OPERATIONS.Insert,
        widget: {
          id: 'css_full_bleed',
          type: 'DIRECT_PLUGIN',
          RenderWidget: FullBleedImage,
        },
      },
      {
        op: PLUGIN_OPERATIONS.Insert,
        widget: {
          id: 'css_advanced',
          type: 'DIRECT_PLUGIN',
          RenderWidget: ImageCSSEditor,
        },
      },
    ],
  },
};

const config = {
  pluginSlots,
};

export default config;

Best Practices Checklist

We're trying to move away from some deprecated patterns in this codebase. Please
check if your PR meets these recommendations before asking for a review:

  • Any new files are using TypeScript (.ts, .tsx).
  • Avoid propTypes and defaultProps in any new or modified code.
  • Tests should use the helpers in src/testUtils.tsx (specifically initializeMocks)
  • Do not add new fields to the Redux state/store. Use React Context to share state among multiple components.
  • Use React Query to load data from REST APIs. See any apiHooks.ts in this repo for examples.
  • All new i18n messages in messages.ts files have a description for translators to use.
  • Avoid using ../ in import paths. To import from parent folders, use @src, e.g. import { initializeMocks } from '@src/testUtils'; instead of from '../../../../testUtils'

xitij2000 added 6 commits May 4, 2026 22:34
This commit is mainly to get git to reconise the rename so file history is
maintained.
This change converts the Image Editor Modal to typescript and simplifies the hooks switching to Formik + Yup for form validation.

It updates the tests to check the behaviour of the component as a whole rather than testing small components.
Introduce `ImageSettingsModalSlot` and `ImageAdditionalSettingsSlot` to enable easier extension of modal functionality. Update form handling with `Formik` and improve property mapping for image settings.
Include `classList` handling in ImageUploadModal and ImageSettingsModal to enable setting and managing custom CSS classes for uploaded images.
@openedx-webhooks openedx-webhooks added open-source-contribution PR author is not from Axim or 2U core contributor PR author is a Core Contributor (who may or may not have write access to this repo). labels May 8, 2026
@openedx-webhooks
Copy link
Copy Markdown

Thanks for the pull request, @xitij2000!

This repository is currently maintained by @bradenmacdonald.

Once you've gone through the following steps feel free to tag them in a comment and let them know that your changes are ready for engineering review.

🔘 Get product approval

If you haven't already, check this list to see if your contribution needs to go through the product review process.

  • If it does, you'll need to submit a product proposal for your contribution, and have it reviewed by the Product Working Group.
    • This process (including the steps you'll need to take) is documented here.
  • If it doesn't, simply proceed with the next step.
🔘 Provide context

To help your reviewers and other members of the community understand the purpose and larger context of your changes, feel free to add as much of the following information to the PR description as you can:

  • Dependencies

    This PR must be merged before / after / at the same time as ...

  • Blockers

    This PR is waiting for OEP-1234 to be accepted.

  • Timeline information

    This PR must be merged by XX date because ...

  • Partner information

    This is for a course on edx.org.

  • Supporting documentation
  • Relevant Open edX discussion forum threads
🔘 Get a green build

If one or more checks are failing, continue working on your changes until this is no longer the case and your build turns green.

🔘 Update the status of your PR

Your PR is currently marked as a draft. After completing the steps above, update its status by clicking "Ready for Review", or removing "WIP" from the title, as appropriate.


Where can I find more information?

If you'd like to get more details on all aspects of the review process for open source pull requests (OSPRs), check out the following resources:

When can I expect my changes to be merged?

Our goal is to get community contributions seen and reviewed as efficiently as possible.

However, the amount of time that it takes to review and merge a PR can vary significantly based on factors such as:

  • The size and impact of the changes that it introduces
  • The need for product review
  • Maintenance status of the parent repository

💡 As a result it may take up to several weeks or months to complete a review and merge your PR.

Copy link
Copy Markdown

@musaabhasan musaabhasan left a comment

Choose a reason for hiding this comment

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

I reviewed the new image additional-settings slot documentation and left two copy-paste correctness notes. The underlying extension point looks useful; these are small fixes that would help plugin authors avoid an accessibility regression or a runtime error when adapting the example.

checked={checked}
onChange={handleChange}
/>
<Form.Label>Showcase Image</Form.Label>
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

For a copy-pasteable accessibility example, the checkbox should have an associated accessible name. As written, the Form.Label is rendered as a sibling and may not be associated with the input, so plugin authors could copy an unlabeled control into the image settings modal. The existing pattern elsewhere in this PR nests the label inside Form.Checkbox, which would keep the example safer for keyboard and screen-reader users.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks for the feedback! I've updated the code sample and also applied this to the example in the PR description.

widget: {
id: 'showcase_class_editor',
type: 'DIRECT_PLUGIN',
RenderWidget: ShowCaseClassEditor,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This example defines ShowcaseClassEditor, but the widget references ShowCaseClassEditor. Copying the snippet as-is will throw because ShowCaseClassEditor is undefined. The RenderWidget value should match the component name above.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done! I've updated the example to use a consistent name,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I've updated the example to use a consistent name.

@xitij2000 xitij2000 force-pushed the kshitij/image-css-editor-2 branch from 32df029 to dd9e370 Compare May 10, 2026 08:48
@xitij2000 xitij2000 force-pushed the kshitij/image-css-editor-2 branch from dd9e370 to 7399639 Compare May 11, 2026 05:27
Ensure proper initialization and fallback of `classList` in image-related components and tests. Update types and test cases for consistency and better validation.
@mphilbrick211 mphilbrick211 moved this from Needs Triage to Waiting on Author in Contributions May 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core contributor PR author is a Core Contributor (who may or may not have write access to this repo). open-source-contribution PR author is not from Axim or 2U

Projects

Status: Waiting on Author

Development

Successfully merging this pull request may close these issues.

4 participants