From 12ae6eda008f0d25ef8ac6959b203f402527c4a9 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Thu, 3 Apr 2025 16:55:07 +0200 Subject: [PATCH 1/2] Support pagination indicator in image gallery REDMINE-20988 --- .../locales/new/image_gallery_peeks.de.yml | 9 +++ .../locales/new/image_gallery_peeks.en.yml | 9 +++ .../imageGallery/ImageGallery.js | 21 +++++- .../imageGallery/ImageGallery.module.css | 11 ++- .../imageGallery/editor/index.js | 5 +- .../contentElements/imageGallery/stories.js | 6 ++ .../src/frontend/PaginationIndicator.js | 67 +++++++++++++++++++ .../frontend/PaginationIndicator.module.css | 34 ++++++++++ .../scrolled/package/src/frontend/index.js | 1 + 9 files changed, 158 insertions(+), 5 deletions(-) create mode 100644 entry_types/scrolled/package/src/frontend/PaginationIndicator.js create mode 100644 entry_types/scrolled/package/src/frontend/PaginationIndicator.module.css diff --git a/entry_types/scrolled/config/locales/new/image_gallery_peeks.de.yml b/entry_types/scrolled/config/locales/new/image_gallery_peeks.de.yml index 4575a80ffa..b963481351 100644 --- a/entry_types/scrolled/config/locales/new/image_gallery_peeks.de.yml +++ b/entry_types/scrolled/config/locales/new/image_gallery_peeks.de.yml @@ -7,3 +7,12 @@ de: displayPeeks: label: "Anrisse benachbarter Bilder zeigen" inline_help: "Teile der benachbarten Bilder als Hinweis auf weitere Inhalte anzeigen." + displayPaginationIndicator: + label: "Paginierung anzeigen" + inline_help: |- + Zeigt die aktuelle Bildposition und die Gesamtzahl der + Bilder an, um die Navigation durch die Galerie zu + erleichtern. + public: + image_gallery_pagination: "Bildergalerie-Paginierung" + go_to_image_gallery_item: "Gehe zu Bild %{index}" diff --git a/entry_types/scrolled/config/locales/new/image_gallery_peeks.en.yml b/entry_types/scrolled/config/locales/new/image_gallery_peeks.en.yml index 67dab244a8..b5bea38a19 100644 --- a/entry_types/scrolled/config/locales/new/image_gallery_peeks.en.yml +++ b/entry_types/scrolled/config/locales/new/image_gallery_peeks.en.yml @@ -7,3 +7,12 @@ en: displayPeeks: label: "Display peeks of neighboring images" inline_help: "Show partial adjacent image previews as navigation cues." + displayPaginationIndicator: + label: "Display pagination" + inline_help: |- + Displays the current image position and the total + number of images to make navigating the gallery + easier. + public: + image_gallery_pagination: "Image gallery pagination" + go_to_image_gallery_item: "Go to image %{index}" diff --git a/entry_types/scrolled/package/src/contentElements/imageGallery/ImageGallery.js b/entry_types/scrolled/package/src/contentElements/imageGallery/ImageGallery.js index 349d02a4d0..c5e419e19e 100644 --- a/entry_types/scrolled/package/src/contentElements/imageGallery/ImageGallery.js +++ b/entry_types/scrolled/package/src/contentElements/imageGallery/ImageGallery.js @@ -12,6 +12,7 @@ import { Image, InlineFileRights, ToggleFullscreenCornerButton, + PaginationIndicator, usePhonePlatform, contentElementWidths } from 'pageflow-scrolled/frontend'; @@ -102,8 +103,12 @@ function Scroller({ }, [visibleIndex, scrollerRef, controlled]); function scrollBy(delta) { + scrollTo(visibleIndex + delta); + } + + function scrollTo(index) { const scroller = scrollerRef.current; - const child = scroller.children[visibleIndex + delta]; + const child = scroller.children[index]; if (child) { scrollerRef.current.scrollTo(child.offsetLeft - scroller.offsetLeft, 0); @@ -139,9 +144,9 @@ function Scroller({
@@ -169,6 +174,16 @@ function Scroller({ ))}
+ {configuration.displayPaginationIndicator && +
+ scrollTo(index)} /> +
}
); } diff --git a/entry_types/scrolled/package/src/contentElements/imageGallery/ImageGallery.module.css b/entry_types/scrolled/package/src/contentElements/imageGallery/ImageGallery.module.css index 4a819fbc1c..ac6d0654bb 100644 --- a/entry_types/scrolled/package/src/contentElements/imageGallery/ImageGallery.module.css +++ b/entry_types/scrolled/package/src/contentElements/imageGallery/ImageGallery.module.css @@ -54,6 +54,15 @@ grid-column: 3; } +.paginationIndicator { + grid-column: 1 / -1; + align-self: end; + justify-self: center; + z-index: 3; + margin-top: var(--theme-image-gallery-pagination-margin-top, 10px); + margin-bottom: var(--theme-image-gallery-pagination-margin-bottom, 10px); +} + .items { grid-row: 1; grid-column: 1/-1; @@ -89,7 +98,7 @@ .figure { transition: transform .2s ease, filter .2s linear; - transform: scale(0.9); + transform: scale(var(--theme-image-gallery-item-scale, 0.9)); filter: brightness(0.5); } diff --git a/entry_types/scrolled/package/src/contentElements/imageGallery/editor/index.js b/entry_types/scrolled/package/src/contentElements/imageGallery/editor/index.js index 7dd6408426..e28a1673eb 100644 --- a/entry_types/scrolled/package/src/contentElements/imageGallery/editor/index.js +++ b/entry_types/scrolled/package/src/contentElements/imageGallery/editor/index.js @@ -21,6 +21,7 @@ editor.contentElementTypes.register('imageGallery', { this.input('displayPeeks', CheckBoxInputView, { storeInverted: 'hidePeeks' }); + this.input('displayPaginationIndicator', CheckBoxInputView); this.input('enableFullscreenOnDesktop', CheckBoxInputView, { disabledBinding: ['position', 'width'], disabled: () => contentElement.getWidth() === contentElementWidths.full, @@ -31,5 +32,7 @@ editor.contentElementTypes.register('imageGallery', { this.group('ContentElementCaption', {entry, disableWhenNoCaption: false}); this.group('ContentElementInlineFileRightsSettings', {entry, disableWhenNoFileRights: false}); }); - } + }, + + defaultConfig: {displayPaginationIndicator: true} }); diff --git a/entry_types/scrolled/package/src/contentElements/imageGallery/stories.js b/entry_types/scrolled/package/src/contentElements/imageGallery/stories.js index 02d6ec41a7..e076567a59 100644 --- a/entry_types/scrolled/package/src/contentElements/imageGallery/stories.js +++ b/entry_types/scrolled/package/src/contentElements/imageGallery/stories.js @@ -105,6 +105,12 @@ storiesOfContentElement(module, { } } } + }, + { + name: 'With pagination indicator', + configuration: { + displayPaginationIndicator: true + } } ] }); diff --git a/entry_types/scrolled/package/src/frontend/PaginationIndicator.js b/entry_types/scrolled/package/src/frontend/PaginationIndicator.js new file mode 100644 index 0000000000..44ed6f47c6 --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/PaginationIndicator.js @@ -0,0 +1,67 @@ +import React, {useEffect, useRef} from 'react'; +import classNames from 'classnames'; + +import { + useI18n, + useTheme +} from 'pageflow-scrolled/frontend'; + +import styles from './PaginationIndicator.module.css'; + +export function PaginationIndicator({ + itemCount, currentIndex, + scrollerRef, + navAriaLabelTranslationKey, itemAriaLabelTranslationKey, + onItemClick +}) { + const {t} = useI18n(); + const navRef = useRef(); + const theme = useTheme(); + + const currentItemFlex = theme.options.properties.root.paginationIndicatorCurrentItemFlex || 3; + + useEffect(() => { + if (!(currentItemFlex > 1)) { + return; + } + + const timeline = new window.ScrollTimeline({ + source: scrollerRef.current, + axis: 'inline' + }); + + const animations = [...navRef.current.children].map((element, index) => { + const start = 1 / Math.max(itemCount - 1, 1) * (index - 1); + const end = 1 / Math.max(itemCount - 1, 1) * (index + 1); + + return element.animate( + [ + (start >= 0) && {flex: 1, offset: start}, + {flex: currentItemFlex}, + (end <= 1) && {flex: 1, offset: end} + ].filter(Boolean), + { + easing: 'linear', + timeline + } + ) + }); + + return () => animations.forEach(animation => animation.cancel()); + }, [currentItemFlex, scrollerRef, itemCount]) + + return ( + + ); +} diff --git a/entry_types/scrolled/package/src/frontend/PaginationIndicator.module.css b/entry_types/scrolled/package/src/frontend/PaginationIndicator.module.css new file mode 100644 index 0000000000..8f8723bcfd --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/PaginationIndicator.module.css @@ -0,0 +1,34 @@ +.nav { + display: flex; + height: var(--theme-pagination-indicator-item-height, 18px); +} + +.item { + flex: 1; + position: relative; + border: 5px solid transparent; + background: transparent; + padding: 0; + display: block; + height: 100%; + box-sizing: border-box; + min-width: var(--theme-pagination-indicator-item-width, 15px); + transition: opacity .5s, transform .5s ease; + cursor: pointer; +} + +.item::after { + content: ""; + position: absolute; + inset: 0; + background-color: var(--theme-light-content-text-color, #fff); + border-radius: var(--theme-pagination-indicator-item-border-radius, 3px); + transition: filter .2s; + opacity: var(--theme-pagination-indicator-item-opacity, 0.9); + border: solid 1px var(--theme-pagination-indicator-item-border-color, rgb(0 0 0 / 0.2)); + filter: var(--theme-pagination-indicator-item-filter, drop-shadow(0 1px 4px rgb(0 0 0 / 0.8))); +} + +.current::after { + opacity: var(--theme-pagination-indicator-current-item-opacity); +} diff --git a/entry_types/scrolled/package/src/frontend/index.js b/entry_types/scrolled/package/src/frontend/index.js index 7c4d5f3c45..72ff4356aa 100644 --- a/entry_types/scrolled/package/src/frontend/index.js +++ b/entry_types/scrolled/package/src/frontend/index.js @@ -49,6 +49,7 @@ export {Panorama} from './Panorama'; export {ExpandableImage} from './ExpandableImage'; export {ToggleFullscreenCornerButton} from './ToggleFullscreenCornerButton'; export {FullscreenViewer} from './FullscreenViewer'; +export {PaginationIndicator} from './PaginationIndicator'; export * from './useOnScreen'; export * from './i18n'; From 01bf87b099f65ef9b68506721fc9fef2515e94ef Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Thu, 3 Apr 2025 18:02:19 +0200 Subject: [PATCH 2/2] Add support for portrait images in image gallery REDMINE-20988 --- .../locales/new/image_gallery_peeks.de.yml | 18 +++ .../locales/new/image_gallery_peeks.en.yml | 17 +++ .../imageGallery/ImageGallery.js | 108 ++++++++++++------ .../imageGallery/editor/ItemsListView.js | 8 +- .../imageGallery/editor/SidebarController.js | 22 ++++ .../editor/SidebarEditItemView.js | 64 +++++++++++ .../imageGallery/editor/SidebarRouter.js | 7 ++ .../imageGallery/editor/index.js | 24 +++- .../editor/models/ItemsCollection.js | 2 +- .../contentElements/imageGallery/stories.js | 13 +++ 10 files changed, 242 insertions(+), 41 deletions(-) create mode 100644 entry_types/scrolled/package/src/contentElements/imageGallery/editor/SidebarController.js create mode 100644 entry_types/scrolled/package/src/contentElements/imageGallery/editor/SidebarEditItemView.js create mode 100644 entry_types/scrolled/package/src/contentElements/imageGallery/editor/SidebarRouter.js diff --git a/entry_types/scrolled/config/locales/new/image_gallery_peeks.de.yml b/entry_types/scrolled/config/locales/new/image_gallery_peeks.de.yml index b963481351..943a623a05 100644 --- a/entry_types/scrolled/config/locales/new/image_gallery_peeks.de.yml +++ b/entry_types/scrolled/config/locales/new/image_gallery_peeks.de.yml @@ -13,6 +13,24 @@ de: Zeigt die aktuelle Bildposition und die Gesamtzahl der Bilder an, um die Navigation durch die Galerie zu erleichtern. + edit_item: + back: Zurück + destroy: Löschen + confirm_delete_link: Soll der Bildergalerie-Eintrag wirklich gelöscht werden? + attributes: + image: + label: Bild + portraitImage: + inline_help: |- + Wird verwendet, wenn der Browser-Viewport höher als + breit ist - zum Beispiel auf Smartphones oder + Tablets in Portrait-Ausrichtung. Kann als + Alternative zu einem querformatigen Bild + konfiguriert werden, das ansonsten zu klein + dargestellt würde. + label: Bild (Hochkant) + tabs: + item: Bildergalerie-Eintrag public: image_gallery_pagination: "Bildergalerie-Paginierung" go_to_image_gallery_item: "Gehe zu Bild %{index}" diff --git a/entry_types/scrolled/config/locales/new/image_gallery_peeks.en.yml b/entry_types/scrolled/config/locales/new/image_gallery_peeks.en.yml index b5bea38a19..06350abaf1 100644 --- a/entry_types/scrolled/config/locales/new/image_gallery_peeks.en.yml +++ b/entry_types/scrolled/config/locales/new/image_gallery_peeks.en.yml @@ -13,6 +13,23 @@ en: Displays the current image position and the total number of images to make navigating the gallery easier. + edit_item: + back: Back + destroy: Delete + confirm_delete_link: Are you sure you want to delete this image gallery item? + attributes: + image: + label: Image + portraitImage: + inline_help: |- + Displayed when the browser viewport is taller than + wide, for example on phones or tablets in portrait + orientation. Can be used to provide an alternative + to a landscape image that would otherwise be + displayed too small. + label: Image (Portrait) + tabs: + item: Image Gallery Item public: image_gallery_pagination: "Image gallery pagination" go_to_image_gallery_item: "Go to image %{index}" diff --git a/entry_types/scrolled/package/src/contentElements/imageGallery/ImageGallery.js b/entry_types/scrolled/package/src/contentElements/imageGallery/ImageGallery.js index c5e419e19e..e9ddad542d 100644 --- a/entry_types/scrolled/package/src/contentElements/imageGallery/ImageGallery.js +++ b/entry_types/scrolled/package/src/contentElements/imageGallery/ImageGallery.js @@ -14,6 +14,7 @@ import { ToggleFullscreenCornerButton, PaginationIndicator, usePhonePlatform, + usePortraitOrientation, contentElementWidths } from 'pageflow-scrolled/frontend'; @@ -189,18 +190,55 @@ function Scroller({ } const Item = forwardRef(function({item, configuration, current, onClick, children}, ref) { - const updateConfiguration = useContentElementConfigurationUpdate(); - const {shouldLoad} = useContentElementLifecycle(); - - const captions = configuration.captions || {}; - const caption = captions[item.id]; - const imageFile = useFileWithInlineRights({ configuration: item, collectionName: 'imageFiles', propertyName: 'image' }); + const portraitImageFile = useFileWithInlineRights({ + configuration: item, + collectionName: 'imageFiles', + propertyName: 'portraitImage' + }); + + const ItemImageComponent = portraitImageFile ? OrientationAwareItemImage : ItemImageWithCaption; + + return ( +
+
+ +
+
+ ); +}); + +function OrientationAwareItemImage({imageFile, portraitImageFile, ...props}) { + const portraitOrientation = usePortraitOrientation(); + + imageFile = portraitOrientation && portraitImageFile ? + portraitImageFile : imageFile; + return ( + + ); +} + +function ItemImageWithCaption({item, imageFile, configuration, current, onClick, children}) { + const {shouldLoad} = useContentElementLifecycle(); + + const updateConfiguration = useContentElementConfigurationUpdate(); + + const captions = configuration.captions || {}; + const caption = captions[item.id]; + const handleCaptionChange = function(caption) { updateConfiguration({ captions: { @@ -211,36 +249,30 @@ const Item = forwardRef(function({item, configuration, current, onClick, childre } return ( -
-
- - -
- -
- -
- {children} - -
-
-
- -
-
-
+ + +
+ +
+ +
+ {children} + +
+
+
+ +
); -}); +} diff --git a/entry_types/scrolled/package/src/contentElements/imageGallery/editor/ItemsListView.js b/entry_types/scrolled/package/src/contentElements/imageGallery/editor/ItemsListView.js index db9f1450f1..1fe72d0bce 100644 --- a/entry_types/scrolled/package/src/contentElements/imageGallery/editor/ItemsListView.js +++ b/entry_types/scrolled/package/src/contentElements/imageGallery/editor/ItemsListView.js @@ -1,5 +1,5 @@ import Marionette from 'backbone.marionette'; -import {buttonStyles} from 'pageflow-scrolled/editor'; +import {editor, buttonStyles} from 'pageflow-scrolled/editor'; import {ListView} from 'pageflow/editor'; import {cssModulesUtils} from 'pageflow/ui'; import I18n from 'i18n-js'; @@ -28,6 +28,12 @@ export const ItemsListView = Marionette.Layout.extend({ collection: this.collection, sortable: true, + onEdit: (model) => { + editor.navigate( + `/scrolled/imageGalleries/${this.options.contentElement.id}/${model.id}`, + {trigger: true} + ) + }, onRemove: (model) => this.collection.remove(model) })); } diff --git a/entry_types/scrolled/package/src/contentElements/imageGallery/editor/SidebarController.js b/entry_types/scrolled/package/src/contentElements/imageGallery/editor/SidebarController.js new file mode 100644 index 0000000000..d9194303bf --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/imageGallery/editor/SidebarController.js @@ -0,0 +1,22 @@ +import {SidebarEditItemView} from './SidebarEditItemView'; +import {ItemsCollection} from './models/ItemsCollection'; +import Marionette from 'backbone.marionette'; + +export const SidebarController = Marionette.Controller.extend({ + initialize: function(options) { + this.entry = options.entry; + this.region = options.region; + }, + + item: function(id, itemId) { + const contentElement = this.entry.contentElements.get(id); + const itemsCollection = ItemsCollection.forContentElement(contentElement, this.entry); + + this.region.show(new SidebarEditItemView({ + model: itemsCollection.get(itemId), + collection: itemsCollection, + entry: this.entry, + contentElement + })); + } +}); diff --git a/entry_types/scrolled/package/src/contentElements/imageGallery/editor/SidebarEditItemView.js b/entry_types/scrolled/package/src/contentElements/imageGallery/editor/SidebarEditItemView.js new file mode 100644 index 0000000000..4ee9ce7547 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/imageGallery/editor/SidebarEditItemView.js @@ -0,0 +1,64 @@ +import {ConfigurationEditorView} from 'pageflow/ui'; +import {editor, FileInputView} from 'pageflow/editor'; +import Marionette from 'backbone.marionette'; +import I18n from 'i18n-js'; + +export const SidebarEditItemView = Marionette.Layout.extend({ + template: (data) => ` + ${I18n.t('pageflow_scrolled.editor.content_elements.imageGallery.edit_item.back')} + ${I18n.t('pageflow_scrolled.editor.content_elements.imageGallery.edit_item.destroy')} + +
+ `, + + regions: { + formContainer: '.form_container', + }, + + events: { + 'click a.back': 'goBack', + 'click a.destroy': 'destroyLink' + }, + + onRender: function () { + const options = this.options; + + const configurationEditor = new ConfigurationEditorView({ + model: this.model, + attributeTranslationKeyPrefixes: ['pageflow_scrolled.editor.content_elements.imageGallery.edit_item.attributes'], + tabTranslationKeyPrefix: 'pageflow_scrolled.editor.content_elements.imageGallery.edit_item.tabs', + }); + + configurationEditor.tab('item', function() { + this.input('image', FileInputView, { + collection: 'image_files', + fileSelectionHandler: 'imageGalleryItem', + fileSelectionHandlerOptions: { + contentElementId: options.contentElement.get('id') + }, + positioning: false + }); + this.input('portraitImage', FileInputView, { + collection: 'image_files', + fileSelectionHandler: 'imageGalleryItem', + fileSelectionHandlerOptions: { + contentElementId: options.contentElement.get('id') + }, + positioning: false + }); + }); + + this.formContainer.show(configurationEditor); + }, + + goBack: function() { + editor.navigate(`/scrolled/content_elements/${this.options.contentElement.get('id')}`, {trigger: true}); + }, + + destroyLink: function () { + if (window.confirm(I18n.t('pageflow_scrolled.editor.content_elements.imageGallery.edit_item.confirm_delete_link'))) { + this.options.collection.remove(this.model); + this.goBack(); + } + } +}); diff --git a/entry_types/scrolled/package/src/contentElements/imageGallery/editor/SidebarRouter.js b/entry_types/scrolled/package/src/contentElements/imageGallery/editor/SidebarRouter.js new file mode 100644 index 0000000000..bc209ae1ca --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/imageGallery/editor/SidebarRouter.js @@ -0,0 +1,7 @@ +import Marionette from 'backbone.marionette'; + +export const SidebarRouter = Marionette.AppRouter.extend({ + appRoutes: { + 'scrolled/imageGalleries/:id/:item_id': 'item' + } +}); diff --git a/entry_types/scrolled/package/src/contentElements/imageGallery/editor/index.js b/entry_types/scrolled/package/src/contentElements/imageGallery/editor/index.js index e28a1673eb..ac638e0ce8 100644 --- a/entry_types/scrolled/package/src/contentElements/imageGallery/editor/index.js +++ b/entry_types/scrolled/package/src/contentElements/imageGallery/editor/index.js @@ -5,8 +5,16 @@ import {CheckBoxInputView, SeparatorView} from 'pageflow/editor'; import {ItemsListView} from './ItemsListView'; import {ItemsCollection} from './models/ItemsCollection'; +import {SidebarRouter} from './SidebarRouter'; +import {SidebarController} from './SidebarController'; + import pictogram from './pictogram.svg'; +editor.registerSideBarRouting({ + router: SidebarRouter, + controller: SidebarController +}); + editor.contentElementTypes.register('imageGallery', { pictogram, category: 'media', @@ -16,7 +24,8 @@ editor.contentElementTypes.register('imageGallery', { configurationEditor({entry, contentElement}) { this.tab('general', function() { this.view(ItemsListView, { - collection: ItemsCollection.forContentElement(this.model.parent, entry) + contentElement, + collection: ItemsCollection.forContentElement(this.model.parent, entry), }); this.input('displayPeeks', CheckBoxInputView, { storeInverted: 'hidePeeks' @@ -36,3 +45,16 @@ editor.contentElementTypes.register('imageGallery', { defaultConfig: {displayPaginationIndicator: true} }); + +editor.registerFileSelectionHandler('imageGalleryItem', function (options) { + const contentElement = options.entry.contentElements.get(options.contentElementId); + const items = ItemsCollection.forContentElement(contentElement, options.entry) + + this.call = function(file) { + items.get(options.id).setReference(options.attributeName, file); + }; + + this.getReferer = function() { + return '/scrolled/imageGalleries/' + contentElement.id + '/' + options.id; + }; +}); diff --git a/entry_types/scrolled/package/src/contentElements/imageGallery/editor/models/ItemsCollection.js b/entry_types/scrolled/package/src/contentElements/imageGallery/editor/models/ItemsCollection.js index 3f5f82969e..e5b9a48475 100644 --- a/entry_types/scrolled/package/src/contentElements/imageGallery/editor/models/ItemsCollection.js +++ b/entry_types/scrolled/package/src/contentElements/imageGallery/editor/models/ItemsCollection.js @@ -11,7 +11,7 @@ export const ItemsCollection = Backbone.Collection.extend({ initialize(models, options) { this.entry = options.entry; this.contentElement = options.contentElement; - this.listenTo(this, 'add remove sort', this.updateConfiguration); + this.listenTo(this, 'add change remove sort', this.updateConfiguration); this.listenTo(this, 'remove', this.pruneCaptions); }, diff --git a/entry_types/scrolled/package/src/contentElements/imageGallery/stories.js b/entry_types/scrolled/package/src/contentElements/imageGallery/stories.js index e076567a59..67a2d43c2d 100644 --- a/entry_types/scrolled/package/src/contentElements/imageGallery/stories.js +++ b/entry_types/scrolled/package/src/contentElements/imageGallery/stories.js @@ -34,6 +34,19 @@ storiesOfContentElement(module, { }, inlineFileRights: true, variants: [ + { + name: 'Portrait image', + configuration: { + items: [ + { + id: 1, + image: filePermaId('imageFiles', 'turtle'), + portraitImage: filePermaId('imageFiles', 'churchBefore') + } + ] + }, + viewport: 'phone' + }, { name: 'Custom scroll button size', themeOptions: {