diff --git a/assets/js/src/core/app/config/services/index.ts b/assets/js/src/core/app/config/services/index.ts index a40efda70..3fff889a1 100644 --- a/assets/js/src/core/app/config/services/index.ts +++ b/assets/js/src/core/app/config/services/index.ts @@ -117,6 +117,7 @@ import { DynamicTypeObjectDataGeoPolyLine } from '@Pimcore/modules/element/dynam import { DynamicTypeObjectDataManyToOneRelation } from '@Pimcore/modules/element/dynamic-types/defintinitions/objects/data-related/types/dynamic-type-object-data-many-to-one-relation' import { DynamicTypeObjectDataManyToManyRelation } from '@Pimcore/modules/element/dynamic-types/defintinitions/objects/data-related/types/dynamic-type-object-data-many-to-many-relation' import { DynamicTypeObjectDataStructuredTable } from '@Pimcore/modules/element/dynamic-types/defintinitions/objects/data-related/types/dynamic-type-object-data-structured-table' +import { DynamicTypeObjectDataTable } from '@Pimcore/modules/element/dynamic-types/defintinitions/objects/data-related/types/dynamic-type-object-data-table' import { DynamicTypeObjectDataBlock } from '@Pimcore/modules/element/dynamic-types/defintinitions/objects/data-related/types/dynamic-type-object-data-block' import { DynamicTypeObjectDataLocalizedFields } from '@Pimcore/modules/element/dynamic-types/defintinitions/objects/data-related/types/dynamic-type-object-data-localized-fields' import { DynamicTypeGridCellAsset } from '@Pimcore/modules/element/dynamic-types/defintinitions/grid-cell/types/asset/dynamic-type-grid-cell-asset' @@ -268,6 +269,7 @@ container.bind(serviceIds['DynamicTypes/ObjectData/GeoPolygon']).to(DynamicTypeO container.bind(serviceIds['DynamicTypes/ObjectData/GeoPolyLine']).to(DynamicTypeObjectDataGeoPolyLine).inSingletonScope() container.bind(serviceIds['DynamicTypes/ObjectData/ManyToOneRelation']).to(DynamicTypeObjectDataManyToOneRelation).inSingletonScope() container.bind(serviceIds['DynamicTypes/ObjectData/ManyToManyRelation']).to(DynamicTypeObjectDataManyToManyRelation).inSingletonScope() +container.bind(serviceIds['DynamicTypes/ObjectData/Table']).to(DynamicTypeObjectDataTable).inSingletonScope() container.bind(serviceIds['DynamicTypes/ObjectData/StructuredTable']).to(DynamicTypeObjectDataStructuredTable).inSingletonScope() container.bind(serviceIds['DynamicTypes/ObjectData/Block']).to(DynamicTypeObjectDataBlock).inSingletonScope() container.bind(serviceIds['DynamicTypes/ObjectData/LocalizedFields']).to(DynamicTypeObjectDataLocalizedFields).inSingletonScope() diff --git a/assets/js/src/core/app/config/services/service-ids.ts b/assets/js/src/core/app/config/services/service-ids.ts index 14021d3f6..3e03af0e9 100644 --- a/assets/js/src/core/app/config/services/service-ids.ts +++ b/assets/js/src/core/app/config/services/service-ids.ts @@ -151,6 +151,7 @@ export const serviceIds = { 'DynamicTypes/ObjectData/GeoPolyLine': 'DynamicTypes/ObjectData/GeoPolyLine', 'DynamicTypes/ObjectData/ManyToOneRelation': 'DynamicTypes/ObjectData/ManyToOneRelation', 'DynamicTypes/ObjectData/ManyToManyRelation': 'DynamicTypes/ObjectData/ManyToManyRelation', + 'DynamicTypes/ObjectData/Table': 'DynamicTypes/ObjectData/Table', 'DynamicTypes/ObjectData/StructuredTable': 'DynamicTypes/ObjectData/StructuredTable', 'DynamicTypes/ObjectData/Block': 'DynamicTypes/ObjectData/Block', 'DynamicTypes/ObjectData/LocalizedFields': 'DynamicTypes/ObjectData/LocalizedFields', diff --git a/assets/js/src/core/assets/icons/content-duplicate.inline.svg b/assets/js/src/core/assets/icons/content-duplicate.inline.svg new file mode 100644 index 000000000..ef8d9fcd4 --- /dev/null +++ b/assets/js/src/core/assets/icons/content-duplicate.inline.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/js/src/core/assets/icons/delete-column.inline.svg b/assets/js/src/core/assets/icons/delete-column.inline.svg new file mode 100644 index 000000000..dc8c00737 --- /dev/null +++ b/assets/js/src/core/assets/icons/delete-column.inline.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/js/src/core/assets/icons/delete-row.inline.svg b/assets/js/src/core/assets/icons/delete-row.inline.svg new file mode 100644 index 000000000..140433d95 --- /dev/null +++ b/assets/js/src/core/assets/icons/delete-row.inline.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/js/src/core/assets/icons/remove-image-thumbnail.inline.svg b/assets/js/src/core/assets/icons/remove-image-thumbnail.inline.svg new file mode 100644 index 000000000..63b95317f --- /dev/null +++ b/assets/js/src/core/assets/icons/remove-image-thumbnail.inline.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/js/src/core/assets/icons/remove-marker.inline.svg b/assets/js/src/core/assets/icons/remove-marker.inline.svg new file mode 100644 index 000000000..403e91808 --- /dev/null +++ b/assets/js/src/core/assets/icons/remove-marker.inline.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/js/src/core/assets/icons/remove-pdf-thumbnail.inline.svg b/assets/js/src/core/assets/icons/remove-pdf-thumbnail.inline.svg new file mode 100644 index 000000000..2e3b17b5d --- /dev/null +++ b/assets/js/src/core/assets/icons/remove-pdf-thumbnail.inline.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/assets/js/src/core/assets/icons/remove-video-thumbnail.inline.svg b/assets/js/src/core/assets/icons/remove-video-thumbnail.inline.svg new file mode 100644 index 000000000..2e3b17b5d --- /dev/null +++ b/assets/js/src/core/assets/icons/remove-video-thumbnail.inline.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/assets/js/src/core/components/element-tree/expander/tree-expander.tsx b/assets/js/src/core/components/element-tree/expander/tree-expander.tsx index 9481aae58..f99e667f8 100644 --- a/assets/js/src/core/components/element-tree/expander/tree-expander.tsx +++ b/assets/js/src/core/components/element-tree/expander/tree-expander.tsx @@ -16,6 +16,7 @@ import { type TreeNodeProps } from '../node/tree-node' import { TreeContext } from '../element-tree' import { Icon } from '@Pimcore/components/icon/icon' import { useTranslation } from 'react-i18next' +import { Spin } from '@Pimcore/components/spin/spin' export interface TreeExpanderProps { node: TreeNodeProps @@ -23,7 +24,7 @@ export interface TreeExpanderProps { } export const TreeExpander = ({ node, state }: TreeExpanderProps): React.JSX.Element => { - const { hasChildren, children } = node + const { hasChildren, children, isLoading } = node const { onLoad } = useContext(TreeContext) const [isExpanded, setIsExpanded] = state const { t } = useTranslation() @@ -42,11 +43,17 @@ export const TreeExpander = ({ node, state }: TreeExpanderProps): React.JSX.Elem } } + console.log({ isLoading }) + return (
+ {isLoading === true && ( + + )} + {node.hasChildren === true && ( // keyboard navigation is already handled on parent level // eslint-disable-next-line jsx-a11y/click-events-have-key-events @@ -56,19 +63,24 @@ export const TreeExpander = ({ node, state }: TreeExpanderProps): React.JSX.Elem role='button' tabIndex={ -1 } > - {isExpanded - ? ( - - ) - : ( - - )} + {isLoading !== true && ( + <> + {isExpanded + ? ( + + ) + : ( + + ) + } + + )} )}
diff --git a/assets/js/src/core/components/element-tree/node/tree-node.styles.ts b/assets/js/src/core/components/element-tree/node/tree-node.styles.ts index d6e49a801..230680419 100644 --- a/assets/js/src/core/components/element-tree/node/tree-node.styles.ts +++ b/assets/js/src/core/components/element-tree/node/tree-node.styles.ts @@ -19,11 +19,18 @@ export const useStyles = createStyles(({ token, css }) => { user-select: none; &.tree-node--is-root { - .tree-node__content { + & > .tree-node__content { padding-left: ${token.paddingSM}px; } } + &.tree-node--danger { + & > .tree-node__content .tree-node__content-wrapper { + color: ${token.colorError}; + text-decoration: line-through; + } + } + .tree-node__content { cursor: pointer; width: 100%; diff --git a/assets/js/src/core/components/element-tree/node/tree-node.tsx b/assets/js/src/core/components/element-tree/node/tree-node.tsx index 4755eec01..b483c705f 100644 --- a/assets/js/src/core/components/element-tree/node/tree-node.tsx +++ b/assets/js/src/core/components/element-tree/node/tree-node.tsx @@ -35,6 +35,8 @@ export interface TreeNodeProps { type?: string parentId?: string isRoot?: boolean + isLoading?: boolean + danger?: boolean } const defaultProps: TreeNodeProps = { @@ -71,6 +73,8 @@ const TreeNode = ({ label = defaultProps.label, level = defaultProps.level, isRoot = defaultProps.isRoot, + isLoading = false, + danger = false, ...props }: TreeNodeProps): React.JSX.Element => { const { token } = useToken() @@ -86,7 +90,7 @@ const TreeNode = ({ } = useContext(TreeContext) const [isExpanded, setIsExpanded] = React.useState(children.length !== 0) const [selectedIds, setSelectedIds] = selectedIdsState! - const treeNodeProps = { id, icon, label, internalKey, level, ...props } + const treeNodeProps = { id, icon, label, internalKey, level, isLoading, isRoot, danger, ...props } const { uploadFile: uploadFileProcessor } = UseFileUploader({ parentId: id }) useEffect(() => { @@ -105,6 +109,10 @@ const TreeNode = ({ classes.push('tree-node--selected') } + if (danger) { + classes.push('tree-node--danger') + } + if (isRoot === true) { classes.push('tree-node--is-root') } diff --git a/assets/js/src/core/components/geo-map/components/address-search-field/address-search-field.tsx b/assets/js/src/core/components/geo-map/components/address-search-field/address-search-field.tsx index d719c843f..161b029b5 100644 --- a/assets/js/src/core/components/geo-map/components/address-search-field/address-search-field.tsx +++ b/assets/js/src/core/components/geo-map/components/address-search-field/address-search-field.tsx @@ -17,6 +17,7 @@ import { useTranslation } from 'react-i18next' import { ERROR_ADDRESS_NOT_FOUND, geoCode } from '@Pimcore/components/geo-map/utils/geocode' import { useAlertModal } from '@Pimcore/components/modal/alert-modal/hooks/use-alert-modal' import { type GeoPoint } from '@Pimcore/components/geo-map/types/geo-types' +import { useSettings } from '@Pimcore/modules/app/settings/hooks/use-settings' interface AddressSearchFieldProps { onSearch: (geoPoint?: GeoPoint) => void @@ -26,13 +27,14 @@ interface AddressSearchFieldProps { export const AddressSearchField = (props: AddressSearchFieldProps): React.JSX.Element => { const { t } = useTranslation() const alertModal = useAlertModal() + const settings = useSettings() const onSearch = async (value: string): Promise => { if (value === '') { props.onSearch(undefined); return } - await geoCode(value) + await geoCode(value, settings.maps.geocoding_url_template as string) .then(props.onSearch) .catch((error: Error) => { if (error.message === ERROR_ADDRESS_NOT_FOUND) { diff --git a/assets/js/src/core/components/geo-map/geo-map.tsx b/assets/js/src/core/components/geo-map/geo-map.tsx index 1c824a032..ef43f81e4 100644 --- a/assets/js/src/core/components/geo-map/geo-map.tsx +++ b/assets/js/src/core/components/geo-map/geo-map.tsx @@ -24,6 +24,7 @@ import { type GeoPoints, type GeoPoint, type GeoType, type GeoBounds } from '@Pi import { addGeoPolyLineToolbar } from '@Pimcore/components/geo-map/toolbar/add-geo-poly-line-toolbar' import { addGeoPolygonToolbar } from '@Pimcore/components/geo-map/toolbar/add-geo-polygon-toolbar' import { addGeoBoundsToolbar } from '@Pimcore/components/geo-map/toolbar/add-geo-bounds-toolbar' +import { useSettings } from '@Pimcore/modules/app/settings/hooks/use-settings' L.Icon.Default.mergeOptions({ iconRetinaUrl: '/bundles/pimcorestudioui/img/leaflet/marker-icon-2x.png', @@ -64,6 +65,7 @@ const GeoMap = forwardRef((props, ref): React.JSX.Elemen const [key, setKey] = useState(0) const [isVisible, setIsVisible] = useState(false) const containerRef = useRef(null) + const settings = useSettings() const geoMapApi: GeoMapAPI = { reset: () => { @@ -94,15 +96,14 @@ const GeoMap = forwardRef((props, ref): React.JSX.Elemen } else { map.setView([props.lat ?? 0, props.lng ?? 0], props.zoom ?? 1) } - - L.tileLayer('https://a.tile.openstreetmap.org/{z}/{x}/{y}.png', { + L.tileLayer(settings.maps.tile_layer_url_template as string, { attribution: '© OpenStreetMap contributors' }).addTo(map) const featureGroup = L.featureGroup().addTo(map) if (props.mode === 'geoPoint') { - addGeoPointToolbar(map, featureGroup, value as GeoPoint, props.onChange, props.disabled) + addGeoPointToolbar(map, featureGroup, settings.maps.reverse_geocoding_url_template as string, value as GeoPoint, props.onChange, props.disabled) } else if (props.mode === 'geoPolyLine') { addGeoPolyLineToolbar(map, featureGroup, value as GeoPoints, props.onChange, props.disabled) } else if (props.mode === 'geoPolygon') { diff --git a/assets/js/src/core/components/geo-map/toolbar/add-geo-point-toolbar.ts b/assets/js/src/core/components/geo-map/toolbar/add-geo-point-toolbar.ts index dbca768d4..addc81864 100644 --- a/assets/js/src/core/components/geo-map/toolbar/add-geo-point-toolbar.ts +++ b/assets/js/src/core/components/geo-map/toolbar/add-geo-point-toolbar.ts @@ -16,7 +16,7 @@ import { reverseGeocode } from '@Pimcore/components/geo-map/utils/geocode' import { type GeoPoint } from '@Pimcore/components/geo-map/types/geo-types' import { convertLatLngToGeoPoint } from '@Pimcore/components/geo-map/utils/lat-lng-convert' -export const addGeoPointToolbar = (leafletMap: L.Map, featureGroup: L.FeatureGroup, geoPoint?: GeoPoint, onChange?: (geoPoint: GeoPoint) => void, disabled?: boolean): void => { +export const addGeoPointToolbar = (leafletMap: L.Map, featureGroup: L.FeatureGroup, reverseGeoCodeUrlTemplate: string, geoPoint?: GeoPoint, onChange?: (geoPoint: GeoPoint) => void, disabled?: boolean): void => { leafletMap.addLayer(featureGroup) const marker = geoPoint !== undefined ? L.marker([geoPoint.latitude, geoPoint.longitude]) : undefined @@ -53,7 +53,7 @@ export const addGeoPointToolbar = (leafletMap: L.Map, featureGroup: L.FeatureGro featureGroup.addLayer(layer) if (featureGroup.getLayers().length === 1) { - await reverseGeocode(layer).catch((error) => { + await reverseGeocode(layer, reverseGeoCodeUrlTemplate).catch((error) => { console.error(error) }) onChange?.(convertLatLngToGeoPoint(layer.getLatLng())) @@ -63,7 +63,7 @@ export const addGeoPointToolbar = (leafletMap: L.Map, featureGroup: L.FeatureGro leafletMap.on('draw:editmove', async function (e) { const layer = e.layer as L.Marker - await reverseGeocode(layer).catch((error) => { + await reverseGeocode(layer, reverseGeoCodeUrlTemplate).catch((error) => { console.error(error) }) onChange?.(convertLatLngToGeoPoint(layer.getLatLng())) diff --git a/assets/js/src/core/components/geo-map/utils/geocode.ts b/assets/js/src/core/components/geo-map/utils/geocode.ts index 2f069082d..7e323b719 100644 --- a/assets/js/src/core/components/geo-map/utils/geocode.ts +++ b/assets/js/src/core/components/geo-map/utils/geocode.ts @@ -16,8 +16,8 @@ import { type GeoPoint } from '@Pimcore/components/geo-map/types/geo-types' export const ERROR_ADDRESS_NOT_FOUND = 'address_not_found' -export const geoCode = async (address: string): Promise => { - const geoCodeUrl = 'https://nominatim.openstreetmap.org/search?q={q}&addressdetails=1&format=json&limit=1'.replace('{q}', encodeURIComponent(address)) +export const geoCode = async (address: string, geoCodeUrlTemplate: string): Promise => { + const geoCodeUrl = geoCodeUrlTemplate.replace('{q}', encodeURIComponent(address)) const response = await fetch(geoCodeUrl) @@ -36,10 +36,10 @@ export const geoCode = async (address: string): Promise => { } } -export const reverseGeocode = async (layerObj: L.Marker): Promise => { - const reverseGeocodeUrl = 'https://nominatim.openstreetmap.org/reverse?format=json&lat={lat}&lon={lng}' +export const reverseGeocode = async (layerObj: L.Marker, reverseGeoCodeUrlTemplate: string): Promise => { + const reverseGeocodeUrl = reverseGeoCodeUrlTemplate .replace('{lat}', layerObj.getLatLng().lat.toString()) - .replace('{lng}', layerObj.getLatLng().lng.toString()) + .replace('{lon}', layerObj.getLatLng().lng.toString()) await fetch(reverseGeocodeUrl).then(async (response: Response | undefined | null) => { if (response === undefined || response === null) { diff --git a/assets/js/src/core/components/grid/columns/default-cell.styles.ts b/assets/js/src/core/components/grid/columns/default-cell.styles.ts index 5da349a7f..11168c73a 100644 --- a/assets/js/src/core/components/grid/columns/default-cell.styles.ts +++ b/assets/js/src/core/components/grid/columns/default-cell.styles.ts @@ -19,7 +19,11 @@ export const useStyle = createStyles(({ token, css }) => { display: flex; width: 100%; height: 100%; - + + &.default-cell--active:not(:focus):not(.default-cell--edit-mode) { + background-color: ${token.controlItemBgActive}; + } + &:focus { outline: 1px solid ${token.colorPrimaryActive}; outline-offset: -1px; diff --git a/assets/js/src/core/components/grid/columns/default-cell.tsx b/assets/js/src/core/components/grid/columns/default-cell.tsx index 4abffb457..02e644a6f 100644 --- a/assets/js/src/core/components/grid/columns/default-cell.tsx +++ b/assets/js/src/core/components/grid/columns/default-cell.tsx @@ -34,6 +34,7 @@ export const DefaultCell = ({ ...props }: DefaultCellProps): React.JSX.Element = const cellType = useMemo(() => column.columnDef.meta?.type ?? 'text', [column.columnDef.meta?.type]) const [isInEditMode, setIsInEditMode] = useState(false) const element = useRef(null) + const [columnWrapperWidth, setColumnWrapperWidth] = useState(undefined) // @todo move to new dynamic type system // const typeRegistry = useInjection(serviceIds['Grid/TypeRegistry']) const { handleArrowNavigation } = useKeyboardNavigation(props) @@ -61,6 +62,7 @@ export const DefaultCell = ({ ...props }: DefaultCellProps): React.JSX.Element = } return useMemo(() => { + const isInAutoWidthColumnEditMode = isInEditMode && column.columnDef.meta?.editable === true && column.columnDef.meta?.autoWidth === true return (
props.onFocus?.({ + rowIndex: row.index, + columnIndex: column.getIndex(), + columnId: column.id + }) } onKeyDown={ onKeyDown } // @todo move to new dynamic type system // onPaste={ onPaste } ref={ element } role='button' + style={ { width: isInAutoWidthColumnEditMode ? columnWrapperWidth : undefined } } tabIndex={ 0 } > - {ComponentRenderer !== null ? ComponentRenderer(props) : <>Cell type not supported} + { ComponentRenderer !== null ? ComponentRenderer(props) : <>Cell type not supported }
) - }, [isInEditMode, props.getValue(), row, row.getIsSelected(), isEditable]) + }, [isInEditMode, props.getValue(), row, row.getIsSelected(), isEditable, props.active]) function getCssClasses (): string[] { const classes: string[] = [] + if (props.active === true) { + classes.push('default-cell--active') + } + if (props.modified === true) { classes.push('default-cell--modified') } @@ -102,6 +115,12 @@ export const DefaultCell = ({ ...props }: DefaultCellProps): React.JSX.Element = return } + if (!isInEditMode) { + if (element.current !== null) { + setColumnWrapperWidth(element.current.offsetWidth) + } + } + if (isEditable && table.options.meta?.onUpdateCellData === undefined) { trackError(new GeneralError('onUpdateCellData is required when using editable cells')) } diff --git a/assets/js/src/core/components/grid/grid-cell/grid-cell.tsx b/assets/js/src/core/components/grid/grid-cell/grid-cell.tsx index d85dc631f..c8ebc73f8 100644 --- a/assets/js/src/core/components/grid/grid-cell/grid-cell.tsx +++ b/assets/js/src/core/components/grid/grid-cell/grid-cell.tsx @@ -13,28 +13,37 @@ import { type Cell, type CellContext, flexRender } from '@tanstack/react-table' import React from 'react' -import { type ExtendedCellContext } from '../grid' +import { type GridCellReference, type ExtendedCellContext } from '../grid' import { type GridContextProviderProps, GridContextProvider } from '../grid-context' +import { + DynamicTypeRegistryProvider +} from '@Pimcore/modules/element/dynamic-types/registry/provider/dynamic-type-registry-provider' export interface GridCellProps { cell: Cell + isActive?: boolean isModified?: boolean + onFocusCell?: (cell: GridCellReference) => void tableElement: GridContextProviderProps['table'] } -export const GridCell = ({ cell, isModified, tableElement }: GridCellProps): React.JSX.Element => { +export const GridCell = ({ cell, isModified, isActive, onFocusCell, tableElement }: GridCellProps): React.JSX.Element => { return ( - -
- {flexRender(cell.column.columnDef.cell, getExtendedCellContext(cell.getContext()))} -
-
+ + +
+ {flexRender(cell.column.columnDef.cell, getExtendedCellContext(cell.getContext()))} +
+
+
) function getExtendedCellContext (context: CellContext): ExtendedCellContext { return { ...context, - modified: isModified + active: isActive, + modified: isModified, + onFocus: onFocusCell } } } diff --git a/assets/js/src/core/components/grid/grid-cell/grid-row.tsx b/assets/js/src/core/components/grid/grid-cell/grid-row.tsx index 261c54067..a6e8f12a6 100644 --- a/assets/js/src/core/components/grid/grid-cell/grid-row.tsx +++ b/assets/js/src/core/components/grid/grid-cell/grid-row.tsx @@ -16,6 +16,7 @@ import React, { useMemo } from 'react' import { GridCell } from './grid-cell' import { type GridContextProviderProps } from '../grid-context' import { type GridProps } from '@Pimcore/types/components/types' +import { type GridCellReference } from '@Pimcore/components/grid/grid' import { Dropdown, type DropdownMenuProps } from '@Pimcore/components/dropdown/dropdown' export interface GridRowProps { @@ -24,6 +25,8 @@ export interface GridRowProps { isSelected?: boolean tableElement: GridContextProviderProps['table'] columns: GridProps['columns'] + activeColumId?: string + onFocusCell?: (cell: GridCellReference) => void contextMenuItems?: DropdownMenuProps['items'] } @@ -68,8 +71,10 @@ const GridRow = ({ row, isSelected, modifiedCells, contextMenuItems = [], ...pro > diff --git a/assets/js/src/core/components/grid/grid.tsx b/assets/js/src/core/components/grid/grid.tsx index 11b240d64..25149907d 100644 --- a/assets/js/src/core/components/grid/grid.tsx +++ b/assets/js/src/core/components/grid/grid.tsx @@ -30,7 +30,7 @@ import { type TableOptions, useReactTable } from '@tanstack/react-table' -import React, { useEffect, useMemo, useRef, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { isEmpty } from 'lodash' import { useStyles } from './grid.styles' import { Resizer } from './resizer/resizer' @@ -39,9 +39,6 @@ import { useTranslation } from 'react-i18next' import { Checkbox, Skeleton } from 'antd' import { GridRow } from './grid-cell/grid-row' import { SortButton, type SortDirection, SortDirections } from '../sort-button/sort-button' -import { - DynamicTypeRegistryProvider -} from '@Pimcore/modules/element/dynamic-types/registry/provider/dynamic-type-registry-provider' import { type GridProps } from '@Pimcore/types/components/types' import trackError, { GeneralError } from '@Pimcore/modules/app/error-handler' import { type DropdownMenuProps } from '@Pimcore/components/dropdown/dropdown' @@ -62,8 +59,16 @@ declare module '@tanstack/react-table' { } } +export interface GridCellReference { + rowIndex: number + columnIndex: number + columnId: string +} + export interface ExtendedCellContext extends CellContext { modified?: boolean + active?: boolean + onFocus?: (cell: GridCellReference) => void } export interface GridContextMenuProps extends Pick { @@ -76,6 +81,9 @@ export const Grid = ({ sorting, manualSorting = false, enableSorting = false, + hideColumnHeaders = false, + highlightActiveCell = false, + onActiveCellChange, enableRowSelection = false, selectedRows = {}, contextMenuItems = [], @@ -85,6 +93,7 @@ export const Grid = ({ const hashId = useCssComponentHash('table') const { styles } = useStyles() const [columnResizeMode] = useState('onEnd') + const [activeCell, setActiveCell] = useState() const [tableAutoWidth, setTableAutoWidth] = useState(props.autoWidth ?? false) const tableElement = useRef(null) const isRowSelectionEnabled = useMemo(() => enableMultipleRowSelection || enableRowSelection, [enableMultipleRowSelection, enableRowSelection]) @@ -92,6 +101,10 @@ export const Grid = ({ const memoModifiedCells = useMemo(() => { return modifiedCells ?? [] }, [JSON.stringify(modifiedCells)]) const autoColumnRef = useRef(null) + useEffect(() => { + onActiveCellChange?.(activeCell) + }, [activeCell]) + useEffect(() => { if (sorting !== undefined) { setInternalSorting(sorting) @@ -196,7 +209,7 @@ export const Grid = ({ for (const column of columns) { if (column.meta?.autoWidth === true) { if (autoWidthColumnFound) { - trackError(new GeneralError('Only one column can have autoWidth set to true')) + trackError(new GeneralError('Only one column can have autoWidth set to true when table autoWidth is enabled.')) } autoWidthColumnFound = true } @@ -206,6 +219,15 @@ export const Grid = ({ const table = useReactTable(tableProps) + const onFocusCell = useCallback((cell: GridCellReference) => { + setActiveCell(cell) + }, []) + + const calculateTableWidth = (): number | string => { + const hasAutoWidthColumn = columns.some(column => column.meta?.autoWidth === true) + return hasAutoWidthColumn ? 'auto' : table.getCenterTotalSize() + } + const renderSortButton = ({ headerColumn }: { headerColumn: Column }): JSX.Element => (
( - -
-
-
-
- +
+
+
+
+
+ { !hideColumnHeaders && ( {table.getHeaderGroups().map(headerGroup => ( @@ -279,8 +301,9 @@ export const Grid = ({ ))} - - {table.getRowModel().rows.length === 0 && ( + )} + + {table.getRowModel().rows.length === 0 && ( - )} - {table.getRowModel().rows.map(row => ( - - ))} - -
-
+ )} + {table.getRowModel().rows.map(row => ( + + ))} + +
-
- ), [table, modifiedCells, data, columns, rowSelection, internalSorting]) +
+ ), [table, modifiedCells, data, columns, rowSelection, internalSorting, highlightActiveCell ? activeCell : undefined]) function getModifiedRow (rowIndex: string): GridProps['modifiedCells'] { return memoModifiedCells.filter(({ rowIndex: rIndex }) => String(rIndex) === String(rowIndex)) ?? [] diff --git a/assets/js/src/core/components/hotspot-image/hotspot-image-container.tsx b/assets/js/src/core/components/hotspot-image/hotspot-image-container.tsx new file mode 100644 index 000000000..bab82d77c --- /dev/null +++ b/assets/js/src/core/components/hotspot-image/hotspot-image-container.tsx @@ -0,0 +1,71 @@ +/** +* Pimcore +* +* This source file is available under two different licenses: +* - Pimcore Open Core License (POCL) +* - Pimcore Commercial License (PCL) +* Full copyright and license information is available in +* LICENSE.md which is distributed with this source code. +* +* @copyright Copyright (c) Pimcore GmbH (http://www.pimcore.org) +* @license https://github.com/pimcore/studio-ui-bundle/blob/1.x/LICENSE.md POCL and PCL +*/ + +import React, { useState } from 'react' +import { HotspotImage, type IHotspot, type IStyleOptions, defaultStyleOptions } from '@Pimcore/components/hotspot-image/hotspot-image' + +interface IHotspotImageContainer { + src: string + styleOptions?: IStyleOptions + items?: IHotspot[] +} + +export const HotspotImageContainer = ({ src, items, styleOptions = defaultStyleOptions }: IHotspotImageContainer): JSX.Element => { + const [hotspots, setHotspots] = useState(items ?? []) + + const addHotspot = (type: string): void => { + const style = styleOptions[type] + const newHotspot: IHotspot = { + id: hotspots.length + 1, + x: 50, + y: 50, + width: style.width, + height: style.height, + type + } + + setHotspots([...hotspots, newHotspot]) + } + + const onRemove = (id: number): void => { + setHotspots(hotspots.filter(h => h.id !== id)) + } + + const onEdit = (id: number): void => { + console.log('Todo show edit view', id) + } + + const onUpdate = (item: IHotspot): void => { + setHotspots(hotspots.map(h => h.id === item.id ? item : h)) + } + + return ( + <> + + +
+ {JSON.stringify(hotspots, null, 2)} +
+ + + + + ) +} diff --git a/assets/js/src/core/components/hotspot-image/hotspot-image.stories.tsx b/assets/js/src/core/components/hotspot-image/hotspot-image.stories.tsx new file mode 100644 index 000000000..6231aad0b --- /dev/null +++ b/assets/js/src/core/components/hotspot-image/hotspot-image.stories.tsx @@ -0,0 +1,47 @@ +/** +* Pimcore +* +* This source file is available under two different licenses: +* - Pimcore Open Core License (POCL) +* - Pimcore Commercial License (PCL) +* Full copyright and license information is available in +* LICENSE.md which is distributed with this source code. +* +* @copyright Copyright (c) Pimcore GmbH (http://www.pimcore.org) +* @license https://github.com/pimcore/studio-ui-bundle/blob/1.x/LICENSE.md POCL and PCL +*/ + +import { type Meta } from '@storybook/react' +import { HotspotImageContainer } from './hotspot-image-container' + +const config: Meta = { + title: 'Components/Data Display/Hotspot Image', + component: HotspotImageContainer, + tags: ['autodocs'] +} + +export default config + +export const _default = { + args: { + src: 'https://144170849.fs1.hubspotusercontent-eu1.net/hub/144170849/hubfs/01-English/01-Website/06-Resources/01-Blog/2024/24-10-Platform-Version-Release-24-3/24-10-Platform-Version-Release-2403-Blog-Header-1.png?width=1440&height=810&name=24-10-Platform-Version-Release-2403-Blog-Header-1.png', + items: [ + { + id: 1, + x: 190, + y: 350, + width: 24, + height: 24, + type: 'marker' + }, + { + id: 2, + x: 647, + y: 106, + width: 150, + height: 150, + type: 'hotspot' + } + ] + } +} diff --git a/assets/js/src/core/components/hotspot-image/hotspot-image.styles.tsx b/assets/js/src/core/components/hotspot-image/hotspot-image.styles.tsx new file mode 100644 index 000000000..4e3cbad12 --- /dev/null +++ b/assets/js/src/core/components/hotspot-image/hotspot-image.styles.tsx @@ -0,0 +1,69 @@ +/** +* Pimcore +* +* This source file is available under two different licenses: +* - Pimcore Open Core License (POCL) +* - Pimcore Commercial License (PCL) +* Full copyright and license information is available in +* LICENSE.md which is distributed with this source code. +* +* @copyright Copyright (c) Pimcore GmbH (http://www.pimcore.org) +* @license https://github.com/pimcore/studio-ui-bundle/blob/1.x/LICENSE.md POCL and PCL +*/ + +import { createStyles } from 'antd-style' + +export const useStyle = createStyles(({ token, css }) => { + return { + hotspotImage: css` + position: relative; + width: fit-content; + height: auto; + margin: 0 auto; + + .hotspot-image__image { + width: auto; + max-width: 100%; + height: auto; + display: block; + } + + .hotspot-image__item { + border-radius: ${token.borderRadius}px; + color: ${token.colorPrimary}; + background: rgba(215, 199, 236, 0.40); + border: 3px dashed ${token.colorPrimary}; + border-radius: ${token.borderRadius}px; + user-select: none; + cursor: nwse-resize; + + &:before { + content: ''; + position: absolute; + right: 6px; + bottom: 6px; + left: 6px; + top: 6px; + cursor: move; + } + } + + .hotspot-image__item--marker { + cursor: move; + border-width: 1px; + padding: 0; + display: flex; + justify-content: center; + align-items: center; + } + + .hotspot-image__popover { + } + `, + Popover: css` + .ant-popover-inner { + padding: ${token.paddingXS}px; + } + ` + } +}) diff --git a/assets/js/src/core/components/hotspot-image/hotspot-image.tsx b/assets/js/src/core/components/hotspot-image/hotspot-image.tsx new file mode 100644 index 000000000..3fbeb6125 --- /dev/null +++ b/assets/js/src/core/components/hotspot-image/hotspot-image.tsx @@ -0,0 +1,246 @@ +/** +* Pimcore +* +* This source file is available under two different licenses: +* - Pimcore Open Core License (POCL) +* - Pimcore Commercial License (PCL) +* Full copyright and license information is available in +* LICENSE.md which is distributed with this source code. +* +* @copyright Copyright (c) Pimcore GmbH (http://www.pimcore.org) +* @license https://github.com/pimcore/studio-ui-bundle/blob/1.x/LICENSE.md POCL and PCL +*/ + +import React, { useState, useRef, useEffect, type MouseEvent } from 'react' +import { useStyle } from './hotspot-image.styles' +import { Icon } from '@Pimcore/components/icon/icon' +import { Popover } from 'antd' +import { IconTextButton } from '@Pimcore/components/icon-text-button/icon-text-button' +import { IconButton } from '@Pimcore/components/icon-button/icon-button' +import { + convertHotspotsToPixel +} from '@Pimcore/components/hotspot-image/utils/calculate-dimensions' +import { dragItem } from '@Pimcore/components/hotspot-image/utils/drag' +import { type Coordinates, type Rectangle } from '@Pimcore/components/hotspot-image/types/types' +import { resizeItem } from '@Pimcore/components/hotspot-image/utils/resize' +import { Tooltip } from '@Pimcore/components/tooltip/tooltip' +import { useTranslation } from 'react-i18next' + +export interface IStyleOptions { + hotspot: { + width: number + height: number + resizeBorderSize: number + minSize: number + icon: any + } + marker: { + width: number + height: number + icon: any + } +} + +export const defaultStyleOptions = { + hotspot: { + width: 10, + height: 10, + resizeBorderSize: 10, + minSize: 24, + icon: null + }, + marker: { + width: 24, + height: 24, + marginLeft: -12, + marginTop: -19, + icon: 'location-marker' + } +} + +export interface IHotspot { + id: number + x: number + y: number + width: number + height: number + type: string +} + +interface IHotspotImage { + src: string + styleOptions?: IStyleOptions + data?: IHotspot[] + onRemove: (id: number) => void + onEdit?: (id: number) => void + onClone?: (id: number) => void + onUpdate: (item: IHotspot) => void +} + +export const HotspotImage = ({ src, data, styleOptions = defaultStyleOptions, onRemove, onEdit, onClone, onUpdate }: IHotspotImage): JSX.Element => { + const { styles } = useStyle() + const [imageLoaded, setImageLoaded] = useState(false) + const imageRef = useRef(null) + const { t } = useTranslation() + + const [items, setItems] = useState(data ?? []) + useEffect((): void => { + setItems(data ?? []) + }, [data?.length]) + + useEffect(() => { + setImageLoaded(false) + }, [src]) + + const [selectedId, setSelectedId] = useState(null) + const [dragging, setDragging] = useState(false) + const [resizeDirection, setResizeDirection] = useState(null) + const [dragStart, setDragStart] = useState({ x: 0, y: 0 }) + const [resizeStart, setResizeStart] = useState({ width: 0, height: 0, x: 0, y: 0 }) + const [popoverOpen, setPopoverOpen] = useState(false) + const containerRef = useRef(null) + + const handleMouseDown = (evt: MouseEvent, hotspot: IHotspot): void => { + const rect = evt.currentTarget.getBoundingClientRect() + const mouseX = evt.clientX - rect.left + const mouseY = evt.clientY - rect.top + + const nearLeftEdge = mouseX < styleOptions[hotspot.type].resizeBorderSize + const nearRightEdge = mouseX > rect.width - styleOptions[hotspot.type].resizeBorderSize + const nearTopEdge = mouseY < styleOptions[hotspot.type].resizeBorderSize + const nearBottomEdge = mouseY > rect.height - styleOptions[hotspot.type].resizeBorderSize + + if (hotspot.type === 'hotspot' && (nearLeftEdge || nearRightEdge || nearTopEdge || nearBottomEdge)) { + let direction = '' + if (nearTopEdge) direction += 'n' + if (nearBottomEdge) direction += 's' + if (nearLeftEdge) direction += 'w' + if (nearRightEdge) direction += 'e' + + setResizeDirection(direction) + setResizeStart({ x: evt.clientX, y: evt.clientY, width: hotspot.width, height: hotspot.height }) + } else { + setDragging(true) + setDragStart({ x: mouseX, y: mouseY }) + } + + setPopoverOpen(false) + setSelectedId(hotspot.id) + evt.stopPropagation() + } + + const handleMouseMove = (evt: MouseEvent): void => { + if (selectedId === null || containerRef.current === null) return + const containerBounds = containerRef.current.getBoundingClientRect() + const hotspotIndex = items.findIndex(h => h.id === selectedId) + const dx = evt.clientX - resizeStart.x + const dy = evt.clientY - resizeStart.y + + if (dragging) { + setItems(dragItem(evt, dragStart, containerBounds, items, hotspotIndex, Number(styleOptions[items[hotspotIndex].type].marginLeft), Number(styleOptions[items[hotspotIndex].type].marginTop))) + } else if (resizeDirection !== null) { + setItems(resizeItem(evt, resizeStart, resizeDirection, containerBounds, items, hotspotIndex, Number(styleOptions[items[hotspotIndex].type].minSize), dx, dy)) + } + } + + const handleMouseUp = (): void => { + setDragging(false) + setResizeDirection(null) + + const updatedItem = items.find(h => h.id === selectedId) + if (updatedItem !== undefined) { + onUpdate(updatedItem) + } + } + + return ( +
+ { + if (imageRef.current !== null) { + setImageLoaded(true) + } + } } + ref={ imageRef } + src={ src } + /> + { imageLoaded && containerRef.current !== null && ( + convertHotspotsToPixel(items, containerRef.current.getBoundingClientRect()).map(hotspot => ( + + {onEdit !== undefined + ? ( + { onEdit(hotspot.id) } } + type="default" + >Todo edit + ) + : null} + + + { onRemove(hotspot.id) } } + type={ 'link' } + /> + + + {onClone !== undefined + ? ( + + { onClone(hotspot.id) } } + type={ 'link' } + /> + + ) + : null} + + + } + key={ hotspot.id } + onOpenChange={ (open) => { setPopoverOpen(open) } } + open={ popoverOpen && selectedId === hotspot.id } + overlayClassName={ [styles.Popover].join(' ') } + trigger={ ['contextMenu'] } + > + + + )) + )} +
+ ) +} diff --git a/assets/js/src/core/components/hotspot-image/types/types.ts b/assets/js/src/core/components/hotspot-image/types/types.ts new file mode 100644 index 000000000..381a46bf9 --- /dev/null +++ b/assets/js/src/core/components/hotspot-image/types/types.ts @@ -0,0 +1,24 @@ +/** +* Pimcore +* +* This source file is available under two different licenses: +* - Pimcore Open Core License (POCL) +* - Pimcore Commercial License (PCL) +* Full copyright and license information is available in +* LICENSE.md which is distributed with this source code. +* +* @copyright Copyright (c) Pimcore GmbH (http://www.pimcore.org) +* @license https://github.com/pimcore/studio-ui-bundle/blob/1.x/LICENSE.md POCL and PCL +*/ + +export interface Coordinates { + x: number + y: number +} + +interface Dimensions { + width: number + height: number +} + +export type Rectangle = Coordinates & Dimensions diff --git a/assets/js/src/core/components/hotspot-image/utils/calculate-dimensions.ts b/assets/js/src/core/components/hotspot-image/utils/calculate-dimensions.ts new file mode 100644 index 000000000..f10646479 --- /dev/null +++ b/assets/js/src/core/components/hotspot-image/utils/calculate-dimensions.ts @@ -0,0 +1,46 @@ +/** +* Pimcore +* +* This source file is available under two different licenses: +* - Pimcore Open Core License (POCL) +* - Pimcore Commercial License (PCL) +* Full copyright and license information is available in +* LICENSE.md which is distributed with this source code. +* +* @copyright Copyright (c) Pimcore GmbH (http://www.pimcore.org) +* @license https://github.com/pimcore/studio-ui-bundle/blob/1.x/LICENSE.md POCL and PCL +*/ + +import { type IHotspot } from '@Pimcore/components/hotspot-image/hotspot-image' + +export const convertHotspotToPixel = (hotspot: IHotspot, containerBounds: DOMRect): IHotspot => { + return { + ...hotspot, + x: percentToPixel(hotspot.x, containerBounds.width), + y: percentToPixel(hotspot.y, containerBounds.height), + width: hotspot.type === 'marker' ? hotspot.width : percentToPixel(hotspot.width, containerBounds.width), + height: hotspot.type === 'marker' ? hotspot.height : percentToPixel(hotspot.height, containerBounds.height) + } +} + +export const convertHotspotsToPixel = (hotspots: IHotspot[], containerBounds: DOMRect): IHotspot[] => { + return hotspots.map(hotspot => convertHotspotToPixel(hotspot, containerBounds)) +} + +export const convertHotspotToPercent = (hotspot: IHotspot, containerBounds: DOMRect): IHotspot => { + return { + ...hotspot, + x: pixelToPercent(hotspot.x, containerBounds.width), + y: pixelToPercent(hotspot.y, containerBounds.height), + width: hotspot.type === 'marker' ? hotspot.width : pixelToPercent(hotspot.width, containerBounds.width), + height: hotspot.type === 'marker' ? hotspot.height : pixelToPercent(hotspot.height, containerBounds.height) + } +} + +const percentToPixel = (percent: number, dimension: number): number => { + return (dimension * percent) / 100 +} + +const pixelToPercent = (pixel: number, dimension: number): number => { + return (pixel * 100) / dimension +} diff --git a/assets/js/src/core/components/hotspot-image/utils/drag.ts b/assets/js/src/core/components/hotspot-image/utils/drag.ts new file mode 100644 index 000000000..24363da3a --- /dev/null +++ b/assets/js/src/core/components/hotspot-image/utils/drag.ts @@ -0,0 +1,36 @@ +/** +* Pimcore +* +* This source file is available under two different licenses: +* - Pimcore Open Core License (POCL) +* - Pimcore Commercial License (PCL) +* Full copyright and license information is available in +* LICENSE.md which is distributed with this source code. +* +* @copyright Copyright (c) Pimcore GmbH (http://www.pimcore.org) +* @license https://github.com/pimcore/studio-ui-bundle/blob/1.x/LICENSE.md POCL and PCL +*/ + +import { type IHotspot } from '@Pimcore/components/hotspot-image/hotspot-image' +import { + convertHotspotToPercent, + convertHotspotToPixel +} from '@Pimcore/components/hotspot-image/utils/calculate-dimensions' +import type { MouseEvent } from 'react' +import { type Coordinates } from '@Pimcore/components/hotspot-image/types/types' + +export const dragItem = ( + evt: MouseEvent, + dragStart: Coordinates, + containerBounds: DOMRect, + hotspots: IHotspot[], + hotspotIndex: number, + marginLeft: number, + marginTop: number +): IHotspot[] => { + const hotspot = convertHotspotToPixel(hotspots[hotspotIndex], containerBounds) + const newX = Math.min(containerBounds.width - hotspot.width, Math.max(0, evt.clientX - containerBounds.left - dragStart.x)) - marginLeft + const newY = Math.min(containerBounds.height - hotspot.height, Math.max(0, evt.clientY - containerBounds.top - dragStart.y)) - marginTop + + return hotspots.map((h, i) => i === hotspotIndex ? convertHotspotToPercent({ ...h, x: newX, y: newY, width: hotspot.width, height: hotspot.height }, containerBounds) : h) +} diff --git a/assets/js/src/core/components/hotspot-image/utils/resize.ts b/assets/js/src/core/components/hotspot-image/utils/resize.ts new file mode 100644 index 000000000..905c101f0 --- /dev/null +++ b/assets/js/src/core/components/hotspot-image/utils/resize.ts @@ -0,0 +1,83 @@ +/** +* Pimcore +* +* This source file is available under two different licenses: +* - Pimcore Open Core License (POCL) +* - Pimcore Commercial License (PCL) +* Full copyright and license information is available in +* LICENSE.md which is distributed with this source code. +* +* @copyright Copyright (c) Pimcore GmbH (http://www.pimcore.org) +* @license https://github.com/pimcore/studio-ui-bundle/blob/1.x/LICENSE.md POCL and PCL +*/ + +import type { MouseEvent } from 'react' +import { + convertHotspotToPercent, + convertHotspotToPixel +} from '@Pimcore/components/hotspot-image/utils/calculate-dimensions' +import { type IHotspot } from '@Pimcore/components/hotspot-image/hotspot-image' +import { type Rectangle } from '@Pimcore/components/hotspot-image/types/types' + +export const resizeItem = ( + evt: MouseEvent, + resizeStart: Rectangle, + resizeDirection: string | null, + containerBounds: DOMRect, + hotspots: IHotspot[], + hotspotIndex: number, + minSize: number, + dx: number, + dy: number +): IHotspot[] => { + const hotspot = convertHotspotToPixel(hotspots[hotspotIndex], containerBounds) + let newWidth = resizeStart.width + let newHeight = resizeStart.height + let newX = hotspot.x + let newY = hotspot.y + + if (resizeDirection?.includes('w') === true) { + ({ newWidth, newX } = handleWestResize(resizeStart, hotspot, dx, evt, containerBounds, minSize)) + } + if (resizeDirection?.includes('e') === true) { + newWidth = Math.min(containerBounds.width - hotspot.x, Math.max(minSize, resizeStart.width + dx)) + } + if (resizeDirection?.includes('n') === true) { + ({ newHeight, newY } = handleNorthResize(resizeStart, hotspot, dy, evt, containerBounds, minSize)) + } + if (resizeDirection?.includes('s') === true) { + newHeight = Math.max(minSize, resizeStart.height + dy) + } + + return hotspots.map((h, i) => i === hotspotIndex + ? convertHotspotToPercent({ + ...h, + x: newX, + y: newY, + width: newWidth, + height: newHeight + }, containerBounds) + : h) +} + +const handleWestResize = (resizeStart: Rectangle, hotspot: IHotspot, dx: number, evt: MouseEvent, containerBounds: DOMRect, minSize: number): { newWidth: number, newX: number } => { + const newWidth = Math.max(minSize, resizeStart.width - dx) + let newX = Math.min(hotspot.x + resizeStart.width - minSize, evt.clientX - containerBounds.left) + + if (newWidth === minSize) { + newX = hotspot.x + hotspot.width - minSize + } + + return { newWidth, newX } +} + +const handleNorthResize = (resizeStart: Rectangle, hotspot: IHotspot, dy: number, evt: MouseEvent, containerBounds: DOMRect, minSize: number): { newHeight: number, newY: number } => { + const newHeight = Math.max(minSize, resizeStart.height - dy) + let newY = Math.min(hotspot.y + resizeStart.height - minSize, evt.clientY - containerBounds.top) + + if (newHeight === minSize) { + newY = hotspot.y + hotspot.height - minSize + } + + return { newHeight, newY } +} diff --git a/assets/js/src/core/components/image-preview/image-preview.styles.tsx b/assets/js/src/core/components/image-preview/image-preview.styles.tsx index b4af6a3f8..79430b425 100644 --- a/assets/js/src/core/components/image-preview/image-preview.styles.tsx +++ b/assets/js/src/core/components/image-preview/image-preview.styles.tsx @@ -41,6 +41,15 @@ export const useStyle = createStyles(({ token, css }) => { } } + `, + hotspotButton: css` + position: absolute; + top: ${token.paddingXXS}px; + left: ${token.paddingXXS}px; + // todo: remove this when loading animation in button is fixed + & > div { + display:none; + } ` } }) diff --git a/assets/js/src/core/components/image-preview/image-preview.tsx b/assets/js/src/core/components/image-preview/image-preview.tsx index 7ed785226..0d8ae5038 100644 --- a/assets/js/src/core/components/image-preview/image-preview.tsx +++ b/assets/js/src/core/components/image-preview/image-preview.tsx @@ -22,6 +22,10 @@ import { Spin } from '@Pimcore/components/spin/spin' import { Flex } from '@Pimcore/components/flex/flex' import { type DropdownProps } from '@Pimcore/components/dropdown/dropdown' import { ImagePreviewDropdown } from '@Pimcore/components/image-preview/components/dropdown/dropdown' +import { Icon } from '@Pimcore/components/icon/icon' +import { Button } from '@Pimcore/components/button/button' +import { Tooltip } from '@Pimcore/components/tooltip/tooltip' +import { useTranslation } from 'react-i18next' interface ImagePreviewProps { src?: string @@ -33,14 +37,16 @@ interface ImagePreviewProps { style?: CSSProperties bordered?: boolean dropdownItems?: DropdownProps['menu']['items'] + onHotspotsDataButtonClick?: () => void } -export const ImagePreview = forwardRef(function ImagePreview ({ src, assetId, assetType, width, height, className, style, dropdownItems, bordered = false }: ImagePreviewProps, ref: MutableRefObject): React.JSX.Element { +export const ImagePreview = forwardRef(function ImagePreview ({ src, assetId, assetType, width, height, className, style, dropdownItems, bordered = false, onHotspotsDataButtonClick }: ImagePreviewProps, ref: MutableRefObject): React.JSX.Element { const [key, setKey] = React.useState(0) const [thumbnailDimensions, setThumbnailDimensions] = React.useState({ width: 0, height: 0 }) const { getStateClasses } = useDroppable() const { styles } = useStyle() const wrapperRef = React.useRef(null) + const { t } = useTranslation() const getAssetPreviewUrl = (): string | undefined => { const { width, height } = thumbnailDimensions @@ -104,6 +110,22 @@ export const ImagePreview = forwardRef(function ImagePreview ({ src, assetId, as ) } + + { onHotspotsDataButtonClick !== undefined && ( + +