This commit is contained in:
2024-05-20 15:37:46 +03:00
commit 00b7dbd0b7
10404 changed files with 3285853 additions and 0 deletions

View File

@ -0,0 +1,43 @@
import { addFilter } from '@wordpress/hooks';
/**
* Add list of all categories to gallery images.
*/
addFilter(
'vpf.editor.controls-render-data',
'vpf/editor/controls-render-data/images-categories-suggestions',
(data) => {
if (data.name === 'images') {
const categories = [];
// find all used categories.
if (data.attributes.images && data.attributes.images.length) {
data.attributes.images.forEach((image) => {
if (image.categories && image.categories.length) {
image.categories.forEach((cat) => {
if (categories.indexOf(cat) === -1) {
categories.push(cat);
}
});
}
});
}
if (
categories.length &&
data.image_controls &&
data.image_controls.categories &&
data.image_controls.categories.options
) {
data.image_controls.categories.options = categories.map(
(val) => ({
label: val,
value: val,
})
);
}
}
return data;
}
);

View File

@ -0,0 +1,109 @@
import { TextareaControl, TextControl } from '@wordpress/components';
import { useSelect } from '@wordpress/data';
import { addFilter } from '@wordpress/hooks';
import { __ } from '@wordpress/i18n';
/**
* Change Title and Description controls when used dynamic source option.
*
* @param props
*/
function RenderTitleAndDescriptionImageControls(props) {
const { data, textSource, img, name, index } = props;
const { imgData } = useSelect(
(select) => {
const { getMedia } = select('core');
return {
imgData: img.id ? getMedia(img.id) : null,
};
},
[img]
);
let text = '';
let description = '';
switch (textSource) {
case 'title':
text = imgData?.title?.raw || '';
description = __(
'Loaded automatically from the image Title',
'visual-portfolio'
);
break;
case 'caption':
text = imgData?.caption?.raw || '';
description = __(
'Loaded automatically from the image Caption',
'visual-portfolio'
);
break;
case 'alt':
text = imgData?.alt_text || '';
description = __(
'Loaded automatically from the image Alt',
'visual-portfolio'
);
break;
case 'description':
text = imgData?.description?.raw || '';
description = __(
'Loaded automatically from the image Description',
'visual-portfolio'
);
break;
// no default
}
const ThisControl = name === 'title' ? TextControl : TextareaControl;
return (
<ThisControl
className={`vpf-control-wrap vpf-control-wrap-${
name === 'title' ? 'text' : 'textarea'
}`}
key={`${
img.id || img.imgThumbnailUrl || img.imgUrl
}-${index}-${name}`}
label={data.label}
value={text}
help={description}
disabled
/>
);
}
// Change gallery image Title and Description control .
addFilter(
'vpf.editor.gallery-controls-render',
'vpf/editor/gallery-controls-render/title-and-description-render-by-source',
(control, data, props, controlData) => {
const { attributes, img } = props;
const { name, index } = controlData;
if (
(name === 'title' &&
attributes.images_titles_source &&
attributes.images_titles_source !== 'custom') ||
(name === 'description' &&
attributes.images_descriptions_source &&
attributes.images_descriptions_source !== 'custom')
) {
control = (
<RenderTitleAndDescriptionImageControls
{...{
data,
textSource: attributes[`images_${name}s_source`],
img,
name,
index,
}}
/>
);
}
return control;
}
);

View File

@ -0,0 +1,779 @@
import './style.scss';
import './extensions/dynamic-categories';
import './extensions/image-title-and-desription';
import {
closestCenter,
DndContext,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
arrayMove,
rectSortingStrategy,
SortableContext,
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import classnames from 'classnames/dedupe';
import { MediaUpload, MediaUploadCheck } from '@wordpress/block-editor';
import {
Button,
FocalPointPicker,
Modal,
TextControl,
} from '@wordpress/components';
import { useSelect } from '@wordpress/data';
import { useState } from '@wordpress/element';
import { applyFilters } from '@wordpress/hooks';
import { __, _n, sprintf } from '@wordpress/i18n';
import ControlsRender from '../controls-render';
const { navigator, VPGutenbergVariables } = window;
const ALLOWED_MEDIA_TYPES = ['image'];
function getHumanFileSize(size) {
const i = Math.floor(Math.log(size) / Math.log(1024));
return `${(size / 1024 ** i).toFixed(2) * 1} ${
['B', 'KB', 'MB', 'GB', 'TB'][i]
}`;
}
function prepareImage(img) {
const imgData = {
id: img.id,
imgUrl: img.url,
imgThumbnailUrl: img.url,
};
// Prepare thumbnail for all images except GIF, since GIFs animated only in full size.
if (!img.mime || img.mime !== 'image/gif') {
if (img.sizes && img.sizes.large && img.sizes.large.url) {
imgData.imgThumbnailUrl = img.sizes.large.url;
} else if (img.sizes && img.sizes.medium && img.sizes.medium.url) {
imgData.imgThumbnailUrl = img.sizes.medium.url;
} else if (
img.sizes &&
img.sizes.thumbnail &&
img.sizes.thumbnail.url
) {
imgData.imgThumbnailUrl = img.sizes.thumbnail.url;
}
}
if (img.title) {
imgData.title = img.title;
}
if (img.description) {
imgData.description = img.description;
}
return imgData;
}
/**
* Prepare selected images array using our format.
* Use the current images set with already user data on images.
*
* @param {Array} images - new images set.
* @param {Array} currentImages - current images set.
* @return {Array}
*/
function prepareImages(images, currentImages) {
const result = [];
const currentImagesIds =
currentImages && Object.keys(currentImages).length
? currentImages.map((img) => img.id)
: [];
if (images && images.length) {
images.forEach((img) => {
// We have to check for image URL, because when the image is removed from the
// system, it should be removed from our block as well after re-save.
if (img.url) {
let currentImgData = false;
if (currentImagesIds.length) {
const currentId = currentImagesIds.indexOf(img.id);
if (currentId > -1 && currentImages[currentId]) {
currentImgData = currentImages[currentId];
}
}
const imgData = currentImgData || prepareImage(img);
result.push(imgData);
}
});
}
return result;
}
const SelectedImageData = function (props) {
const {
showFocalPoint,
focalPoint,
imgId,
imgUrl,
onChangeFocalPoint,
onChangeImage,
} = props;
const [showMoreInfo, setShowMoreInfo] = useState(false);
const [linkCopied, setLinkCopied] = useState(false);
const { imageData } = useSelect(
(select) => {
if (!imgId) {
return {};
}
const { getMedia } = select('core');
const imgData = getMedia(imgId);
if (!imgData) {
return {};
}
return {
imageData: imgData,
};
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[showMoreInfo, imgId]
);
return (
<MediaUploadCheck>
<div
className={`vpf-component-gallery-control-item-modal-image-info editor-post-featured-image ${
showMoreInfo
? 'vpf-component-gallery-control-item-modal-image-info-sticky-bottom'
: ''
}`}
>
{showFocalPoint ? (
<FocalPointPicker
url={imageData?.source_url || imgUrl}
value={focalPoint}
dimensions={{
width: imageData?.media_details?.width || 80,
height: imageData?.media_details?.height || 80,
}}
onChange={(val) => {
onChangeFocalPoint(val);
}}
/>
) : null}
<MediaUpload
onSelect={(image) => {
const imgData = prepareImage(image);
onChangeImage(imgData);
}}
allowedTypes={ALLOWED_MEDIA_TYPES}
render={({ open }) => (
<Button onClick={open} isSecondary>
{__('Replace Image', 'visual-portfolio')}
</Button>
)}
/>
<Button
onClick={() => {
onChangeImage(false);
}}
isLink
isDestructive
>
{__('Remove Image from Gallery', 'visual-portfolio')}
</Button>
<div className="vpf-component-gallery-control-item-modal-image-additional-info">
<Button
onClick={() => {
setShowMoreInfo(!showMoreInfo);
}}
isLink
>
{showMoreInfo
? __('Hide Additional Info', 'visual-portfolio')
: __('Show Additional Info', 'visual-portfolio')}
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8 4L14 10L8 16"
stroke="currentColor"
fill="none"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
transform={`rotate(${
showMoreInfo ? '-' : ''
}90 10 10)`}
/>
</svg>
</Button>
{showMoreInfo ? (
<>
<div>
<strong>
{__('File name:', 'visual-portfolio')}
</strong>{' '}
{imageData?.source_url.split('/').pop() || '-'}
<br />{' '}
<strong>
{__('File type:', 'visual-portfolio')}
</strong>{' '}
{imageData?.mime_type || '-'}
<br />{' '}
<strong>
{__('File size:', '@text_domain')}
</strong>{' '}
{imageData?.media_details?.filesize
? getHumanFileSize(
imageData.media_details.filesize
)
: '-'}
{imageData?.media_details?.width ? (
<>
<br />{' '}
<strong>
{__('Dimensions:', '@text_domain')}
</strong>{' '}
{imageData.media_details.width} by{' '}
{imageData.media_details.height} pixels
</>
) : null}
</div>
<div>
<TextControl
label={__('File URL:', 'visual-portfolio')}
value={imageData?.source_url || ''}
readOnly
/>
<Button
onClick={() => {
navigator.clipboard
.writeText(
imageData?.source_url || ''
)
.then(() => {
setLinkCopied(true);
});
}}
isSecondary
isSmall
>
{__(
'Copy URL to Clipboard',
'visual-portfolio'
)}
</Button>
{linkCopied ? (
<span className="vpf-component-gallery-control-item-modal-image-additional-info-copied">
{__('Copied!', 'visual-portfolio')}
</span>
) : null}
</div>
{imageData?.link ? (
<div>
<a
href={imageData?.link}
target="_blank"
rel="noreferrer"
>
{__(
'View attachment page',
'visual-portfolio'
)}
</a>
{' | '}
<a
href={`${VPGutenbergVariables.admin_url}post.php?post=${imageData.id}&action=edit`}
target="_blank"
rel="noreferrer"
>
{__(
'Edit more details',
'visual-portfolio'
)}
</a>
</div>
) : null}
</>
) : null}
</div>
</div>
</MediaUploadCheck>
);
};
const SortableItem = function (props) {
const {
img,
items,
index,
onChange,
imageControls,
controlName,
focalPoint,
clientId,
isSetupWizard,
} = props;
const idx = index - 1;
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
isSorting,
} = useSortable({
id: props.id,
});
const style = {
transform: CSS.Translate.toString(transform),
transition: isSorting ? transition : '',
};
const [isOpen, setOpen] = useState(false);
const openModal = () => setOpen(true);
const closeModal = () => setOpen(false);
return (
<>
<div
className={classnames(
'vpf-component-gallery-control-item',
isDragging
? 'vpf-component-gallery-control-item-dragging'
: ''
)}
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
>
<Button
className="vpf-component-gallery-control-item-button"
onClick={openModal}
aria-expanded={isOpen}
>
<img
src={img.imgThumbnailUrl || img.imgUrl}
alt={img.alt || img.imgThumbnailUrl || img.imgUrl}
loading="lazy"
/>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
</Button>
<Button
className="vpf-component-gallery-control-item-remove"
onClick={() => {
const newImages = [...items];
if (newImages[idx]) {
newImages.splice(idx, 1);
onChange(newImages);
}
}}
>
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3.5 5.5H7.5M16.5 5.5H12.5M12.5 5.5V2.5H7.5V5.5M12.5 5.5H7.5M5 8.5L6 17H14L15 8.5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
fill="transparent"
/>
</svg>
</Button>
</div>
{isOpen ? (
<Modal
title={__('Image Settings', 'visual-portfolio')}
onRequestClose={(e) => {
if (
e?.relatedTarget?.classList?.contains('media-modal')
) {
// Don't close modal if opened media modal.
} else {
closeModal(e);
}
}}
>
<div className="vpf-component-gallery-control-item-modal">
{focalPoint && img.id ? (
<SelectedImageData
showFocalPoint={focalPoint}
focalPoint={img.focalPoint}
imgId={img.id}
imgUrl={img.imgThumbnailUrl || img.imgUrl}
onChangeFocalPoint={(val) => {
const newImages = [...items];
if (newImages[idx]) {
newImages[idx] = {
...newImages[idx],
focalPoint: val,
};
onChange(newImages);
}
}}
onChangeImage={(imgData) => {
const newImages = [...items];
if (!newImages[idx]) {
return;
}
if (imgData === false) {
newImages.splice(idx, 1);
onChange(newImages);
closeModal();
} else {
newImages[idx] = {
...newImages[idx],
...imgData,
};
onChange(newImages);
}
}}
/>
) : (
''
)}
<div>
{Object.keys(imageControls).map((name) => {
const newCondition = [];
// prepare name.
const imgControlName = `${controlName}[${idx}].${name}`;
// prepare conditions for the current item.
if (imageControls[name].condition.length) {
imageControls[name].condition.forEach(
(data) => {
const newData = { ...data };
if (
newData.control &&
/SELF/g.test(newData.control)
) {
newData.control =
newData.control.replace(
/SELF/g,
`${controlName}[${idx}]`
);
}
newCondition.push(newData);
}
);
}
return applyFilters(
'vpf.editor.gallery-controls-render',
<ControlsRender.Control
key={`${
img.id ||
img.imgThumbnailUrl ||
img.imgUrl
}-${idx}-${name}`}
attributes={props.attributes}
onChange={(val) => {
const newImages = [...items];
if (newImages[idx]) {
newImages[idx] = {
...newImages[idx],
[name]: val,
};
onChange(newImages);
}
}}
{...imageControls[name]}
name={imgControlName}
value={img[name]}
condition={newCondition}
clientId={clientId}
isSetupWizard={isSetupWizard}
/>,
imageControls[name],
props,
{
name,
fullName: imgControlName,
index: idx,
condition: newCondition,
}
);
})}
</div>
</div>
</Modal>
) : null}
</>
);
};
const SortableList = function (props) {
const {
items,
onChange,
onSortEnd,
imageControls,
controlName,
attributes,
focalPoint,
isSetupWizard,
} = props;
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } })
);
// Automatically open images selector when first time select Images in Setup Wizard.
const [isOpenedInSetupWizard, setOpenInSetupWizard] = useState(
!isSetupWizard
);
const [showingItems, setShowingItems] = useState(18);
const sortableItems = [];
if (items && items.length) {
items.forEach((data, i) => {
if (i < showingItems) {
sortableItems.push({
id: i + 1,
data,
});
}
});
}
const editGalleryButton = (
<MediaUpload
multiple="add"
onSelect={(images) => {
onChange(prepareImages(images, items));
}}
allowedTypes={ALLOWED_MEDIA_TYPES}
value={items && items.length ? items.map((img) => img.id) : false}
render={({ open }) => {
if (!isOpenedInSetupWizard && (!items || !items.length)) {
setOpenInSetupWizard(true);
open();
}
return (
<Button
className="vpf-component-gallery-control-item-fullwidth vpf-component-gallery-control-item-add"
onClick={(event) => {
event.stopPropagation();
open();
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
height="20"
width="20"
role="img"
aria-hidden="true"
focusable="false"
>
{items && items.length ? (
<path d="M9 14h10l-3.45-4.5-2.3 3-1.55-2Zm-1 4q-.825 0-1.412-.587Q6 16.825 6 16V4q0-.825.588-1.413Q7.175 2 8 2h12q.825 0 1.413.587Q22 3.175 22 4v12q0 .825-.587 1.413Q20.825 18 20 18Zm0-2h12V4H8v12Zm-4 6q-.825 0-1.412-.587Q2 20.825 2 20V6h2v14h14v2ZM8 4v12V4Z" />
) : (
<path d="M18 11.2h-5.2V6h-1.6v5.2H6v1.6h5.2V18h1.6v-5.2H18z" />
)}
</svg>
<span>
{items && items.length
? __('Edit Gallery', 'visual-portfolio')
: __('Add Images', 'visual-portfolio')}
</span>
</Button>
);
}}
/>
);
return (
<div className="vpf-component-gallery-control-items">
{items && items.length && items.length > 9
? editGalleryButton
: null}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={(event) => {
const { active, over } = event;
if (active.id !== over.id) {
onSortEnd(active.id - 1, over.id - 1);
}
}}
>
<SortableContext
items={sortableItems}
strategy={rectSortingStrategy}
>
{sortableItems.map(({ data, id }) => (
<SortableItem
key={`vpf-component-gallery-control-items-sortable-${id}`}
index={id}
id={id}
img={data}
items={items}
onChange={onChange}
imageControls={imageControls}
controlName={controlName}
attributes={attributes}
focalPoint={focalPoint}
isSetupWizard={isSetupWizard}
/>
))}
</SortableContext>
</DndContext>
{items && items.length ? (
<span className="vpf-component-gallery-control-item-fullwidth vpf-component-gallery-control-item-pagination">
<span>
{sprintf(
_n(
'Showing %1$s of %2$s Image',
'Showing %1$s of %2$s Images',
items.length,
'visual-portfolio'
),
showingItems > items.length
? items.length
: showingItems,
items.length
)}
</span>
{items.length > showingItems ? (
<div className="vpf-component-gallery-control-item-pagination-buttons">
<Button
isSecondary
isSmall
onClick={() => {
setShowingItems(showingItems + 18);
}}
>
{__('Show More', 'visual-portfolio')}
</Button>
<Button
isLink
isSmall
onClick={() => {
setShowingItems(items.length);
}}
>
{__('Show All', 'visual-portfolio')}
</Button>
</div>
) : null}
</span>
) : null}
{editGalleryButton}
</div>
);
};
/**
* Component Class
*
* @param props
*/
export default function GalleryControl(props) {
const {
imageControls,
attributes,
name: controlName,
value,
onChange,
focalPoint,
isSetupWizard,
} = props;
const filteredValue = value.filter((img) => img.id);
return (
<div className="vpf-component-gallery-control">
<MediaUpload
onSelect={(images) => {
onChange(prepareImages(images));
}}
allowedTypes={ALLOWED_MEDIA_TYPES}
multiple="add"
value={
filteredValue && Object.keys(filteredValue).length
? filteredValue.map((img) => img.id)
: []
}
render={() => (
<SortableList
items={filteredValue}
onChange={onChange}
imageControls={imageControls}
controlName={controlName}
attributes={attributes}
focalPoint={focalPoint}
isSetupWizard={isSetupWizard}
onSortEnd={(oldIndex, newIndex) => {
const newImages = arrayMove(
filteredValue,
oldIndex,
newIndex
);
onChange(newImages);
}}
/>
)}
/>
</div>
);
}

View File

@ -0,0 +1,216 @@
@import "../../variables";
.vpf-component-gallery-control {
.vpf-component-gallery-control-items {
position: relative;
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 15px;
.vpf-component-gallery-control-item {
position: relative;
display: inline-block;
.vpf-component-gallery-control-item-button {
position: relative;
display: block;
width: 100%;
height: 100%;
padding: 0;
padding-bottom: 100%;
overflow: hidden;
background-color: #f7f7f7 !important;
border-radius: 5px;
box-shadow: none !important;
transition: 0.2s background-color;
img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
border: 1px solid #efefef;
border-radius: 5px;
transition: 0.2s opacity;
}
svg {
position: absolute;
top: 50%;
left: 50%;
width: 20px;
height: 20px;
margin-top: -10px;
margin-left: -10px;
color: #fff;
opacity: 0;
fill: none;
transition: 0.2s opacity;
}
&:hover,
&:focus {
background-color: #000 !important;
img {
opacity: 0.6;
}
svg {
opacity: 1;
}
}
}
}
.vpf-component-gallery-control-item-remove {
position: absolute;
top: -5px;
right: -5px;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
color: #fff;
background-color: #272727;
border-radius: 20px;
opacity: 0;
transition: 0.2s opacity, 0.2s background-color;
&:hover,
&:focus {
background-color: #d51515;
}
svg {
width: 70%;
height: auto;
}
}
.vpf-component-gallery-control-item:hover .vpf-component-gallery-control-item-remove,
.vpf-component-gallery-control-item:focus .vpf-component-gallery-control-item-remove {
opacity: 1;
}
}
.vpf-component-gallery-control-item-dragging {
z-index: 2;
.vpf-component-gallery-control-item-button {
img {
opacity: 1;
}
svg {
display: none;
}
}
.vpf-component-gallery-control-item-remove {
display: none;
}
}
.vpf-component-gallery-control-item-fullwidth {
grid-column: 1 / -1;
justify-content: center;
}
.vpf-component-gallery-control-item-pagination {
display: flex;
flex-direction: column;
gap: 10px;
align-items: center;
span {
font-size: 12px;
color: #8a8a8a;
}
.vpf-component-gallery-control-item-pagination-buttons {
display: flex;
}
}
.vpf-component-gallery-control-item-add.components-button {
border: 1px solid $gray-900;
> span {
margin-left: 0.5em;
font-size: 0.9em;
font-weight: 700;
text-transform: uppercase;
}
}
}
.vpf-component-gallery-control-item-modal {
max-width: 540px;
.vpf-component-gallery-control-item-modal-image-info {
margin-bottom: 30px;
img {
max-height: 150px;
}
}
.vpf-component-gallery-control-item-modal-image-additional-info {
margin-top: 30px;
> .components-button {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
padding: 10px;
color: #7d7d7d;
text-decoration: none;
background-color: #ebebeb;
svg {
width: 13px;
margin-left: 10px;
}
}
> div {
margin-top: 20px;
}
.vpf-component-gallery-control-item-modal-image-additional-info-copied {
margin-left: 10px;
color: #15b11d;
}
}
@media screen and (min-width: 600px) {
display: flex;
gap: 30px;
align-items: flex-start;
.vpf-component-gallery-control-item-modal-image-info {
position: sticky;
top: 30px;
flex: 1;
width: 230px;
margin-bottom: 0;
+ div {
width: 280px;
}
}
.vpf-component-gallery-control-item-modal-image-info-sticky-bottom {
position: sticky;
top: auto;
bottom: 30px;
margin-top: auto;
}
}
}