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,60 @@
import './style.scss';
import classnames from 'classnames/dedupe';
import { Button, Tooltip } from '@wordpress/components';
/**
* Component Class
*
* @param props
*/
export default function AlignControl(props) {
const { options, value, onChange } = props;
let controlsArray = ['left', 'center', 'right'];
if (options === 'box') {
controlsArray = [
'top-left',
'top-center',
'top-right',
...controlsArray,
'bottom-left',
'bottom-center',
'bottom-right',
];
}
return (
<div className="vpf-component-align-control">
{controlsArray.map((align) => {
const alignTitle = align
.split('-')
.map((word) => {
return word.slice(0, 1).toUpperCase() + word.slice(1);
})
.join(' ');
return (
<Tooltip key={`align-${align}`} text={alignTitle}>
<Button
className={classnames(
`vpf-component-align-control-${align}`,
value === align
? 'vpf-component-align-control-active'
: ''
)}
onClick={() => {
onChange(align);
}}
>
<span />
<span />
<span />
</Button>
</Tooltip>
);
})}
</div>
);
}

View File

@ -0,0 +1,82 @@
.vpf-component-align-control {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
max-width: 160px;
border: 1px solid #d7dade;
button {
position: relative;
display: flex;
flex-direction: column;
gap: 5px;
align-items: center;
justify-content: center;
height: 42px;
padding: 10px 16px;
> span {
width: 20px;
height: 2px;
background-color: #ccc;
opacity: 0;
&:nth-child(1) {
width: 12px;
}
&:nth-child(2) {
width: 18px;
}
&:nth-child(3) {
width: 8px;
}
}
&::after {
position: absolute;
top: calc(50% - 1.5px);
left: calc(50% - 1.5px);
display: block;
width: 3px;
height: 3px;
content: "";
background-color: #ccc;
}
&:hover,
&:focus {
> span {
background-color: #ccc;
opacity: 1;
}
&::after {
opacity: 0;
}
}
&.vpf-component-align-control-active {
> span {
background-color: var(--wp-admin-theme-color);
opacity: 1;
}
&::after {
opacity: 0;
}
}
&.vpf-component-align-control-left,
&.vpf-component-align-control-top-left,
&.vpf-component-align-control-bottom-left {
align-items: flex-start;
}
&.vpf-component-align-control-right,
&.vpf-component-align-control-top-right,
&.vpf-component-align-control-bottom-right {
align-items: flex-end;
}
}
}

View File

@ -0,0 +1,127 @@
import './style.scss';
import { SelectControl, TextControl } from '@wordpress/components';
import { Component } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
const DEFAULT_RATIOS = {
'': __('Auto', 'visual-portfolio'),
'16:9': __('Wide 16:9', 'visual-portfolio'),
'21:9': __('Ultra Wide 21:9', 'visual-portfolio'),
'4:3': __('TV 4:3', 'visual-portfolio'),
'3:2': __('Classic Film 3:2', 'visual-portfolio'),
custom: __('Custom', 'visual-portfolio'),
};
/**
* Component Class
*/
export default class AspectRatio extends Component {
constructor(...args) {
super(...args);
this.state = {
isCustom: typeof DEFAULT_RATIOS[this.props.value] === 'undefined',
};
this.updatePart = this.updatePart.bind(this);
}
/**
* Parse aspect ratio string.
*
* @param {string} val - aspect ratio string.
*
* @return {Array}
*/
parseParts(val) {
let left = '';
let right = '';
if (val && /:/g.test(val)) {
const parts = val.split(':');
[left, right] = parts;
}
return [left, right];
}
/**
* Update part of aspect ration string
*
* @param {string} val - new value for ratio part
* @param {boolean} left - left part of aspect ratio
*/
updatePart(val, left = true) {
const { value, onChange } = this.props;
const parse = this.parseParts(value);
if (!val || !parse[0] || !parse[1]) {
return;
}
if (left) {
parse[0] = val;
} else {
parse[1] = val;
}
onChange(`${parse[0]}:${parse[1]}`);
}
render() {
const { value, onChange } = this.props;
const { isCustom } = this.state;
const parts = this.parseParts(value);
return (
<div className="vpf-component-aspect-ratio">
<SelectControl
value={isCustom ? 'custom' : value}
onChange={(val) => {
if (val === 'custom') {
this.setState({
isCustom: true,
});
if (!value) {
onChange('3:4');
}
} else {
this.setState({
isCustom: false,
});
onChange(val);
}
}}
options={Object.keys(DEFAULT_RATIOS).map((ratio) => ({
label: DEFAULT_RATIOS[ratio],
value: ratio,
}))}
/>
{isCustom ? (
<div className="vpf-component-aspect-ratio-custom">
<TextControl
label={__('Width', 'visual-portfolio')}
type="number"
value={parts[0]}
onChange={(val) => this.updatePart(val, true)}
/>
<TextControl
label={__('Height', 'visual-portfolio')}
type="number"
value={parts[1]}
onChange={(val) => this.updatePart(val, false)}
/>
</div>
) : (
''
)}
</div>
);
}
}

View File

@ -0,0 +1,9 @@
.vpf-component-aspect-ratio-custom {
display: grid;
grid-template-columns: 1fr 1fr;
grid-gap: 10px;
> .components-base-control {
margin-bottom: 0;
}
}

View File

@ -0,0 +1,250 @@
import './style.scss';
import { Component } from '@wordpress/element';
const { navigator } = window;
// generate dom tree.
function getNodeTree(node) {
if (node && node.hasChildNodes()) {
const children = [];
for (let j = 0; j < node.childNodes.length; j += 1) {
children.push(getNodeTree(node.childNodes[j]));
}
return {
classList: node.classList,
nodeName: node.nodeName,
children,
};
}
return false;
}
/**
* Component Class
*/
export default class ClassesTree extends Component {
constructor(...args) {
super(...args);
this.state = {
nodes: false,
};
this.onFrameLoad = this.onFrameLoad.bind(this);
this.maybeFindIframe = this.maybeFindIframe.bind(this);
this.updateTreeData = this.updateTreeData.bind(this);
}
componentDidMount() {
this.maybeFindIframe();
}
componentDidUpdate() {
this.maybeFindIframe();
}
componentWillUnmount() {
if (!this.iframePreview) {
return;
}
this.iframePreview.removeEventListener('load', this.onFrameLoad);
}
/**
* On frame load event.
*/
onFrameLoad() {
if (!this.iframePreview.contentWindow) {
return;
}
// this.frameWindow = this.iframePreview.contentWindow;
this.frameJQuery = this.iframePreview.contentWindow.jQuery;
if (this.frameJQuery) {
this.$framePortfolio = this.frameJQuery('.vp-portfolio');
}
this.updateTreeData();
}
maybeFindIframe() {
if (this.iframePreview) {
return;
}
const { clientId } = this.props;
const iframePreview = document.querySelector(
`#block-${clientId} iframe`
);
if (iframePreview) {
this.iframePreview = iframePreview;
this.iframePreview.addEventListener('load', this.onFrameLoad);
this.onFrameLoad();
}
}
updateTreeData() {
if (this.$framePortfolio) {
this.setState({
nodes: getNodeTree(this.$framePortfolio[0]),
});
}
}
render() {
if (!this.iframePreview) {
return null;
}
return (
<div className="vpf-component-classes-tree">
<ClassesTree.TreeItem
node={this.state.nodes}
skipNodeByClass={/vp-portfolio__item-popup/}
collapseByClass={
/^(vp-portfolio__preloader-wrap|vp-portfolio__filter-wrap|vp-portfolio__sort-wrap|vp-portfolio__items-wrap|vp-portfolio__pagination-wrap)$/
}
skipClass={/vp-uid-/}
/>
</div>
);
}
}
ClassesTree.TreeItem = class TreeItem extends Component {
constructor(...args) {
super(...args);
this.state = {
isCollapsed: null,
};
this.isCollapsed = this.isCollapsed.bind(this);
}
isCollapsed() {
const { node, collapseByClass } = this.props;
let { isCollapsed } = this.state;
// check if collapsed by default.
if (
isCollapsed === null &&
node &&
node.classList &&
node.classList.length
) {
node.classList.forEach((className) => {
if (collapseByClass && collapseByClass.test(className)) {
isCollapsed = true;
}
});
}
return isCollapsed;
}
render() {
const { node, skipNodeByClass, skipClass } = this.props;
if (!node || !node.children.length) {
return null;
}
const classes = [];
let skip = false;
// Classes.
if (node.classList && node.classList.length) {
node.classList.forEach((className) => {
if (!skipClass || !skipClass.test(className)) {
classes.push(className);
}
// Skip?
if (skipNodeByClass && skipNodeByClass.test(className)) {
skip = true;
}
});
}
if (skip) {
return null;
}
return (
<ul>
<li
className={`vpf-component-classes-tree-node ${
this.isCollapsed() ? '' : 'is-collapsed'
}`}
>
<div>
{node.children.length ? (
<button
type="button"
className="vpf-component-classes-tree-node-collapse"
onClick={() =>
this.setState({
isCollapsed: !this.isCollapsed(),
})
}
/>
) : (
''
)}
&lt;
{node.nodeName.toLowerCase()}
{classes.length ? (
<>
{' class="'}
{classes.map((className) => (
<button
key={className}
type="button"
className="vpf-component-classes-tree-node-class"
onClick={() => {
navigator.clipboard.writeText(
className
);
}}
>
{className}
</button>
))}
{'" '}
</>
) : (
''
)}
&gt;
</div>
</li>
{node.children.length && this.isCollapsed()
? node.children.map((childNode) => {
if (childNode) {
return (
// eslint-disable-next-line react/jsx-key
<li className="vpf-component-classes-tree-child">
<ClassesTree.TreeItem
{...this.props}
node={childNode}
/>
</li>
);
}
return null;
})
: ''}
</ul>
);
}
};

View File

@ -0,0 +1,126 @@
@use "sass:color";
$dom_tree_color_bg_node_class: #dcdfe6 !default;
$dom_tree_color_text_node_class: #595d67 !default;
/**
* DOM Tree
*/
.vpf-component-classes-tree-help code {
display: inline-block;
padding: 2px 6px;
line-height: 1.2;
color: $dom_tree_color_text_node_class;
cursor: pointer;
background-color: $dom_tree_color_bg_node_class;
border-radius: 3px;
transition: 0.15s background-color, 0.15s color;
&:hover {
color: color.adjust($dom_tree_color_text_node_class, $lightness: -20%);
background-color: color.adjust($dom_tree_color_bg_node_class, $lightness: -10%);
}
}
.vpf-component-classes-tree {
padding: 15px;
padding-top: 1px;
padding-bottom: 0;
padding-left: 0;
font-family: monospace;
color: #67666d;
background-color: #f9f9fa;
border: 1px solid #ddd;
.spinner {
float: none;
margin-top: 15px;
margin-bottom: 15px;
}
ul {
position: relative;
padding-left: 15px;
list-style: none;
}
// Node
.vpf-component-classes-tree-node > div {
position: relative;
display: inline-block;
line-height: 1.6;
}
// Node Class
.vpf-component-classes-tree-node-class {
display: inline;
padding: 2px 6px;
line-height: 1.2;
color: $dom_tree_color_text_node_class;
cursor: pointer;
background-color: $dom_tree_color_bg_node_class;
border: none;
border-radius: 3px;
transition: 0.15s background-color, 0.15s color;
&:hover {
color: color.adjust($dom_tree_color_text_node_class, $lightness: -20%);
background-color: color.adjust($dom_tree_color_bg_node_class, $lightness: -10%);
}
+ .vpf-component-classes-tree-node-class {
margin-left: 5px;
}
}
// Collapse
.vpf-component-classes-tree-node-collapse {
position: relative;
top: -1px;
display: inline-block;
padding: 0;
margin-right: 5px;
cursor: pointer;
border: none;
border-top: 6px solid;
border-right: 4px solid transparent;
border-left: 4px solid transparent;
&::after {
position: absolute;
top: -12px;
right: -10px;
bottom: -7px;
left: -10px;
display: block;
content: "";
}
}
.is-collapsed .vpf-component-classes-tree-node-collapse {
transform: rotate(-90deg);
}
// Hover
.vpf-component-classes-tree-child {
position: relative;
&::before {
position: absolute;
top: -3px;
right: 0;
bottom: -3px;
left: 8px;
display: block;
content: "";
background-color: #f0f0f1;
border-radius: 4px;
opacity: 0;
transition: 0.2s opacity;
}
}
.vpf-component-classes-tree-node:hover ~ .vpf-component-classes-tree-child::before {
opacity: 1;
}
}

View File

@ -0,0 +1,88 @@
// It is required to load react-ace first.
// eslint-disable-next-line simple-import-sort/imports
import AceEditor from 'react-ace';
import 'ace-builds/src-noconflict/mode-css';
import 'ace-builds/src-noconflict/mode-javascript';
import 'ace-builds/src-noconflict/snippets/css';
import 'ace-builds/src-noconflict/snippets/javascript';
import 'ace-builds/src-noconflict/snippets/text';
import 'ace-builds/src-noconflict/ext-language_tools';
import './style.scss';
import { Component } from '@wordpress/element';
/**
* Component Class
*/
export default class CodeEditor extends Component {
constructor(...args) {
super(...args);
this.state = {
codePlaceholder: this.props.codePlaceholder,
};
this.maybeRemovePlaceholder = this.maybeRemovePlaceholder.bind(this);
}
componentDidMount() {
this.maybeRemovePlaceholder();
}
/**
* Remove placeholder after first change.
*/
maybeRemovePlaceholder() {
const { value } = this.props;
const { codePlaceholder } = this.state;
if (value && codePlaceholder) {
this.setState({ codePlaceholder: '' });
}
}
render() {
const { value, onChange, mode, maxLines, minLines } = this.props;
const { codePlaceholder } = this.state;
return (
<AceEditor
className="vpf-component-code-editor"
theme="textmate"
onLoad={(editor) => {
editor.renderer.setScrollMargin(16, 16, 16, 16);
editor.renderer.setPadding(16);
}}
fontSize={12}
showPrintMargin
showGutter
highlightActiveLine={false}
width="100%"
setOptions={{
enableBasicAutocompletion: true,
enableLiveAutocompletion: true,
enableSnippets: true,
showLineNumbers: true,
printMargin: false,
tabSize: 2,
}}
editorProps={{
$blockScrolling: Infinity,
}}
value={value || codePlaceholder}
onChange={(val) => {
onChange(val === codePlaceholder ? '' : val);
this.maybeRemovePlaceholder();
}}
mode={mode}
maxLines={maxLines}
minLines={minLines}
/>
);
}
}

View File

@ -0,0 +1,37 @@
@import "../../variables";
.vpf-component-code-editor {
&.ace_editor {
width: 100%;
line-height: 1.45;
background-color: $light-gray-100;
border-radius: 3px;
box-shadow: 0 0 0 1px $light-gray-400;
.ace_gutter {
background-color: $light-gray-400;
}
.ace_gutter-cell {
background-color: $light-gray-600;
}
}
.ace_tooltip {
padding: 6px 10px;
background: none;
background-color: #fff;
border: 1px solid #e4e4e4;
border-radius: 3px;
box-shadow: 0 2px 10px 0 rgba(0, 0, 0, 5%);
}
.ace_hidden-cursors {
opacity: 0;
}
}
.vpf-control-pre-custom-css {
padding: 20px;
background-color: $light-gray-400;
}

View File

@ -0,0 +1,66 @@
import './style.scss';
import classnames from 'classnames/dedupe';
import { Button } from '@wordpress/components';
import { Fragment, useState } from '@wordpress/element';
/**
* Component Class
*
* @param props
*/
export default function CollapseControl(props) {
const { children, options, initialOpen } = props;
const [collapsed, setCollapsed] = useState(initialOpen);
return (
<div className="vpf-component-collapse-control">
{options.map((option) => {
return (
<Fragment key={option.category}>
<Button
onClick={() => {
setCollapsed(
option.category === collapsed
? ''
: option.category
);
}}
className={classnames(
'vpf-component-collapse-control-toggle',
option.category === collapsed
? 'vpf-component-collapse-control-active'
: ''
)}
>
<span>{option.title}</span>
<svg
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
className="components-panel__arrow"
aria-hidden="true"
focusable="false"
>
<path d="M17.5 11.6L12 16l-5.5-4.4.9-1.2L12 14l4.5-3.6 1 1.2z" />
</svg>
</Button>
<div
className={classnames(
'vpf-component-collapse-control-content',
option.category === collapsed
? 'vpf-component-collapse-control-content-active'
: ''
)}
>
{children(option)}
</div>
</Fragment>
);
})}
</div>
);
}

View File

@ -0,0 +1,49 @@
.vpf-component-collapse-control {
margin-top: 0;
margin-top: -10px;
margin-right: -16px;
margin-bottom: -10px;
margin-left: -16px;
// Toggle.
.vpf-component-collapse-control-toggle {
display: flex;
gap: 6px;
width: 100%;
padding: 10px 16px;
font-weight: 500;
// Remove default Gutenberg box shadow.
&:focus:not(:focus-visible) {
box-shadow: none;
}
svg {
margin-left: auto;
}
}
.vpf-component-collapse-control-active svg {
transform: rotate(180deg);
}
// Content.
.vpf-component-collapse-control-content {
display: none;
}
.vpf-component-collapse-control-content-active {
display: block;
}
// Fix panel styles.
.components-panel__body {
padding-top: 10px;
padding-bottom: 10px;
border-top: none;
}
.components-panel__body:has(+ .vpf-component-collapse-control-toggle) {
padding-bottom: 0;
}
}

View File

@ -0,0 +1,153 @@
import './style.scss';
import classnames from 'classnames/dedupe';
import { __experimentalUseMultipleOriginColorsAndGradients as useMultipleOriginColorsAndGradients } from '@wordpress/block-editor';
import {
Button,
ColorPalette,
Dropdown,
GradientPicker,
TabPanel,
} from '@wordpress/components';
import { useSelect } from '@wordpress/data';
function useColors() {
// New way to get colors and gradients.
if (
useMultipleOriginColorsAndGradients &&
useMultipleOriginColorsAndGradients()
) {
const colorsData = useMultipleOriginColorsAndGradients();
return {
colors: colorsData.colors,
gradients: colorsData.gradients,
};
}
// Old way.
const { colors, gradients } = useSelect((select) => {
const settings = select('core/block-editor').getSettings();
const themeColors = [];
const themeGradients = [];
if (settings.colors && settings.colors.length) {
themeColors.push({ name: 'Theme', colors: settings.colors });
}
if (settings.gradients && settings.gradients.length) {
themeGradients.push({
name: 'Theme',
gradients: settings.gradients,
});
}
return {
colors: themeColors,
gradients: themeGradients,
};
});
return { colors, gradients };
}
/**
* Component Class
*
* @param props
*/
export default function ColorPicker(props) {
const {
label,
value,
onChange,
alpha = false,
gradient = false,
afterDropdownContent,
} = props;
const { colors, gradients } = useColors();
const isGradient = value && value.match(/gradient/);
const colorValue = isGradient ? undefined : value;
const gradientValue = isGradient ? value : undefined;
const tabs = {
solid: (
<ColorPalette
colors={colors}
value={colorValue}
enableAlpha={alpha}
onChange={(val) => {
onChange(val);
}}
__experimentalHasMultipleOrigins
__experimentalIsRenderedInSidebar
/>
),
gradient: (
<GradientPicker
__nextHasNoMargin
value={gradientValue}
onChange={(val) => {
onChange(val);
}}
gradients={gradients}
/>
),
};
return (
<Dropdown
className="vpf-component-color-picker__dropdown"
contentClassName="vpf-component-color-picker__dropdown-content"
popoverProps={{
placement: 'left-start',
offset: 36,
shift: true,
}}
renderToggle={({ isOpen, onToggle }) => (
<Button
className={classnames(
'vpf-component-color-toggle',
isOpen ? 'vpf-component-color-toggle-active' : ''
)}
onClick={onToggle}
>
<span
className="vpf-component-color-toggle-indicator"
style={{ background: value || '' }}
/>
<span className="vpf-component-color-toggle-label">
{label}
</span>
</Button>
)}
renderContent={() => (
<div className="vpf-component-color-picker">
{gradient ? (
<TabPanel
tabs={[
{
name: 'solid',
title: 'Solid',
},
{
name: 'gradient',
title: 'Gradient',
},
]}
initialTabName={isGradient ? 'gradient' : 'solid'}
>
{(tab) => {
return tabs[tab.name];
}}
</TabPanel>
) : (
tabs.solid
)}
{afterDropdownContent || ''}
</div>
)}
/>
);
}

View File

@ -0,0 +1,69 @@
@use "sass:math";
// Group label.
.vpf-control-wrap-html:has(+ .vpf-control-wrap-color) {
margin-bottom: -8px;
}
.vpf-control-wrap-color:has(+ .vpf-control-wrap-color) {
margin-bottom: -9px;
.vpf-component-color-toggle {
border-bottom: none;
}
}
.vpf-component-color-toggle {
position: relative;
width: 100%;
height: auto;
padding: 10px 12px;
margin-bottom: 0;
border: 1px solid rgba(0, 0, 0, 10%);
border-radius: 0;
&.vpf-component-color-toggle-active {
color: var(--wp-admin-theme-color);
background-color: #f0f0f0;
}
.vpf-component-color-toggle-indicator {
position: relative;
display: block;
width: 20px;
height: 20px;
padding: 0;
margin-right: 8px;
color: transparent;
cursor: pointer;
background: linear-gradient(-45deg, transparent 48%, #ddd 0, #ddd 52%, transparent 0);
border: none;
border-radius: 50%;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 20%);
}
.vpf-component-color-toggle-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.vpf-component-color-picker__dropdown {
display: block;
width: 100%;
}
.vpf-component-color-picker__dropdown-content {
.components-popover__content {
width: 260px;
padding: 16px;
}
.components-tab-panel__tabs {
margin-top: -16px;
margin-right: -16px;
margin-bottom: 16px;
margin-left: -16px;
}
}

View File

@ -0,0 +1,886 @@
import './style.scss';
import classnames from 'classnames/dedupe';
import {
__experimentalUnitControl,
BaseControl,
Button,
ButtonGroup,
CheckboxControl,
Notice,
PanelBody,
RadioControl,
RangeControl,
TextareaControl,
TextControl,
ToggleControl,
Tooltip,
UnitControl as __stableUnitControl,
} from '@wordpress/components';
import {
Component,
RawHTML,
useEffect,
useRef,
useState,
} from '@wordpress/element';
import { applyFilters } from '@wordpress/hooks';
import { __ } from '@wordpress/i18n';
import controlConditionCheck from '../../utils/control-condition-check';
import controlGetValue from '../../utils/control-get-value';
import { maybeDecode, maybeEncode } from '../../utils/encode-decode';
import AlignControl from '../align-control';
import AspectRatio from '../aspect-ratio';
import ClassesTree from '../classes-tree';
import CodeEditor from '../code-editor';
import CollapseControl from '../collapse-control';
import ColorPicker from '../color-picker';
import DatePicker from '../date-picker';
import ElementsSelector from '../elements-selector';
import GalleryControl from '../gallery-control';
import IconsSelector from '../icons-selector';
import NavigatorControl from '../navigator-control';
import ProNote from '../pro-note';
import SelectControl from '../select-control';
import SortableControl from '../sortable-control';
import TabsControl from '../tabs-control';
import TilesSelector from '../tiles-selector';
import ToggleGroupControl from '../toggle-group-control';
import ToggleModal from '../toggle-modal';
const UnitControl = __stableUnitControl || __experimentalUnitControl;
const {
controls: registeredControls,
controls_categories: registeredControlsCategories,
plugin_version: pluginVersion,
} = window.VPGutenbergVariables;
const openedCategoriesCache = {};
/**
* Component Class
*/
class ControlsRender extends Component {
render() {
const {
category,
categoryToggle = true,
attributes,
setAttributes,
controls,
clientId,
isSetupWizard,
showPanel = true,
} = this.props;
if (!attributes) {
return null;
}
// content source conditions.
if (
/^content-source-/g.test(category) &&
category !== 'content-source-general' &&
`content-source-${attributes.content_source}` !== category
) {
return null;
}
const usedControls = controls || registeredControls;
const result = [];
Object.keys(usedControls).forEach((name) => {
const control = usedControls[name];
if (
category &&
(!control.category || category !== control.category)
) {
return;
}
const controlData = applyFilters(
'vpf.editor.controls-render-data',
{
attributes,
setAttributes,
onChange: (val) => {
const newAttrs = applyFilters(
'vpf.editor.controls-on-change',
{ [control.name]: val },
control,
val,
attributes
);
setAttributes(newAttrs);
},
...control,
}
);
// Conditions check.
if (!ControlsRender.AllowRender(controlData, isSetupWizard)) {
return;
}
result.push(
applyFilters(
'vpf.editor.controls-render',
<ControlsRender.Control
key={`control-${control.name}-${control.label}`}
{...controlData}
clientId={clientId}
isSetupWizard={isSetupWizard}
renderProps={this.props}
/>,
controlData,
this.props
)
);
});
let categoryTitle = categoryToggle ? category : false;
let categoryIcon = false;
let categoryPro = false;
let categoryOpened = !categoryToggle;
if (
categoryToggle &&
typeof registeredControlsCategories[category] !== 'undefined'
) {
categoryTitle = registeredControlsCategories[category].title;
categoryIcon = registeredControlsCategories[category].icon || false;
categoryPro = !!registeredControlsCategories[category].is_pro;
if (typeof openedCategoriesCache[category] === 'undefined') {
openedCategoriesCache[category] =
registeredControlsCategories[category].is_opened || false;
}
categoryOpened = openedCategoriesCache[category];
}
if (isSetupWizard) {
return result.length ? (
<div className="vpf-setup-wizard-panel">{result}</div>
) : (
''
);
}
if (!showPanel) {
return result.length ? result : '';
}
return result.length ? (
<PanelBody
title={
categoryTitle ? (
<>
{categoryIcon ? (
<span className="vpf-control-category-title-icon">
<RawHTML>{categoryIcon}</RawHTML>
</span>
) : null}
<span>{categoryTitle}</span>
{categoryPro ? (
<span className="vpf-control-category-title-pro">
{__('PRO', 'visual-portfolio')}
</span>
) : (
''
)}
</>
) : (
false
)
}
onToggle={() => {
openedCategoriesCache[category] = !categoryOpened;
}}
initialOpen={categoryOpened}
scrollAfterOpen
>
{result}
</PanelBody>
) : (
''
);
}
}
/**
* Render Single Control.
*
* @param {Object} props - control props.
*
* @return {JSX} control.
*/
ControlsRender.Control = function (props) {
const { attributes, onChange, isSetupWizard } = props;
const $ref = useRef();
const [positionInGroup, setPositionInGroup] = useState('');
const controlVal = controlGetValue(props.name, attributes);
useEffect(() => {
if (props.group && $ref.current) {
const $element = $ref.current.parentElement.parentElement;
let $prevSibling = $element.previousElementSibling;
let $nextSibling = $element.nextElementSibling;
// Skip separator.
while (
$prevSibling &&
$prevSibling.classList.contains('vpf-control-group-separator')
) {
$prevSibling = $prevSibling.previousElementSibling;
}
while (
$nextSibling &&
$nextSibling.classList.contains('vpf-control-group-separator')
) {
$nextSibling = $nextSibling.nextElementSibling;
}
const isGroupEnabled =
($prevSibling &&
$prevSibling.classList.contains(
`vpf-control-group-${props.group}`
)) ||
($nextSibling &&
$nextSibling.classList.contains(
`vpf-control-group-${props.group}`
));
const isStart =
$prevSibling &&
!$prevSibling.classList.contains(
`vpf-control-group-${props.group}`
) &&
$prevSibling.classList.contains(`vpf-control-wrap`);
const isEnd =
$nextSibling &&
!$nextSibling.classList.contains(
`vpf-control-group-${props.group}`
) &&
$nextSibling.classList.contains(`vpf-control-wrap`);
let newPosition = '';
if (!isGroupEnabled) {
// skip
} else if (isStart) {
newPosition = 'start';
} else if (isEnd) {
newPosition = 'end';
}
if (positionInGroup !== newPosition) {
setPositionInGroup(newPosition);
}
}
}, [$ref, props.group, controlVal, positionInGroup]);
// Conditions check.
if (!ControlsRender.AllowRender(props, isSetupWizard)) {
return null;
}
let renderControl = '';
let renderControlLabel = props.label;
let renderControlAfter = '';
let renderControlHelp = props.description ? (
<RawHTML className="components-base-control__help">
{props.description}
</RawHTML>
) : null;
let renderControlClassName = classnames(
'vpf-control-wrap',
`vpf-control-wrap-${props.type}`
);
if (props.group) {
renderControlClassName = classnames(
renderControlClassName,
'vpf-control-with-group',
`vpf-control-group-${props.group}`,
positionInGroup
? `vpf-control-group-position-${positionInGroup}`
: false
);
}
const categoryControlOptions = [];
// Check if category is empty.
if (
(props.type === 'category_tabs' ||
props.type === 'category_toggle_group' ||
props.type === 'category_collapse' ||
props.type === 'category_navigator') &&
props.options &&
props.options.length
) {
props.options.forEach((opt) => {
const isEmpty = ControlsRender.isCategoryEmpty({
...props.renderProps,
category: opt.category,
categoryToggle: false,
});
if (!isEmpty) {
categoryControlOptions.push(opt);
}
});
}
// Specific controls.
switch (props.type) {
case 'category_tabs':
if (categoryControlOptions.length) {
renderControl = (
<TabsControl
controlName={props.name}
options={categoryControlOptions}
key={categoryControlOptions}
>
{(tab) => {
return (
<ControlsRender
{...props.renderProps}
category={tab.name}
categoryToggle={false}
/>
);
}}
</TabsControl>
);
} else {
renderControl = null;
}
break;
case 'category_toggle_group':
if (categoryControlOptions.length) {
renderControl = (
<ToggleGroupControl
controlName={props.name}
options={categoryControlOptions}
key={categoryControlOptions}
>
{(group) => {
return (
<ControlsRender
{...props.renderProps}
category={group.category}
categoryToggle={false}
/>
);
}}
</ToggleGroupControl>
);
} else {
renderControl = null;
}
break;
case 'category_collapse':
if (categoryControlOptions.length) {
renderControl = (
<CollapseControl
controlName={props.name}
initialOpen={props.initialOpen}
options={categoryControlOptions}
key={categoryControlOptions}
>
{(tab) => {
return (
<ControlsRender
{...props.renderProps}
category={tab.category}
categoryToggle={false}
/>
);
}}
</CollapseControl>
);
} else {
renderControl = null;
}
break;
case 'category_navigator':
if (categoryControlOptions.length) {
renderControl = (
<NavigatorControl
controlName={props.name}
options={categoryControlOptions}
key={categoryControlOptions}
>
{(tab) => {
return (
<ControlsRender
{...props.renderProps}
category={tab.category}
categoryToggle={false}
/>
);
}}
</NavigatorControl>
);
} else {
renderControl = null;
}
break;
case 'html':
renderControl = <RawHTML>{props.default}</RawHTML>;
break;
case 'select':
case 'select2':
renderControl = (
<SelectControl
controlName={props.name}
callback={props.value_callback}
attributes={attributes}
value={controlVal}
options={props.options || {}}
onChange={(val) => onChange(val)}
isSearchable={props.searchable}
isMultiple={props.multiple}
isCreatable={props.creatable || props.tags}
/>
);
break;
case 'buttons':
renderControl = (
<ButtonGroup>
{Object.keys(props.options || {}).map((val) => (
<Button
isSmall
isPrimary={controlVal === val}
isPressed={controlVal === val}
key={val}
onClick={() => onChange(val)}
>
{props.options[val]}
</Button>
))}
</ButtonGroup>
);
break;
case 'icons_selector':
renderControl = (
<IconsSelector
controlName={props.name}
callback={props.value_callback}
attributes={attributes}
value={controlVal}
options={props.options}
onChange={(val) => onChange(val)}
collapseRows={props.collapse_rows || false}
isSetupWizard={isSetupWizard}
/>
);
break;
case 'tiles_selector':
renderControl = (
<TilesSelector
value={controlVal}
options={props.options}
onChange={(val) => onChange(val)}
/>
);
break;
case 'elements_selector':
renderControl = (
<ElementsSelector
value={controlVal}
locations={props.locations}
options={props.options}
onChange={(val) => onChange(val)}
props={props}
/>
);
break;
case 'align': {
renderControl = (
<AlignControl
value={controlVal}
options={props.options || 'horizontal'}
onChange={(val) => onChange(val)}
/>
);
break;
}
case 'aspect_ratio': {
renderControl = (
<AspectRatio
value={controlVal}
onChange={(val) => onChange(val)}
/>
);
break;
}
case 'gallery':
renderControl = (
<GalleryControl
imageControls={props.image_controls}
focalPoint={props.focal_point}
attributes={attributes}
name={props.name}
value={controlVal}
onChange={(val) => onChange(val)}
isSetupWizard={isSetupWizard}
/>
);
break;
case 'code_editor':
renderControl = (
<CodeEditor
value={props.encode ? maybeDecode(controlVal) : controlVal}
mode={props.mode}
maxLines={props.max_lines}
minLines={props.min_lines}
codePlaceholder={props.code_placeholder}
onChange={(val) =>
onChange(props.encode ? maybeEncode(val) : val)
}
/>
);
if (props.allow_modal) {
renderControlAfter = (
<ToggleModal
modalTitle={__('Custom CSS', 'visual-portfolio')}
buttonLabel={__('Open in Modal', 'visual-portfolio')}
size="md"
>
<BaseControl
id={`vpf-custom-css-${props.label || props.name}`}
label={props.label}
help={
props.description ? (
<RawHTML>{props.description}</RawHTML>
) : (
false
)
}
className={classnames(
'vpf-control-wrap',
`vpf-control-wrap-${props.type}`
)}
>
<div>{renderControl}</div>
</BaseControl>
{props.classes_tree ? (
<>
<p>{__('Classes Tree:', 'visual-portfolio')}</p>
<ClassesTree {...props} />
</>
) : (
''
)}
</ToggleModal>
);
}
break;
case 'range':
renderControl = (
<RangeControl
min={props.min}
max={props.max}
step={props.step}
value={parseFloat(controlVal)}
onChange={(val) => onChange(parseFloat(val))}
/>
);
break;
case 'toggle':
renderControl = (
<ToggleControl
checked={controlVal}
label={props.alongside}
onChange={(val) => onChange(val)}
/>
);
break;
case 'checkbox':
renderControl = (
<CheckboxControl
checked={controlVal}
label={props.alongside}
onChange={(val) => onChange(val)}
/>
);
break;
case 'radio':
renderControl = (
<RadioControl
label={renderControlLabel}
selected={controlVal}
options={Object.keys(props.options || {}).map((val) => ({
label: props.options[val],
value: val,
}))}
onChange={(option) => onChange(option)}
/>
);
renderControlLabel = false;
break;
case 'color':
renderControl = (
<ColorPicker
label={renderControlLabel}
value={controlVal}
alpha={props.alpha}
gradient={props.gradient}
onChange={(val) => onChange(val)}
/>
);
renderControlLabel = false;
break;
case 'date':
renderControl = (
<DatePicker
value={controlVal}
onChange={(val) => onChange(val)}
/>
);
break;
case 'textarea':
renderControl = (
<TextareaControl
label={renderControlLabel}
value={controlVal}
onChange={(val) => onChange(val)}
/>
);
renderControlLabel = false;
break;
case 'url':
renderControl = (
<TextControl
label={renderControlLabel}
type="url"
value={controlVal}
onChange={(val) => onChange(val)}
/>
);
renderControlLabel = false;
break;
case 'number':
renderControl = (
<TextControl
label={renderControlLabel}
type="number"
min={props.min}
max={props.max}
step={props.step}
value={parseFloat(controlVal)}
onChange={(val) => onChange(parseFloat(val))}
/>
);
renderControlLabel = false;
break;
case 'unit':
renderControl = (
<UnitControl
label={renderControlLabel}
value={controlVal}
onChange={(val) => onChange(val)}
labelPosition="edge"
__unstableInputWidth="70px"
/>
);
renderControlLabel = false;
break;
case 'hidden':
renderControl = (
<TextControl
type="hidden"
value={controlVal}
onChange={(val) => onChange(val)}
/>
);
break;
case 'notice':
renderControl = renderControlHelp ? (
<Notice status={props.status} isDismissible={false}>
{renderControlHelp}
</Notice>
) : (
''
);
renderControlHelp = false;
break;
case 'pro_note':
renderControl = (
<ProNote title={renderControlLabel}>
{renderControlHelp || ''}
<ProNote.Button
target="_blank"
rel="noopener noreferrer"
href={`https://visualportfolio.co/pricing/?utm_source=plugin&utm_medium=block_settings&utm_campaign=${props.name}&utm_content=${pluginVersion}`}
>
{__('Go Pro', 'visual-portfolio')}
</ProNote.Button>
</ProNote>
);
renderControlLabel = false;
renderControlHelp = false;
break;
case 'sortable':
renderControl = (
<SortableControl
label={renderControlLabel}
controlName={props.name}
attributes={attributes}
value={controlVal}
options={props.options || {}}
defaultVal={props.default || {}}
allowDisablingOptions={
props.allow_disabling_options || false
}
onChange={(val) => onChange(val)}
/>
);
break;
default:
renderControl = (
<TextControl
label={renderControlLabel}
value={controlVal}
onChange={(val) => onChange(val)}
/>
);
renderControlLabel = false;
}
// Hint.
if (props.hint) {
renderControl = (
<Tooltip text={props.hint} position={props.hint_place}>
<div>{renderControl}</div>
</Tooltip>
);
}
// TODO: use this filter for custom controls.
const data = applyFilters(
'vpf.editor.controls-render-inner-data',
{
renderControl,
renderControlLabel,
renderControlHelp,
renderControlAfter,
renderControlClassName,
},
{ props, controlVal }
);
// Prevent rendering.
if (data.renderControl === null) {
return null;
}
return (
<>
{positionInGroup === 'start' ? (
<div className="vpf-control-group-separator" />
) : null}
<BaseControl
id={`vpf-control-group-${props.name}`}
label={data.renderControlLabel}
className={data.renderControlClassName}
>
<div ref={$ref}>{data.renderControl}</div>
{data.renderControlHelp}
</BaseControl>
{data.renderControlAfter}
{positionInGroup === 'end' ? (
<div className="vpf-control-group-separator" />
) : null}
</>
);
};
/**
* Check if control is allowed to rendering.
*
* @param props
* @param isSetupWizard
*/
ControlsRender.AllowRender = function (props, isSetupWizard = false) {
if (props.skip) {
return false;
}
if (
props.condition &&
props.condition.length &&
!controlConditionCheck(props.condition, props.attributes)
) {
return false;
}
if (isSetupWizard && !props.setup_wizard) {
return false;
}
return true;
};
/**
* Check if category does not contains controls.
*
* @param props
*/
ControlsRender.isCategoryEmpty = function (props) {
const { category, attributes, setAttributes, controls, isSetupWizard } =
props;
const usedControls = controls || registeredControls;
let isEmpty = true;
Object.keys(usedControls).forEach((name) => {
if (!isEmpty) {
return;
}
const control = usedControls[name];
if (category && (!control.category || category !== control.category)) {
return;
}
const controlData = applyFilters('vpf.editor.controls-render-data', {
attributes,
setAttributes,
onChange: (val) => {
const newAttrs = applyFilters(
'vpf.editor.controls-on-change',
{ [control.name]: val },
control,
val,
attributes
);
setAttributes(newAttrs);
},
...control,
});
// Conditions check.
if (!ControlsRender.AllowRender(controlData, isSetupWizard)) {
return;
}
isEmpty = false;
});
return isEmpty;
};
export default ControlsRender;

View File

@ -0,0 +1,78 @@
.vpf-control-wrap {
> .components-base-control__help,
> .components-base-control__field > .components-base-control__help {
margin-bottom: 1em;
font-size: 12px;
font-style: normal;
color: rgb(117, 117, 117);
word-break: break-word;
}
.components-base-control__label {
display: inline-block;
margin-bottom: 8px;
}
.components-modal__content & {
margin-bottom: 24px;
}
}
// Text control.
.vpf-control-wrap-text input[disabled] {
color: rgba(44, 51, 56, 50%);
border-color: rgba(220, 220, 222, 75%);
box-shadow: inset 0 1px 2px rgb(0 0 0 / 4%);
}
// Notice control.
.vpf-control-wrap-notice .components-notice {
margin: 0;
&.is-info {
background-color: #e6f7ff;
}
}
.vpf-control-category-title-pro {
padding: 0.2em 0.8em;
margin-right: 30px;
margin-left: auto;
font-size: 0.8em;
color: #fff;
background-color: #2540cc;
border-radius: 1em;
}
.components-panel__body-toggle > .vpf-control-category-title-icon {
position: absolute;
top: 50%;
display: block;
transform: translateY(-50%);
+ span {
margin-left: 28px;
}
svg {
display: block;
width: 18px;
height: 18px;
}
}
// Group controls.
.vpf-control-group-separator {
margin-bottom: 18px;
border-bottom: 1px solid #e0e0e0;
+ .vpf-control-group-separator {
display: none;
}
.vpf-component-modal &,
.vpf-component-elements-selector-modal & {
margin-right: -32px;
margin-left: -32px;
}
}

View File

@ -0,0 +1,49 @@
import { Button, DatePicker, Dropdown } from '@wordpress/components';
import {
__experimentalGetSettings as getSettings,
dateI18n,
} from '@wordpress/date';
import { Component } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
/**
* Component Class
*/
export default class VPDatePicker extends Component {
render() {
const { value, onChange } = this.props;
const settings = getSettings();
const resolvedFormat = settings.formats.datetime || 'F j, Y';
return (
<Dropdown
renderToggle={({ onToggle }) => (
<Button isSecondary isSmall onClick={onToggle}>
{value
? dateI18n(resolvedFormat, value)
: __('Select Date', 'visual-portfolio')}
</Button>
)}
renderContent={() => (
<div className="components-datetime vpf-component-date-picker">
<DatePicker currentDate={value} onChange={onChange} />
{value ? (
<Button
isSecondary
isSmall
onClick={() => {
onChange('');
}}
>
{__('Reset Date', 'visual-portfolio')}
</Button>
) : (
''
)}
</div>
)}
/>
);
}
}

View File

@ -0,0 +1 @@
import './style.scss';

View File

@ -0,0 +1,3 @@
.vpf-component-dropdown-no-padding .components-popover__content > div {
padding: 0;
}

View File

@ -0,0 +1,446 @@
import './style.scss';
import classnames from 'classnames/dedupe';
import {
Button,
Dropdown,
DropdownMenu,
Modal,
PanelBody,
Toolbar,
ToolbarButton,
} from '@wordpress/components';
import { Component, useState } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import ControlsRender from '../controls-render';
const alignIcons = {
left: (
<svg
width="24"
height="24"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
role="img"
aria-hidden="true"
focusable="false"
>
<path d="M9 9v6h11V9H9zM4 20h1.5V4H4v16z" />
</svg>
),
center: (
<svg
width="24"
height="24"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
role="img"
aria-hidden="true"
focusable="false"
>
<path d="M20 9h-7.2V4h-1.6v5H4v6h7.2v5h1.6v-5H20z" />
</svg>
),
right: (
<svg
width="24"
height="24"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
role="img"
aria-hidden="true"
focusable="false"
>
<path d="M4 15h11V9H4v6zM18.5 4v16H20V4h-1.5z" />
</svg>
),
between: (
<svg
width="24"
height="24"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
role="img"
aria-hidden="true"
focusable="false"
>
<path d="M9 15h6V9H9v6zm-5 5h1.5V4H4v16zM18.5 4v16H20V4h-1.5z" />
</svg>
),
};
/**
* Options render
*
* @param props
*/
function ElementsSelectorOptions(props) {
const {
location,
locationData,
value,
onChange,
options,
optionName,
parentProps,
} = props;
const [isOpen, setOpen] = useState(false);
const openModal = () => setOpen(true);
const closeModal = () => setOpen(false);
return (
<>
<button
type="button"
aria-expanded={isOpen}
className="vpf-component-elements-selector-control-location-options-item"
onClick={openModal}
>
{options[optionName] ? options[optionName].title : optionName}
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11 4L17 10M17 10L11 16M17 10H3"
stroke="black"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
{isOpen ? (
<Modal
title={`${
options[optionName]
? options[optionName].title
: optionName
} ${__('Settings', 'visual-portfolio')}`}
onRequestClose={(e) => {
if (
e?.relatedTarget?.classList?.contains('media-modal')
) {
// Don't close modal if opened media modal.
} else {
closeModal(e);
}
}}
className="vpf-component-elements-selector-modal"
>
{options[optionName] && options[optionName].category ? (
<ControlsRender
{...parentProps.props}
category={options[optionName].category}
categoryToggle={false}
/>
) : null}
{optionName !== 'items' ? (
<PanelBody>
<Button
isLink
style={{
color: 'red',
marginTop: '5px',
}}
onClick={() => {
if (
// eslint-disable-next-line no-alert
window.confirm(
__(
'Are you sure you want to remove the element?',
'visual-portfolio'
)
)
) {
onChange({
...value,
[location]: {
...value[location],
elements:
locationData.elements.filter(
(elementName) =>
elementName !==
optionName
),
},
});
}
}}
>
{__('Remove', 'visual-portfolio')}
{` ${
options[optionName]
? options[optionName].title
: optionName
}`}
</Button>
</PanelBody>
) : null}
</Modal>
) : null}
</>
);
}
/**
* Component Class
*/
export default class ElementsSelector extends Component {
constructor(...args) {
super(...args);
this.getLocationData = this.getLocationData.bind(this);
this.renderLocation = this.renderLocation.bind(this);
this.renderAlignSettings = this.renderAlignSettings.bind(this);
}
getLocationData(location) {
const { options, locations, value } = this.props;
const title =
(locations[location] && locations[location].title) || false;
const elements =
value[location] && value[location].elements
? value[location].elements
: [];
const align =
value[location] && value[location].align
? value[location].align
: false;
const availableAlign =
locations[location] && locations[location].align
? locations[location].align
: [];
const availableElements = {};
// find all available elements
Object.keys(options).forEach((name) => {
const data = options[name];
if (
(!data.allowed_locations ||
data.allowed_locations.indexOf(location) !== -1) &&
elements.indexOf(name) === -1
) {
availableElements[name] = data;
}
});
return {
title,
elements,
align,
availableAlign,
availableElements,
};
}
renderAlignSettings(location) {
const { value, onChange } = this.props;
const locationData = this.getLocationData(location);
const controls = [];
if (locationData.availableAlign.length) {
locationData.availableAlign.forEach((alignName) => {
controls.push(
<ToolbarButton
key={alignName}
icon={alignIcons[alignName]}
label={`${
alignName.charAt(0).toUpperCase() +
alignName.slice(1)
}`}
onClick={() =>
onChange({
...value,
[location]: {
...value[location],
align: alignName,
},
})
}
isActive={
locationData.align
? locationData.align === alignName
: false
}
/>
);
});
}
if (!controls.length) {
return null;
}
return (
<Dropdown
className="vpf-component-elements-selector-align__dropdown"
contentClassName="vpf-component-elements-selector-align__dropdown-content"
popoverProps={{
placement: 'left-start',
offset: 36,
shift: true,
}}
renderToggle={({ isOpen, onToggle }) => (
<button
type="button"
aria-expanded={isOpen}
className="vpf-component-elements-selector-control-location-options-item"
onClick={onToggle}
>
{locationData.align && alignIcons[locationData.align]
? alignIcons[locationData.align]
: alignIcons.center}
</button>
)}
renderContent={() => (
<Toolbar label="Elements Selector">{controls}</Toolbar>
)}
/>
);
}
renderLocation(location) {
const { value, onChange, options } = this.props;
const locationData = this.getLocationData(location);
const { availableElements } = locationData;
return (
<div
key={location}
className="vpf-component-elements-selector-control-location"
>
{locationData.title ? (
<div className="vpf-component-elements-selector-control-location-title">
{locationData.title}
</div>
) : null}
{locationData.availableAlign.length &&
locationData.elements.length ? (
<div className="vpf-component-elements-selector-control-location-align">
{this.renderAlignSettings(location)}
</div>
) : null}
<div
className={classnames(
'vpf-component-elements-selector-control-location-options',
locationData.align
? `vpf-component-elements-selector-control-location-options-${locationData.align}`
: ''
)}
>
{locationData.elements.length
? locationData.elements.map((optionName) => (
<ElementsSelectorOptions
key={optionName}
location={location}
locationData={locationData}
value={value}
onChange={onChange}
options={options}
optionName={optionName}
parentProps={this.props}
/>
))
: null}
{Object.keys(availableElements).length ? (
<DropdownMenu
className="vpf-component-elements-selector-control-location-options-add-button"
popoverProps={{
position: 'bottom center',
}}
icon={
<svg
width="20"
height="20"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
role="img"
aria-hidden="true"
focusable="false"
>
<path d="M18 11.2h-5.2V6h-1.6v5.2H6v1.6h5.2V18h1.6v-5.2H18z" />
</svg>
}
controls={Object.keys(availableElements).map(
(optionName) => ({
title: (
<>
{
availableElements[optionName]
.title
}
{availableElements[optionName]
.is_pro ? (
<span className="vpf-component-elements-selector-control-location-options-title-pro">
{__(
'PRO',
'visual-portfolio'
)}
</span>
) : (
''
)}
</>
),
onClick() {
if (
availableElements[optionName].is_pro
) {
return;
}
const newElements = [
...locationData.elements,
];
if (
newElements.indexOf(optionName) ===
-1
) {
newElements.push(optionName);
onChange({
...value,
[location]: {
...value[location],
elements: newElements,
},
});
}
},
})
)}
/>
) : (
''
)}
</div>
</div>
);
}
render() {
const { locations } = this.props;
return (
<div className="vpf-component-elements-selector-control">
{Object.keys(locations).map((name) =>
this.renderLocation(name)
)}
</div>
);
}
}

View File

@ -0,0 +1,167 @@
@import "../../variables";
.vpf-component-elements-selector-control-location {
position: relative;
// background-color: $light-gray-100;
& + & {
margin-top: 17px;
}
.vpf-component-elements-selector-control-location-title {
margin-bottom: 8px;
font-size: 12px;
font-weight: 500;
opacity: 0.6;
}
.vpf-component-elements-selector-control-location-align {
position: absolute;
top: -2px;
right: 0;
button {
display: flex;
width: 25px;
height: auto;
padding: 4px 5px;
margin-top: 0;
font-size: 10px;
color: #000;
cursor: pointer;
background: #fff;
border: 1px solid #e1e1e1;
border-radius: 3px;
&:hover,
&:focus {
color: var(--wp-admin-theme-color);
background: #f9f8f8;
border-color: #cbcbcb;
}
svg {
width: 100%;
height: auto;
margin-left: 0;
fill: currentcolor;
}
}
}
.vpf-component-elements-selector-control-location-options {
display: flex;
flex-wrap: wrap;
margin-top: -10px;
> * {
width: 100%;
}
}
}
// Buttons.
.vpf-component-elements-selector-control-location-options-add-button {
display: flex;
align-items: center;
width: 100%;
margin-top: 10px;
.components-button {
justify-content: center;
width: 100%;
height: 40px;
background: #fff;
border: 1px solid #e1e1e1;
border-radius: 4px;
box-shadow: none;
&:hover,
&:focus {
background: #f9f8f8;
border-color: #cbcbcb;
}
}
}
.vpf-component-elements-selector-control-location-options-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
height: 40px;
padding: 9px 12px;
margin-top: 10px;
font-weight: 500;
color: #000;
background-color: #f9f8f8;
border: 1px solid #ccc;
border-radius: 4px;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 10%);
&:hover,
&:focus {
color: var(--wp-admin-theme-color);
border-color: $gray-600;
}
}
button.vpf-component-elements-selector-control-location-options-item {
cursor: pointer;
svg {
width: 13px;
height: auto;
margin-left: 5px;
}
}
// Align dropdown.
.vpf-component-elements-selector-align__dropdown-content {
.components-accessible-toolbar {
border: none;
.components-button.has-icon.has-icon {
min-width: 40px;
height: 30px;
padding-top: 0;
padding-right: 0;
padding-bottom: 0;
padding-left: 0;
}
.components-button::before {
right: 4px;
left: 4px;
}
}
}
.vpf-component-elements-selector-control-location-options-title-pro {
padding: 0.2em 0.8em;
margin-left: 10px;
font-size: 0.7em;
font-weight: 600;
color: #fff;
background: linear-gradient(to left, #743ad5, #d53a9d);
border-radius: 1em;
}
.vpf-component-elements-selector-modal {
.components-panel__body {
max-width: 350px;
padding-right: 24px;
padding-left: 24px;
margin-right: -24px;
margin-left: -24px;
}
.components-modal__header + .components-panel__body {
border-top: none;
}
.components-panel__body:last-child {
padding-bottom: 0;
border-bottom: none;
}
}

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;
}
}
}

View File

@ -0,0 +1,240 @@
import './style.scss';
import classnames from 'classnames/dedupe';
import $ from 'jquery';
import { Button, Spinner } from '@wordpress/components';
import { Component, RawHTML } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
const { ajaxurl, VPGutenbergVariables } = window;
const cachedOptions = {};
/**
* Component Class
*/
export default class IconsSelector extends Component {
constructor(...args) {
super(...args);
const { callback } = this.props;
this.state = {
options: { ...this.props.options },
ajaxStatus: !!callback,
collapsed: true,
};
cachedOptions[this.props.controlName] = { ...this.props.options };
this.requestAjax = this.requestAjax.bind(this);
}
componentDidMount() {
const { callback } = this.props;
if (callback) {
this.requestAjax({}, (result) => {
if (result.options) {
this.setState({
options: result.options,
});
}
});
}
}
/**
* Request AJAX dynamic data.
*
* @param {Object} additionalData - additional data for AJAX call.
* @param {Function} callback - callback.
* @param {boolean} useStateLoading - use state change when loading.
*/
requestAjax(
additionalData = {},
callback = () => {},
useStateLoading = true
) {
const { controlName, attributes } = this.props;
if (this.isAJAXinProgress) {
return;
}
this.isAJAXinProgress = true;
if (useStateLoading) {
this.setState({
ajaxStatus: 'progress',
});
}
const ajaxData = {
action: 'vp_dynamic_control_callback',
nonce: VPGutenbergVariables.nonce,
vp_control_name: controlName,
vp_attributes: attributes,
...additionalData,
};
$.ajax({
url: ajaxurl,
method: 'POST',
dataType: 'json',
data: ajaxData,
complete: (data) => {
const json = data.responseJSON;
if (callback && json.response) {
if (json.response.options) {
cachedOptions[controlName] = {
...cachedOptions[controlName],
...json.response.options,
};
}
callback(json.response);
}
if (useStateLoading) {
this.setState({
ajaxStatus: true,
});
}
this.isAJAXinProgress = false;
},
});
}
render() {
const { controlName, value, onChange, collapseRows, isSetupWizard } =
this.props;
const { options, ajaxStatus, collapsed } = this.state;
const isLoading = ajaxStatus && ajaxStatus === 'progress';
if (isLoading) {
return (
<div className="vpf-component-icon-selector">
<Spinner />
</div>
);
}
const optionsArray = Object.keys(options);
const fromIndex = optionsArray.indexOf(value);
const itemsPerRow = isSetupWizard ? 5 : 3;
const allowedItems = collapseRows * itemsPerRow;
const allowCollapsing =
collapseRows !== false && optionsArray.length > allowedItems;
const visibleCollapsedItems = allowedItems - 1;
// Move the selected option to the end of collapsed list
// in case this item is not visible.
if (
allowCollapsing &&
collapsed &&
fromIndex >= visibleCollapsedItems
) {
const toIndex = visibleCollapsedItems - 1;
const element = optionsArray[fromIndex];
optionsArray.splice(fromIndex, 1);
optionsArray.splice(toIndex, 0, element);
}
return (
<div
className={classnames(
'vpf-component-icon-selector',
allowCollapsing
? 'vpf-component-icon-selector-allow-collapsing'
: ''
)}
data-control-name={controlName}
>
{optionsArray
.filter((elm, i) => {
if (allowCollapsing) {
return collapsed ? i < visibleCollapsedItems : true;
}
return true;
})
.map((k) => {
const option = options[k];
let { icon } = option;
if (isSetupWizard) {
if (option.image_preview_wizard) {
icon = `<img src="${option.image_preview_wizard}" alt="${option.title} Preview">`;
} else if (option.icon_wizard) {
icon = option.icon_wizard;
}
}
return (
<Button
key={`icon-selector-${option.title}-${option.value}`}
onClick={() => onChange(option.value)}
className={classnames(
'vpf-component-icon-selector-item',
value === option.value
? 'vpf-component-icon-selector-item-active'
: '',
option.className
)}
>
{icon ? <RawHTML>{icon}</RawHTML> : ''}
{option.title ? (
<span>{option.title}</span>
) : (
''
)}
</Button>
);
})}
{allowCollapsing ? (
<Button
onClick={() => {
this.setState({
collapsed: !collapsed,
});
}}
className={classnames(
'vpf-component-icon-selector-item',
'vpf-component-icon-selector-item-collapse',
collapsed
? ''
: 'vpf-component-icon-selector-item-expanded'
)}
>
<div className="vpf-component-icon-selector-item-collapse">
<svg
width="11"
height="6"
viewBox="0 0 11 6"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10 1.25L5.5 4.75L1 1.25"
stroke="currentColor"
strokeWidth="1"
/>
</svg>
</div>
<span>
{collapsed
? __('More', 'visual-portfolio')
: __('Less', 'visual-portfolio')}
</span>
</Button>
) : null}
</div>
);
}
}

View File

@ -0,0 +1,45 @@
%vpf-icons-selector-item {
position: relative;
height: auto;
padding: 15px;
margin: 0;
color: #000;
border-radius: 2px;
transition: 0.1s color ease-in-out;
&::after {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
pointer-events: none;
content: "";
background: var(--wp-admin-theme-color);
border-radius: 2px;
opacity: 0;
transition: 0.1s opacity ease-in-out;
}
&:hover,
&:focus {
color: var(--wp-admin-theme-color);
&::after {
opacity: 0.04;
}
}
// Remove default Gutenberg box shadow.
&:focus:not(:focus-visible) {
box-shadow: none;
}
}
%vpf-icons-selector-item-active {
color: var(--wp-admin-theme-color);
outline: 1px solid var(--wp-admin-theme-color);
&::after {
opacity: 0.04;
}
}

View File

@ -0,0 +1,99 @@
@import "./selector-placeholder.scss";
.vpf-control-wrap-icons_selector:has(+ .vpf-control-wrap-category_tabs),
.vpf-control-wrap-icons_selector:has(+ .vpf-control-wrap-category_collapse) {
margin-bottom: 0;
}
.vpf-control-wrap-icons_selector:has(+ .vpf-control-wrap-category_navigator) {
margin-bottom: 16px;
}
.vpf-component-icon-selector {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
grid-gap: 5px;
width: 100%;
.vpf-component-icon-selector-item {
@extend %vpf-icons-selector-item;
display: flex;
flex-direction: column;
align-items: center;
min-width: 0;
cursor: pointer;
transition: 0.2s border, 0.2s background-color, 0.2s box-shadow;
svg {
width: 100%;
max-width: 18px;
height: auto;
color: inherit;
fill: none;
}
&.vpf-component-icon-selector-item-active {
@extend %vpf-icons-selector-item-active;
}
span {
margin-right: -8px;
margin-left: -8px;
font-size: 12px;
word-break: break-word;
}
div + span {
padding-top: 8px;
}
}
.vpf-component-icon-selector-item-collapse {
svg {
width: 14px;
height: 14px;
color: var(--wp-admin-theme-color);
}
.vpf-component-icon-selector-item-collapse {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 23px;
height: 23px;
&::after {
position: absolute;
top: -3px;
right: -3px;
bottom: -3px;
left: -3px;
display: block;
content: "";
background-color: var(--wp-admin-theme-color);
border-radius: 15px;
opacity: 0.05;
transition: 0.1s opacity ease-in-out;
}
}
&:hover,
&:focus {
&::after {
opacity: 0;
}
.vpf-component-icon-selector-item-collapse::after {
opacity: 0.1;
}
}
&.vpf-component-icon-selector-item-expanded {
svg {
transform: rotate(180deg);
}
}
}
}

View File

@ -0,0 +1,437 @@
import './style.scss';
import './live-reload-conditions';
import classnames from 'classnames/dedupe';
import iframeResizer from 'iframe-resizer/js/iframeResizer';
import $ from 'jquery';
import { isEqual, uniq } from 'lodash';
import rafSchd from 'raf-schd';
import { debounce, throttle } from 'throttle-debounce';
import { Spinner } from '@wordpress/components';
import { dispatch, withSelect } from '@wordpress/data';
import { Component, createRef, Fragment } from '@wordpress/element';
import { applyFilters } from '@wordpress/hooks';
import getDynamicCSS, { hasDynamicCSS } from '../../utils/controls-dynamic-css';
const {
VPAdminGutenbergVariables: variables,
VPGutenbergVariables: { controls: registeredControls },
} = window;
let uniqueIdCount = 1;
function getUpdatedKeys(oldData, newData) {
const keys = uniq([...Object.keys(oldData), ...Object.keys(newData)]);
const changedKeys = [];
keys.forEach((k) => {
if (!isEqual(oldData[k], newData[k])) {
changedKeys.push(k);
}
});
return changedKeys;
}
/**
* Component Class
*/
class IframePreview extends Component {
constructor(...args) {
super(...args);
this.state = {
loading: true,
uniqueId: `vpf-preview-${uniqueIdCount}`,
currentIframeHeight: 0,
latestIframeHeight: 0,
};
uniqueIdCount += 1;
this.frameRef = createRef();
this.formRef = createRef();
this.maybePreviewTypeChanged = this.maybePreviewTypeChanged.bind(this);
this.maybeAttributesChanged = this.maybeAttributesChanged.bind(this);
this.onFrameLoad = this.onFrameLoad.bind(this);
this.maybeReload = this.maybeReload.bind(this);
this.maybeReloadDebounce = debounce(
300,
rafSchd(this.maybeReload.bind(this))
);
this.maybeResizePreviews = this.maybeResizePreviews.bind(this);
this.maybeResizePreviewsThrottle = throttle(
100,
rafSchd(this.maybeResizePreviews)
);
this.updateIframeHeight = this.updateIframeHeight.bind(this);
this.updateIframeHeightThrottle = throttle(
100,
rafSchd(this.updateIframeHeight)
);
this.printInput = this.printInput.bind(this);
}
componentDidMount() {
const self = this;
const { clientId } = self.props;
iframeResizer(
{
interval: 10,
warningTimeout: 60000,
checkOrigin: false,
onMessage({ message }) {
// select current block on click message.
if (message === 'clicked') {
dispatch('core/block-editor').selectBlock(clientId);
window.focus();
}
},
onResized({ height }) {
self.updateIframeHeightThrottle(`${height}px`);
},
},
self.frameRef.current
);
self.frameRef.current.addEventListener('load', self.onFrameLoad);
window.addEventListener('resize', self.maybeResizePreviewsThrottle);
self.maybeReload();
}
componentDidUpdate(prevProps) {
this.maybePreviewTypeChanged(prevProps);
this.maybeAttributesChanged(prevProps);
}
componentWillUnmount() {
this.frameRef.current.removeEventListener('load', this.onFrameLoad);
window.removeEventListener('resize', this.maybeResizePreviewsThrottle);
if (this.frameRef.current.iframeResizer) {
this.frameRef.current.iframeResizer.close();
this.frameRef.current.iframeResizer.removeListeners();
}
}
/**
* On frame load event.
*
* @param {Object} e - event data.
*/
onFrameLoad(e) {
this.frameWindow = e.target.contentWindow;
this.frameJQuery = e.target.contentWindow.jQuery;
if (this.frameJQuery) {
this.$framePortfolio = this.frameJQuery('.vp-portfolio');
this.maybeResizePreviews();
if (this.frameTimeout) {
clearTimeout(this.frameTimeout);
}
// We need this timeout, since we resize iframe size and layouts resized with transitions.
this.frameTimeout = setTimeout(() => {
this.setState({
loading: false,
});
}, 300);
}
}
maybePreviewTypeChanged(prevProps) {
if (prevProps.previewDeviceType === this.props.previewDeviceType) {
return;
}
this.maybeResizePreviews();
}
maybeAttributesChanged(prevProps) {
if (this.busyReload) {
return;
}
this.busyReload = true;
const { attributes: newAttributes } = this.props;
const { attributes: oldAttributes } = prevProps;
const frame = this.frameRef.current;
const changedAttributes = {};
const changedAttributeKeys = getUpdatedKeys(
oldAttributes,
newAttributes
);
// check changed attributes.
changedAttributeKeys.forEach((name) => {
if (typeof newAttributes[name] !== 'undefined') {
changedAttributes[name] = newAttributes[name];
}
});
if (Object.keys(changedAttributes).length) {
let reload = false;
Object.keys(changedAttributes).forEach((name) => {
// Don't reload if block has dynamic styles.
const hasStyles = hasDynamicCSS(name);
// Don't reload if reloading disabled in control attributes.
const hasReloadAttribute =
registeredControls[name] &&
registeredControls[name].reload_iframe;
reload = reload || (!hasStyles && hasReloadAttribute);
});
const data = applyFilters('vpf.editor.changed-attributes', {
attributes: changedAttributes,
reload,
$frame: this.frameRef.current,
frameWindow: this.frameWindow,
frameJQuery: this.frameJQuery,
$framePortfolio: this.$framePortfolio,
});
if (!data.reload) {
// Update AJAX dynamic data.
if (data.frameWindow && data.frameWindow.vp_preview_post_data) {
data.frameWindow.vp_preview_post_data[data.name] =
data.value;
}
// Insert dynamic CSS.
if (frame.iFrameResizer && newAttributes.block_id) {
frame.iFrameResizer.sendMessage({
name: 'dynamic-css',
blockId: newAttributes.block_id,
styles: getDynamicCSS(newAttributes),
});
}
}
if (data.reload) {
this.maybeReloadDebounce();
}
this.busyReload = false;
} else {
this.busyReload = false;
}
}
maybeReload() {
let latestIframeHeight = 0;
if (this.frameRef.current) {
latestIframeHeight = this.state.currentIframeHeight;
}
this.setState({
loading: true,
latestIframeHeight,
});
this.formRef.current.submit();
}
/**
* Resize frame to properly work with @media.
*/
maybeResizePreviews() {
const contentWidth = $(
'.editor-styles-wrapper, .edit-post-visual-editor__content-area'
)
.eq(0)
.width();
if (!contentWidth || !this.frameRef.current) {
return;
}
const frame = this.frameRef.current;
const $frame = $(frame);
const parentWidth = $frame
.closest('.visual-portfolio-gutenberg-preview')
.width();
$frame.css({
width: contentWidth,
});
if (frame.iFrameResizer) {
frame.iFrameResizer.sendMessage({
name: 'resize',
width: parentWidth,
});
frame.iFrameResizer.resize();
}
}
/**
* Update iframe height.
*
* @param newHeight
*/
updateIframeHeight(newHeight) {
this.setState({
currentIframeHeight: newHeight,
});
}
/**
* Prepare form input for POST variables.
*
* @param {string} name - option name.
* @param {Mixed} val - option value.
*
* @return {JSX} - form control.
*/
printInput(name, val) {
const params = {
type: 'text',
name,
value: val,
readOnly: true,
};
if (typeof val === 'number') {
params.type = 'number';
} else if (typeof val === 'boolean') {
params.type = 'number';
params.value = val ? 1 : 0;
} else if (typeof val === 'object' && val !== null) {
return (
<>
{Object.keys(val).map((i) => (
<Fragment key={`${name}[${i}]`}>
{this.printInput(`${name}[${i}]`, val[i])}
</Fragment>
))}
</>
);
} else {
params.value = params.value || '';
}
return <input {...params} />;
}
render() {
const { attributes, postType, postId } = this.props;
const { loading, uniqueId, currentIframeHeight, latestIframeHeight } =
this.state;
const { id, content_source: contentSource } = attributes;
return (
<div
className={classnames(
'visual-portfolio-gutenberg-preview',
loading ? 'visual-portfolio-gutenberg-preview-loading' : ''
)}
style={{
height: loading ? latestIframeHeight : currentIframeHeight,
}}
>
<div className="visual-portfolio-gutenberg-preview-inner">
<form
action={variables.preview_url}
target={uniqueId}
method="POST"
style={{ display: 'none' }}
ref={this.formRef}
>
<input
type="hidden"
name="vp_preview_frame"
value="true"
readOnly
/>
<input
type="hidden"
name="vp_preview_type"
value="gutenberg"
readOnly
/>
<input
type="hidden"
name="vp_preview_post_type"
value={postType}
readOnly
/>
<input
type="hidden"
name="vp_preview_post_id"
value={postId}
readOnly
/>
<input
type="hidden"
name="vp_preview_nonce"
value={variables.nonce}
readOnly
/>
{contentSource === 'saved' ? (
<input
type="text"
name="vp_id"
value={id}
readOnly
/>
) : (
<>
{Object.keys(attributes).map((k) => {
const val = attributes[k];
return (
<Fragment key={`vp_${k}`}>
{this.printInput(`vp_${k}`, val)}
</Fragment>
);
})}
</>
)}
</form>
<iframe
title="vp-preview"
id={uniqueId}
name={uniqueId}
// eslint-disable-next-line react/no-unknown-property
allowtransparency="true"
ref={this.frameRef}
/>
</div>
{loading ? <Spinner /> : ''}
</div>
);
}
}
export default withSelect((select) => {
const { __experimentalGetPreviewDeviceType } =
select('core/edit-post') || {};
const { getCurrentPost } = select('core/editor') || {};
return {
previewDeviceType: __experimentalGetPreviewDeviceType
? __experimentalGetPreviewDeviceType()
: 'desktop',
postType: getCurrentPost ? getCurrentPost().type : 'standard',
postId: getCurrentPost ? getCurrentPost().id : 'widgets',
};
})(IframePreview);

View File

@ -0,0 +1,152 @@
import { addFilter } from '@wordpress/hooks';
// live reload
addFilter(
'vpf.editor.changed-attributes',
'vpf/editor/changed-attributes/live-reload',
(data) => {
if (!data.$framePortfolio) {
return data;
}
let reload = false;
Object.keys(data.attributes).forEach((name) => {
const val = data.attributes[name];
switch (name) {
case 'tiles_type':
case 'masonry_columns':
case 'masonry_images_aspect_ratio':
case 'grid_columns':
case 'grid_images_aspect_ratio':
case 'justified_row_height':
case 'justified_row_height_tolerance':
case 'justified_max_rows_count':
case 'justified_last_row':
case 'slider_effect':
case 'slider_speed':
case 'slider_autoplay':
case 'slider_autoplay_hover_pause':
case 'slider_centered_slides':
case 'slider_loop':
case 'slider_free_mode':
case 'slider_free_mode_sticky':
case 'slider_bullets_dynamic':
case 'items_gap':
case 'items_gap_vertical': {
data.$framePortfolio.attr(
`data-vp-${name.replace(/_/g, '-')}`,
val
);
data.$framePortfolio.vpf('init');
break;
}
case 'items_style_default__align':
case 'items_style_fade__align':
case 'items_style_fly__align':
case 'items_style_emerge__align': {
let allAlignClasses = '';
[
'left',
'center',
'right',
'top-left',
'top-center',
'top-right',
'bottom-left',
'bottom-center',
'bottom-right',
].forEach((alignName) => {
allAlignClasses += `${
allAlignClasses ? ' ' : ''
}vp-portfolio__item-align-${alignName}`;
});
data.$framePortfolio
.find('.vp-portfolio__item-overlay')
.removeClass(allAlignClasses)
.addClass(`vp-portfolio__item-align-${val}`);
break;
}
case 'items_style_default__caption_text_align':
case 'items_style_fade__caption_text_align':
case 'items_style_fly__caption_text_align':
case 'items_style_emerge__caption_text_align': {
let allAlignClasses = '';
[
'left',
'center',
'right',
'top-left',
'top-center',
'top-right',
'bottom-left',
'bottom-center',
'bottom-right',
].forEach((alignName) => {
allAlignClasses += `${
allAlignClasses ? ' ' : ''
}vp-portfolio__item-caption-text-align-${alignName}`;
});
data.$framePortfolio
.find('.vp-portfolio__item-caption')
.removeClass(allAlignClasses)
.addClass(
`vp-portfolio__item-caption-text-align-${val}`
);
break;
}
case 'items_style_default__overlay_text_align':
case 'items_style_fade__overlay_text_align':
case 'items_style_fly__overlay_text_align':
case 'items_style_emerge__overlay_text_align': {
let allAlignClasses = '';
[
'left',
'center',
'right',
'top-left',
'top-center',
'top-right',
'bottom-left',
'bottom-center',
'bottom-right',
].forEach((alignName) => {
allAlignClasses += `${
allAlignClasses ? ' ' : ''
}vp-portfolio__item-overlay-text-align-${alignName}`;
});
data.$framePortfolio
.find('.vp-portfolio__item-overlay')
.removeClass(allAlignClasses)
.addClass(
`vp-portfolio__item-overlay-text-align-${val}`
);
break;
}
// prevent some options reload
case 'list_name':
// no reload
break;
default:
reload = reload || data.reload;
break;
}
});
return {
...data,
reload,
};
}
);

View File

@ -0,0 +1,45 @@
.visual-portfolio-gutenberg-preview {
position: relative;
min-height: 40px;
overflow: hidden;
iframe {
position: absolute;
width: 100%;
max-width: none !important;
margin: 0;
}
}
// Loading.
.visual-portfolio-gutenberg-preview-loading {
min-height: 150px;
&::after {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
display: block;
content: "";
background-color: rgba(139, 139, 150, 10%);
}
iframe {
opacity: 0;
}
> .visual-portfolio-gutenberg-preview-inner {
position: absolute;
}
> .components-spinner {
position: absolute;
top: 50%;
left: 50%;
z-index: 10;
margin: 0;
transform: translateX(-50%) translateY(-50%);
}
}

View File

@ -0,0 +1,24 @@
import { useEffect, useRef } from '@wordpress/element';
const { Masonry } = window;
export default function MasonryWrapper(props) {
const { options, children, ...restProps } = props;
const ref = useRef();
// Init.
useEffect(() => {
const instance = new Masonry(ref.current, options);
return () => {
instance.destroy();
};
}, [ref, options, children]);
return (
<div ref={ref} {...restProps}>
{children}
</div>
);
}

View File

@ -0,0 +1,89 @@
import './style.scss';
import {
__experimentalNavigatorBackButton,
__experimentalNavigatorButton,
__experimentalNavigatorProvider,
__experimentalNavigatorScreen,
NavigatorBackButton as __stableNavigatorBackButton,
NavigatorButton as __stableNavigatorButton,
NavigatorProvider as __stableNavigatorProvider,
NavigatorScreen as __stableNavigatorScreen,
} from '@wordpress/components';
const NavigatorProvider =
__stableNavigatorProvider || __experimentalNavigatorProvider;
const NavigatorScreen =
__stableNavigatorScreen || __experimentalNavigatorScreen;
const NavigatorButton =
__stableNavigatorButton || __experimentalNavigatorButton;
const NavigatorBackButton =
__stableNavigatorBackButton || __experimentalNavigatorBackButton;
/**
* Component Class
*
* @param props
*/
export default function NavigatorControl(props) {
const { children, options } = props;
return (
<NavigatorProvider
initialPath="/"
className="vpf-component-navigator-control"
>
<NavigatorScreen
path="/"
className="vpf-component-navigator-control-toggles"
>
{options.map((option) => {
return (
<NavigatorButton
key={option.category}
path={`/${option.category}`}
>
<span>{option.title}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="24"
height="24"
aria-hidden="true"
focusable="false"
>
<path d="M10.6 6L9.4 7l4.6 5-4.6 5 1.2 1 5.4-6z" />
</svg>
</NavigatorButton>
);
})}
</NavigatorScreen>
{options.map((option) => {
return (
<NavigatorScreen
key={option.category}
path={`/${option.category}`}
>
<div className="vpf-component-navigator-control-screen-title">
<NavigatorBackButton>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="24"
height="24"
aria-hidden="true"
focusable="false"
>
<path d="M14.6 7l-1.2-1L8 12l5.4 6 1.2-1-4.6-5z" />
</svg>
</NavigatorBackButton>
<span>{option.title}</span>
</div>
{children(option)}
</NavigatorScreen>
);
})}
</NavigatorProvider>
);
}

View File

@ -0,0 +1,68 @@
.vpf-control-wrap + .vpf-control-wrap-category_navigator {
padding-top: 10px;
border-top: 1px solid #e0e0e0;
}
.vpf-control-wrap-category_navigator:has(+ .vpf-control-wrap) {
padding-bottom: 4px;
border-bottom: 1px solid #e0e0e0;
}
.vpf-component-navigator-control {
// Fix overflow problem.
margin: -16px;
> .components-navigator-screen {
padding: 16px;
}
// Toggle.
.vpf-component-navigator-control-toggles > button {
display: flex;
gap: 6px;
width: 100%;
padding: 10px 0;
font-weight: 500;
svg {
margin-left: auto;
}
// Remove default Gutenberg box shadow.
&:focus:not(:focus-visible) {
box-shadow: none;
}
}
// Screen title.
.vpf-component-navigator-control-screen-title {
display: flex;
align-items: center;
font-weight: 500;
button {
padding: 6px;
margin-left: -12px;
// Remove default Gutenberg box shadow.
&:focus:not(:focus-visible) {
box-shadow: none;
}
}
}
// Screen content.
.components-navigator-screen > .components-panel__body {
padding: 16px 0;
padding-bottom: 0;
}
// Fix panel padding.
.components-panel__body {
border-top: none;
}
.components-panel__body:has(+ .vpf-component-collapse-control-toggle) {
padding-bottom: 0;
}
}

View File

@ -0,0 +1,21 @@
import './style.scss';
import classnames from 'classnames/dedupe';
import { Notice } from '@wordpress/components';
/**
* Component Class
*
* @param props
*/
export default function NoticeComponent(props) {
const { className, ...allProps } = props;
return (
<Notice
className={classnames('vpf-component-notice', className)}
{...allProps}
/>
);
}

View File

@ -0,0 +1,20 @@
@import "../../variables";
.vpf-component-notice {
margin-top: 20px;
margin-right: 0;
margin-left: 0;
&.is-info {
color: $blue-50;
background-color: rgba($blue-30, 0.1);
p {
color: inherit;
}
}
p:last-of-type {
margin-bottom: 0 !important;
}
}

View File

@ -0,0 +1,43 @@
import './style.scss';
import { Component } from '@wordpress/element';
/**
* Component Class
*/
export default class ProNote extends Component {
render() {
const {
title,
children,
contentBefore = '',
contentAfter = '',
} = this.props;
return (
<div className="vpf-pro-note-wrapper">
{contentBefore}
<div className="vpf-pro-note">
{title ? <h3>{title}</h3> : ''}
{children ? <div>{children}</div> : ''}
</div>
{contentAfter}
</div>
);
}
}
/**
* Button Component Class
*/
ProNote.Button = class ProNoteButton extends Component {
render() {
const { children } = this.props;
return (
<a className="vpf-pro-note-button" {...this.props}>
{children}
</a>
);
}
};

View File

@ -0,0 +1,83 @@
.vpf-pro-note {
position: relative;
max-width: 280px;
padding: 20px;
font-size: 13px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
color: #b65e96;
background: linear-gradient(to left, rgba(#743ad5, 20%), rgba(#d53a9d, 20%));
.vpf-setup-wizard-panel & {
margin: 0 auto;
}
&::before {
position: absolute;
top: 1px;
right: 1px;
bottom: 1px;
left: 1px;
z-index: 0;
display: block;
content: "";
background-color: #fff6fc;
}
> * {
position: relative;
z-index: 1;
}
h3 {
margin-top: 0;
margin-bottom: 0;
font-size: 13px;
font-weight: 600;
font-family: inherit;
color: #d53a9d;
text-transform: none;
}
p {
font-size: inherit !important;
}
ul {
margin: 0;
list-style: none;
li::before {
display: inline;
margin-bottom: 5px;
content: "- ";
}
}
.vpf-pro-note-description,
.components-base-control__help {
margin: 1em 0;
}
&-button {
display: inline-block;
padding: 7px 15px;
text-decoration: none;
background: linear-gradient(to left, #743ad5, #d53a9d);
border-radius: 3px;
transition: 0.2s filter ease, 0.2s transform ease;
&,
&:hover,
&:focus,
&:active {
color: #fff;
}
&:hover,
&:focus {
filter: contrast(1.5) drop-shadow(0 3px 3px rgba(213, 58, 157, 30%));
transform: translateY(-1px);
}
}
}

View File

@ -0,0 +1,412 @@
import './style.scss';
import selectStyles from 'gutenberg-react-select-styles';
import $ from 'jquery';
import rafSchd from 'raf-schd';
import Select, { components } from 'react-select';
import AsyncSelect from 'react-select/async';
import CreatableSelect from 'react-select/creatable';
import {
SortableContainer,
SortableElement,
sortableHandle,
} from 'react-sortable-hoc';
import { debounce } from 'throttle-debounce';
import { Component } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
const { Option } = components;
const { ajaxurl, VPGutenbergVariables } = window;
const cachedOptions = {};
const SortableSelect = SortableContainer(Select);
const SortableCreatableSelect = SortableContainer(CreatableSelect);
const SortableAsyncSelect = SortableContainer(AsyncSelect);
const SortableMultiValueLabel = sortableHandle((props) => (
<components.MultiValueLabel {...props} />
));
const SortableMultiValue = SortableElement((props) => {
// this prevents the menu from being opened/closed when the user clicks
// on a value to begin dragging it. ideally, detecting a click (instead of
// a drag) would still focus the control and toggle the menu, but that
// requires some magic with refs that are out of scope for this example
const onMouseDown = (e) => {
e.preventDefault();
e.stopPropagation();
};
const innerProps = { ...props.innerProps, onMouseDown };
return <components.MultiValue {...props} innerProps={innerProps} />;
});
function arrayMove(array, from, to) {
array = array.slice();
array.splice(to < 0 ? array.length + to : to, 0, array.splice(from, 1)[0]);
return array;
}
/**
* Component Class
*/
export default class VpfSelectControl extends Component {
constructor(...args) {
super(...args);
const { callback } = this.props;
this.state = {
options: {},
ajaxStatus: !!callback,
};
this.getOptions = this.getOptions.bind(this);
this.getDefaultValue = this.getDefaultValue.bind(this);
this.findValueData = this.findValueData.bind(this);
this.requestAjax = this.requestAjax.bind(this);
this.requestAjaxDebounce = debounce(300, rafSchd(this.requestAjax));
}
componentDidMount() {
const { callback } = this.props;
if (callback) {
this.requestAjax({}, (result) => {
if (result.options) {
this.setState({
options: result.options,
});
}
});
}
}
/**
* Get options list.
*
* @return {Object} - options list for React Select.
*/
getOptions() {
const { controlName } = this.props;
if (cachedOptions[controlName]) {
return cachedOptions[controlName];
}
return Object.keys(this.state.options).length
? this.state.options
: this.props.options;
}
/**
* Get default value in to support React Select attribute.
*
* @return {Object} - value object for React Select.
*/
getDefaultValue() {
const { value, isMultiple } = this.props;
let result = null;
if (isMultiple) {
if ((!value && typeof value !== 'string') || !value.length) {
return result;
}
result = [];
value.forEach((innerVal) => {
result.push(this.findValueData(innerVal));
});
} else {
if (!value && typeof value !== 'string') {
return result;
}
result = this.findValueData(value);
}
return result;
}
/**
* Find option data by value.
*
* @param {string} findVal - value.
*
* @return {Object | boolean} - value object.
*/
findValueData(findVal) {
let result = {
value: findVal,
label: findVal,
};
const options = this.getOptions();
// Find value in options.
if (options) {
Object.keys(options).forEach((val) => {
const data = options[val];
if (val === findVal) {
if (typeof data === 'string') {
result.label = data;
} else {
result = data;
}
}
});
}
return result;
}
/**
* Request AJAX dynamic data.
*
* @param {Object} additionalData - additional data for AJAX call.
* @param {Function} callback - callback.
* @param {boolean} useStateLoading - use state change when loading.
*/
requestAjax(
additionalData = {},
callback = () => {},
useStateLoading = true
) {
const { controlName, attributes } = this.props;
if (this.isAJAXinProgress) {
return;
}
this.isAJAXinProgress = true;
if (useStateLoading) {
this.setState({
ajaxStatus: 'progress',
});
}
const ajaxData = {
action: 'vp_dynamic_control_callback',
nonce: VPGutenbergVariables.nonce,
vp_control_name: controlName,
vp_attributes: attributes,
...additionalData,
};
$.ajax({
url: ajaxurl,
method: 'POST',
dataType: 'json',
data: ajaxData,
complete: (data) => {
const json = data.responseJSON;
if (callback && json.response) {
if (json.response.options) {
cachedOptions[controlName] = {
...cachedOptions[controlName],
...json.response.options,
};
}
callback(json.response);
}
if (useStateLoading) {
this.setState({
ajaxStatus: true,
});
}
this.isAJAXinProgress = false;
},
});
}
/**
* Prepare options for React Select structure.
*
* @param {Object} options - options object.
*
* @return {Object} - prepared options.
*/
prepareOptions(options) {
return Object.keys(options || {}).map((val) => {
const option = options[val];
let result = {
value: val,
label: options[val],
};
if (typeof option === 'object') {
result = { ...option };
}
return result;
});
}
render() {
const { onChange, isMultiple, isSearchable, isCreatable, callback } =
this.props;
const { ajaxStatus } = this.state;
const isAsync = !!callback && isSearchable;
const isLoading = ajaxStatus && ajaxStatus === 'progress';
const selectProps = {
// Test opened menu items:
// menuIsOpen: true,
className: 'vpf-component-select',
styles: {
...selectStyles,
menuPortal: (styles) => {
return {
...styles,
zIndex: 1000000,
};
},
},
menuPortalTarget: document.body,
components: {
Option(optionProps) {
const { data } = optionProps;
return (
<Option {...optionProps}>
{typeof data.img !== 'undefined' ? (
<div className="vpf-component-select-option-img">
{data.img ? (
<img src={data.img} alt={data.label} />
) : (
''
)}
</div>
) : (
''
)}
<span className="vpf-component-select-option-label">
{data.label}
</span>
{data.category ? (
<div className="vpf-component-select-option-category">
{data.category}
</div>
) : (
''
)}
</Option>
);
},
},
value: this.getDefaultValue(),
options: this.prepareOptions(this.getOptions()),
onChange(val) {
if (isMultiple) {
if (Array.isArray(val)) {
const result = [];
val.forEach((innerVal) => {
result.push(innerVal ? innerVal.value : '');
});
onChange(result);
} else {
onChange([]);
}
} else {
onChange(val ? val.value : '');
}
},
isMulti: isMultiple,
isSearchable,
isLoading,
isClearable: false,
placeholder: isSearchable
? __('Type to search…', 'visual-portfolio')
: __('Select…', 'visual-portfolio'),
};
// Multiple select.
if (isMultiple) {
selectProps.useDragHandle = true;
selectProps.axis = 'xy';
selectProps.onSortEnd = ({ oldIndex, newIndex }) => {
const newValue = arrayMove(
this.getDefaultValue(),
oldIndex,
newIndex
);
selectProps.onChange(newValue);
};
selectProps.distance = 4;
// small fix for https://github.com/clauderic/react-sortable-hoc/pull/352:
selectProps.getHelperDimensions = ({ node }) =>
node.getBoundingClientRect();
selectProps.components.MultiValue = SortableMultiValue;
selectProps.components.MultiValueLabel = SortableMultiValueLabel;
// prevent closing options dropdown after select.
selectProps.closeMenuOnSelect = false;
}
// Creatable select.
if (isCreatable) {
selectProps.placeholder = __(
'Type and press Enter…',
'visual-portfolio'
);
selectProps.isSearchable = true;
if (isMultiple) {
return <SortableCreatableSelect {...selectProps} />;
}
return <CreatableSelect {...selectProps} />;
}
// Async select.
if (isAsync) {
selectProps.loadOptions = (inputValue, cb) => {
this.requestAjaxDebounce(
{ q: inputValue },
(result) => {
const newOptions = [];
if (result && result.options) {
Object.keys(result.options).forEach((k) => {
newOptions.push(result.options[k]);
});
}
cb(newOptions.length ? newOptions : null);
},
false
);
};
selectProps.cacheOptions = true;
selectProps.defaultOptions = selectProps.options;
delete selectProps.options;
delete selectProps.isLoading;
if (isMultiple) {
return <SortableAsyncSelect {...selectProps} />;
}
return <AsyncSelect {...selectProps} />;
}
// Default select.
if (isMultiple) {
return <SortableSelect {...selectProps} />;
}
return <Select {...selectProps} />;
}
}

View File

@ -0,0 +1,35 @@
.vpf-component-select {
margin-bottom: 8px;
}
.vpf-component-select-option-img {
position: relative;
display: block;
width: 30px;
height: 30px;
margin-right: 10px;
overflow: hidden;
background-color: rgba(0, 0, 0, 10%);
border-radius: 3px;
img {
position: absolute;
top: 0;
left: 0;
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
}
.vpf-component-select-option-label {
margin-right: auto;
}
.vpf-component-select-option-category {
padding: 2px 10px;
margin-left: 10px;
background-color: rgba(0, 0, 0, 10%);
border-radius: 3px;
}

View File

@ -0,0 +1,342 @@
import './style.scss';
import { Button, ToggleControl } from '@wordpress/components';
import { useCallback, useEffect, useState } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import ControlsRender from '../controls-render';
import StepsWizard from './steps-wizard';
const { plugin_name: pluginName } = window.VPGutenbergVariables;
const NOTICE_LIMIT = parseInt(
window.VPGutenbergVariables.items_count_notice_limit,
10
);
function renderControls(props, category) {
return <ControlsRender {...props} category={category} isSetupWizard />;
}
function hasLayoutElement(element, attributes) {
const { layout_elements: layoutElements } = attributes;
const checkIn = element === 'filter' ? 'top' : 'bottom';
return (
typeof layoutElements[checkIn] !== 'undefined' &&
layoutElements[checkIn]?.elements.includes(element)
);
}
function toggleLayoutElement(element, attributes) {
const { layout_elements: layoutElements } = attributes;
const checkIn = element === 'filter' ? 'top' : 'bottom';
if (
typeof layoutElements[checkIn] === 'undefined' ||
!layoutElements[checkIn]?.elements
) {
return layoutElements;
}
const result = JSON.parse(JSON.stringify(layoutElements));
if (hasLayoutElement(element, attributes)) {
result[checkIn].elements = [];
} else {
result[checkIn].elements = [element];
}
return result;
}
/**
* Component Class
*
* @param props
*/
export default function SetupWizard(props) {
const { attributes, setAttributes } = props;
const {
align,
content_source: contentSource,
items_click_action: clickAction,
layout,
images,
} = attributes;
const [step, setStep] = useState(0);
const [allowNextStep, setAllowNextStep] = useState(false);
const maxSteps = 2.5;
// Add startup attributes.
useEffect(() => {
if (!align && !contentSource) {
setAttributes({ align: 'wide' });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Set some starter attributes for different content sources.
// And hide the setup wizard.
const setStarterAttributes = useCallback(() => {
let newAttributes = {};
switch (contentSource) {
case 'images':
// Hide setup wizard once user select images.
if (images && images.length) {
newAttributes = {
...newAttributes,
items_count: -1,
items_click_action: 'popup_gallery',
};
// Add infinite scroll to the gallery when user adds a lot of images.
if (layout !== 'slider' && images.length > NOTICE_LIMIT) {
newAttributes = {
...newAttributes,
items_count: NOTICE_LIMIT,
layout_elements: {
top: {
elements: [],
align: 'center',
},
items: {
elements: ['items'],
},
bottom: {
elements: ['pagination'],
align: 'center',
},
},
pagination: 'infinite',
pagination_hide_on_end: true,
};
}
}
break;
case 'post-based':
case 'social-stream':
newAttributes = {
...newAttributes,
layout_elements: {
top: {
elements: [],
align: 'center',
},
items: {
elements: ['items'],
},
bottom: {
elements: ['pagination'],
align: 'center',
},
},
};
break;
// no default
}
// Prepare better default settings for Popup.
// We can't change defaults of registered controls because it may break existing user galleries.
// This is why we change it here, in the Setup Wizard.
newAttributes = {
...newAttributes,
items_click_action_popup_title_source:
contentSource === 'post-based' ? 'title' : 'item_title',
items_click_action_popup_description_source:
contentSource === 'post-based'
? 'description'
: 'item_description',
items_click_action_popup_deep_link_pid: 'filename',
};
setAttributes(newAttributes);
setAllowNextStep(true);
}, [contentSource, images, layout, setAttributes]);
useEffect(() => {
if (contentSource) {
setStarterAttributes();
}
// We have to check for contentSource change here because we don't want to run this on every render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [contentSource]);
return (
<div className={`vpf-setup-wizard vpf-setup-wizard-step-${step}`}>
<StepsWizard step={step}>
{/* Step 0: Content Source */}
<StepsWizard.Step>
<div className="vpf-setup-wizard-title">{pluginName}</div>
<div
className="vpf-setup-wizard-description"
dangerouslySetInnerHTML={{
__html: __(
'Set the common settings in the setup wizard,<br />more options will be available in the block settings.<br />Select the Content Source first:',
'visual-portfolio'
),
}}
/>
{renderControls(props, 'content-source')}
{renderControls(props, 'content-source-images')}
{renderControls(props, 'content-source-social-stream')}
</StepsWizard.Step>
{/* Step 1: Items Style */}
<StepsWizard.Step>
<div className="vpf-setup-wizard-title">
{__('Items Style', 'visual-portfolio')}
</div>
<div
className="vpf-setup-wizard-description"
dangerouslySetInnerHTML={{
__html: __(
'Select one of the featured styles to get started.<br />More style settings will be available in the block settings.',
'visual-portfolio'
),
}}
/>
{renderControls(props, 'items-style')}
</StepsWizard.Step>
{/* Step 2: Layout Elements */}
<StepsWizard.Step>
<div className="vpf-setup-wizard-title">
{__('Additional Settings', 'visual-portfolio')}
</div>
<div className="vpf-setup-wizard-layout-elements">
<div>
<ToggleControl
label={__('Filter', 'visual-portfolio')}
checked={hasLayoutElement('filter', attributes)}
onChange={() => {
setAttributes({
layout_elements: toggleLayoutElement(
'filter',
attributes
),
});
}}
/>
</div>
<div>
<ToggleControl
label={__('Pagination', 'visual-portfolio')}
checked={hasLayoutElement(
'pagination',
attributes
)}
onChange={() => {
setAttributes({
layout_elements: toggleLayoutElement(
'pagination',
attributes
),
});
}}
/>
</div>
<div>
<ToggleControl
label={__('Popup Gallery', 'visual-portfolio')}
checked={clickAction === 'popup_gallery'}
onChange={() => {
setAttributes({
items_click_action:
clickAction === 'popup_gallery'
? 'url'
: 'popup_gallery',
});
}}
/>
</div>
</div>
</StepsWizard.Step>
</StepsWizard>
{/* Pagination */}
<div className="vpf-setup-wizard-pagination">
{contentSource ? (
<>
<div className="vpf-setup-wizard-pagination-button">
<Button
isLink
onClick={() => {
// Skip Setup
if (step === 0) {
setAttributes({
setup_wizard: '',
content_source:
attributes.contentSource ||
'images',
});
// Previous Step
} else {
setStep(step - 1);
}
}}
>
{step === 0
? __('Skip Setup', 'visual-portfolio')
: __('Previous Step', 'visual-portfolio')}
</Button>
</div>
<div className="vpf-setup-wizard-pagination-progress">
<div
style={{
width: `${Math.max(
15,
Math.min(100, (100 * step) / maxSteps)
)}%`,
}}
/>
</div>
<div className="vpf-setup-wizard-pagination-button vpf-setup-wizard-pagination-button-end">
<Button
isPrimary
disabled={!allowNextStep}
onClick={() => {
if (step === 2) {
setAttributes({ setup_wizard: '' });
} else {
setStep(step + 1);
}
}}
>
{__('Continue', 'visual-portfolio')}
<svg
width="14"
height="14"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
style={{ marginLeft: '5px' }}
>
<path
d="M3 10H17"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
<path
d="M11 4L17 10L11 16"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</svg>
</Button>
</div>
</>
) : null}
</div>
</div>
);
}

View File

@ -0,0 +1,92 @@
import classnames from 'classnames/dedupe';
import { debounce } from 'throttle-debounce';
import { useCallback, useEffect, useRef, useState } from '@wordpress/element';
const { ResizeObserver, MutationObserver } = window;
function stepsWizard(props) {
const { step, children } = props;
const $ref = useRef();
const [newStep, setNewStep] = useState(step);
const [height, setHeight] = useState(0);
const maybeUpdateHeight = useCallback(() => {
let newHeight = 0;
$ref.current.childNodes.forEach(($child) => {
const styles = window.getComputedStyle($child);
const margin =
parseFloat(styles.marginTop) + parseFloat(styles.marginBottom);
newHeight += Math.ceil($child.offsetHeight + margin);
});
setHeight(`${newHeight}px`);
}, [$ref]);
useEffect(() => {
if (step !== newStep) {
setNewStep(step);
}
}, [step, newStep]);
useEffect(() => {
maybeUpdateHeight();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [newStep]);
useEffect(() => {
const $element = $ref.current;
const calculateHeight = debounce(100, () => {
maybeUpdateHeight();
});
// Resize observer is used to properly set height
// when selected images, saved post and reloaded page.
const resizeObserver = new ResizeObserver(calculateHeight);
const mutationObserver = new MutationObserver(calculateHeight);
resizeObserver.observe($element);
mutationObserver.observe($element, {
attributes: true,
characterData: true,
childList: true,
subtree: true,
attributeOldValue: true,
characterDataOldValue: true,
});
return () => {
resizeObserver.disconnect();
mutationObserver.disconnect();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [$ref.current]);
return typeof children[newStep] !== 'undefined' ? (
<div
ref={$ref}
className={classnames(
'vpf-component-steps-wizard',
step !== newStep
? `vpf-component-steps-wizard-animate-${
newStep > step ? 'left' : 'right'
}`
: false
)}
style={height ? { height } : {}}
>
{children[newStep]}
</div>
) : null;
}
stepsWizard.Step = function (props) {
const { children } = props;
return children;
};
export default stepsWizard;

View File

@ -0,0 +1,286 @@
@use "sass:color";
$brand_color: #2540cc !default;
.vpf-setup-wizard {
--wp-admin-theme-color: #{$brand_color};
padding: 20px;
padding-top: 50px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
color: #000;
text-align: center;
background-color: #fff;
border: 1px solid #7e7e7e;
border-radius: 6px;
// Hide default Gutenberg outline and use our own.
.wp-block-visual-portfolio-block.is-selected:has(> &) {
&::after {
content: none !important;
}
.vpf-setup-wizard {
border-color: $brand_color;
}
}
> div {
max-width: 520px;
margin: 0 auto;
}
.vpf-setup-wizard-title {
margin-bottom: 15px;
font-size: 18px;
font-weight: 500;
}
.vpf-setup-wizard-description {
font-size: 14px;
opacity: 0.75;
}
.vpf-setup-wizard-panel {
margin-top: 25px;
}
// Pagination.
.vpf-setup-wizard-pagination {
display: flex;
align-items: center;
justify-content: space-between;
max-width: none;
margin-top: 40px;
}
.vpf-setup-wizard-pagination-progress {
width: 130px;
height: 2px;
background-color: #eaeaea;
> div {
height: 2px;
background-color: $brand_color;
transition: 0.3s width ease;
}
}
.vpf-setup-wizard-pagination-button {
display: flex;
justify-content: flex-start;
min-width: 100px;
.components-button {
height: 34px;
padding: 6px 20px;
&.is-link {
padding: 0;
font-weight: 400;
color: #b9b9b9;
text-decoration: none;
&:hover,
&:focus {
color: $brand_color;
}
}
}
.components-button.is-primary {
background-color: $brand_color;
&:disabled {
background-color: $brand_color;
border-color: $brand_color;
svg {
color: #fff;
opacity: 0.4;
}
}
&:hover:not(:disabled) {
background: color.adjust($brand_color, $lightness: -10%);
}
&:focus:not(:disabled) {
box-shadow: inset 0 0 0 1px #fff, 0 0 0 1.5px $brand_color;
}
}
}
.vpf-setup-wizard-pagination-button-end {
justify-content: flex-end;
}
// Icons Selector
.vpf-component-icon-selector {
display: flex;
justify-content: center;
.vpf-component-icon-selector-item {
padding: 15px 20px;
background: none !important;
border-color: #fff;
svg {
width: 44px;
max-width: 44px;
height: 44px;
color: #2b2b2b;
transition: 0.2s color;
}
span {
font-size: 0.95em;
font-weight: 500;
text-transform: initial;
transition: 0.2s color;
}
&:hover,
&:focus {
svg,
span {
color: #1e1e1e;
}
}
&.vpf-component-icon-selector-item-active {
border-color: $brand_color !important;
box-shadow: 0 0 0 1px $brand_color !important;
svg,
span {
color: $brand_color;
}
}
}
}
// Step 0: Content Source Selector
&-step-0 {
// Gallery Control
.vpf-component-gallery-control .vpf-component-gallery-control-items {
grid-template-columns: 1fr 1fr 1fr 1fr 1fr 1fr;
}
}
// Step 1: Items Style Selector
&-step-1 .vpf-component-icon-selector {
gap: 20px;
padding-bottom: 30px;
margin-top: 35px;
// Limit to 4 items.
> .vpf-component-icon-selector-item:nth-child(4) ~ * {
display: none;
}
.vpf-component-icon-selector-item {
position: relative;
flex: 1;
padding: 0;
background: #eee !important;
border: none !important;
border-radius: 6px;
&::before {
display: block;
width: 100%;
padding-top: 100%;
content: "";
}
img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: contain;
border-radius: 6px;
}
svg {
position: absolute;
top: 50%;
left: 50%;
transform: translateX(-50%) translateY(-50%);
}
&:hover,
&:focus {
border-color: none !important;
box-shadow: 0 0 0 1.5px #fff, 0 0 0 3.5px #000 !important;
}
&.vpf-component-icon-selector-item-active {
border-color: none !important;
box-shadow: 0 0 0 1.5px #fff, 0 0 0 3.5px $brand_color !important;
}
> span {
position: absolute;
top: calc(100% + 5px);
opacity: 0;
transition: 0.2s ease;
transform: translateY(-4px);
}
&:hover,
&:focus,
&.vpf-component-icon-selector-item-active {
> span {
opacity: 1;
transform: translateY(0);
}
}
}
}
// Step 2: Layout Elements
&-step-2 {
.vpf-setup-wizard-layout-elements {
> div {
padding: 25px 0;
+ div {
border-top: 1px solid #ececec;
}
}
.components-toggle-control__label {
font-size: 16px;
font-weight: 500;
}
}
}
.spinner,
.components-base-control,
.components-base-control__field {
margin: 0;
}
}
// Steps Wizard.
.vpf-component-steps-wizard {
opacity: 1;
transition: 0.3s opacity, 0.3s transform, 0.3s height;
transform: translateX(0);
&-animate-right {
opacity: 0;
transition: 0.3s height;
transform: translateX(40px);
}
&-animate-left {
opacity: 0;
transition: 0.3s height;
transform: translateX(-40px);
}
}

View File

@ -0,0 +1,240 @@
import './style.scss';
import {
closestCenter,
DndContext,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import classnames from 'classnames/dedupe';
import { Button } from '@wordpress/components';
import { Component } from '@wordpress/element';
const SortableItem = function ({ id, element, sourceOptions, items, props }) {
const { allowDisablingOptions, onChange } = props;
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
isSorting,
} = useSortable({
id,
});
const style = {
transform: CSS.Translate.toString(transform),
transition: isSorting ? transition : '',
};
const label = sourceOptions[element];
return (
<li
className={classnames(
'vpf-component-sortable-item',
isDragging ? 'vpf-component-sortable-item-dragging' : ''
)}
ref={setNodeRef}
style={style}
>
<span {...attributes} {...listeners}>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10 4.99976H8V6.99976H10V4.99976Z"
fill="currentColor"
/>
<path
d="M10 10.9998H8V12.9998H10V10.9998Z"
fill="currentColor"
/>
<path
d="M10 16.9998H8V18.9998H10V16.9998Z"
fill="currentColor"
/>
<path
d="M16 4.99976H14V6.99976H16V4.99976Z"
fill="currentColor"
/>
<path
d="M16 10.9998H14V12.9998H16V10.9998Z"
fill="currentColor"
/>
<path
d="M16 16.9998H14V18.9998H16V16.9998Z"
fill="currentColor"
/>
</svg>
</span>
{label}
{allowDisablingOptions ? (
<Button
className="vpf-component-sortable-delete"
onClick={() => {
const updateValue = [...items];
const findIndex = items.indexOf(element);
updateValue.splice(findIndex, 1);
onChange(updateValue);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth="2"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M20 12H4"
/>
</svg>
</Button>
) : null}
</li>
);
};
const SortableList = function ({
items,
sourceOptions,
classes,
onSortEnd,
props,
}) {
const sensors = useSensors(useSensor(PointerSensor));
return (
<ul className={classes}>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={(event) => {
const { active, over } = event;
if (active.id !== over.id) {
onSortEnd(
items.indexOf(active.id),
items.indexOf(over.id)
);
}
}}
>
<SortableContext
items={items}
strategy={verticalListSortingStrategy}
>
{items.map((value) => (
<SortableItem
key={`item-${value}`}
id={value}
element={value}
sourceOptions={sourceOptions}
props={props}
items={items}
/>
))}
</SortableContext>
</DndContext>
</ul>
);
};
/**
* Component Class
*/
export default class SortableControl extends Component {
render() {
const { options, defaultOptions, allowDisablingOptions, onChange } =
this.props;
let { value } = this.props;
if (typeof value === 'undefined') {
value = typeof defaultOptions !== 'undefined' ? defaultOptions : [];
}
const disabledOptions = Object.keys(options).filter(
(findValue) => !value.includes(findValue)
);
const classes = classnames(
'vpf-component-sortable',
disabledOptions.length > 0
? 'vpf-dragging-has-disabled-options'
: ''
);
return (
<div>
<SortableList
items={value}
sourceOptions={options}
classes={classes}
props={this.props}
onSortEnd={(oldIndex, newIndex) => {
const updateValue = arrayMove(
value,
oldIndex,
newIndex
);
onChange(updateValue);
}}
/>
{disabledOptions.length > 0 ? (
<ul className="vpf-component-sortable-disabled">
{disabledOptions.map((el) => (
<li key={`disabled-item-${el}`}>
{allowDisablingOptions ? (
<Button
className="vpf-component-sortable-add"
onClick={() => {
const updateValue = [...value];
updateValue.push(el);
onChange(updateValue);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth="2"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 4v16m8-8H4"
/>
</svg>
</Button>
) : null}
{options[el]}
</li>
))}
</ul>
) : null}
</div>
);
}
}

View File

@ -0,0 +1,120 @@
.vpf-component-sortable,
.vpf-component-sortable-disabled {
padding: 4px 0;
margin: 0;
overflow: hidden;
list-style: none;
border: 1px solid #7e8993;
border-radius: 4px;
}
.vpf-dragging-has-disabled-options {
padding-bottom: 0;
border-bottom: none;
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
}
.vpf-component-sortable > li {
position: relative;
display: flex;
align-items: center;
padding: 3px 8px;
margin: 0;
list-style: none;
&:hover {
background-color: #f7f7f7;
}
> span {
display: inline-flex;
padding: 4px 0;
margin: 0;
margin-right: 6px;
vertical-align: middle;
cursor: grab;
border-radius: 3px;
&:hover {
background-color: #e1e1e1;
}
svg {
width: 20px;
height: 20px;
}
}
&:hover .vpf-component-sortable-delete {
opacity: 1;
}
&.vpf-component-sortable-item-dragging {
z-index: 1;
background-color: #f7f7f7;
.vpf-component-sortable-delete {
opacity: 0;
}
}
}
.vpf-component-sortable + ul.vpf-component-sortable-disabled {
padding: 0 0 4px;
margin: 0;
list-style: none;
background-color: #f6f6f6;
border: 1px solid #7e8993;
border-top-color: #dcdcdc;
border-radius: 0 0 4px 4px;
> li {
display: flex;
align-items: center;
height: 34px;
padding: 3px 8px;
margin: 0;
color: #0000007a;
list-style: none;
&:hover {
background-color: #f7f7f7;
}
}
}
.vpf-component-sortable-delete,
.vpf-component-sortable-add {
display: inline-flex;
height: 28px;
vertical-align: middle;
cursor: pointer;
border-radius: 3px;
&:hover {
background-color: #e1e1e1;
}
svg {
width: 11px;
height: auto;
}
}
.vpf-component-sortable-delete {
padding: 0 5px;
margin: 0;
margin-left: auto;
opacity: 0;
svg {
width: 9px;
}
}
.vpf-component-sortable-add {
padding: 0 4px;
margin-right: 7px;
color: #000;
}

View File

@ -0,0 +1,17 @@
import './style.scss';
import { Spinner } from '@wordpress/components';
import { Component } from '@wordpress/element';
/**
* Component Class
*/
export default class SpinnerComponent extends Component {
render() {
return (
<div className="vpf-component-spinner">
<Spinner />
</div>
);
}
}

View File

@ -0,0 +1,4 @@
.vpf-component-spinner .components-spinner {
float: none;
margin-left: 0;
}

View File

@ -0,0 +1,20 @@
import { useEffect } from '@wordpress/element';
/**
* Render dynamic styles for editor.
*
* @param root0
* @param root0.children
* @return {null} nothing.
*/
export default function StylesRender({ children }) {
useEffect(() => {
const node = document.createElement('style');
node.innerHTML = children;
document.body.appendChild(node);
return () => document.body.removeChild(node);
}, [children]);
return null;
}

View File

@ -0,0 +1,29 @@
import './style.scss';
import { TabPanel } from '@wordpress/components';
import { Component, RawHTML } from '@wordpress/element';
/**
* Component Class
*/
export default class TabsControl extends Component {
render() {
const { onChange, children, options } = this.props;
return (
<TabPanel
className="vpf-component-tabs-control"
onSelect={onChange}
tabs={options.map((item) => {
return {
name: item.category,
title: item.title,
icon: item.icon ? <RawHTML>{item.icon}</RawHTML> : null,
};
})}
>
{children}
</TabPanel>
);
}
}

View File

@ -0,0 +1,8 @@
.vpf-component-tabs-control {
margin: 0 -16px;
margin-bottom: -26px;
.components-tab-panel__tabs-item {
flex: 1;
}
}

View File

@ -0,0 +1,121 @@
import './style.scss';
import classnames from 'classnames/dedupe';
import { Button } from '@wordpress/components';
import { Component } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import MasonryWrapper from '../masonry-wrapper';
import StylesRender from '../styles-render';
import ToggleModal from '../toggle-modal';
/**
* Component Class
*/
export default class TilesSelector extends Component {
constructor(...args) {
super(...args);
this.renderPreview = this.renderPreview.bind(this);
}
renderPreview(tilesType) {
const settings = tilesType.split(/[:|]/);
const selector = `[data-tiles-preview="${tilesType}"]`;
let styles = '';
// remove last empty item
if (
typeof settings[settings.length - 1] !== 'undefined' &&
!settings[settings.length - 1]
) {
settings.pop();
}
// get columns number
const columns = parseInt(settings[0], 10) || 1;
settings.shift();
// set columns
styles += `${selector} .vpf-tiles-preview-item-wrap { width: ${
100 / columns
}%; }`;
// set items sizes
if (settings && settings.length) {
for (let k = 0; k < settings.length; k += 1) {
const size = settings[k].split(',');
const w = parseFloat(size[0]) || 1;
const h = parseFloat(size[1]) || 1;
let itemSelector = '.vpf-tiles-preview-item-wrap';
if (settings.length > 1) {
itemSelector += `:nth-of-type(${settings.length}n+${
k + 1
})`;
}
if (w && w !== 1) {
styles += `${selector} ${itemSelector} { width: ${
(w * 100) / columns
}%; }`;
}
styles += `${selector} ${itemSelector} .vpf-tiles-preview-item::after { padding-top: ${
h * 100
}%; }`;
}
}
return (
<>
<StylesRender>{styles}</StylesRender>
<MasonryWrapper
data-tiles-preview={tilesType}
options={{
transitionDuration: 0,
}}
>
{Array(...Array(4 * columns)).map((i) => (
<div key={i} className="vpf-tiles-preview-item-wrap">
<div className="vpf-tiles-preview-item" />
</div>
))}
</MasonryWrapper>
</>
);
}
render() {
const { value, options, onChange } = this.props;
return (
<div className="vpf-component-tiles-selector">
<ToggleModal
modalTitle={__('Tiles', 'visual-portfolio')}
buttonLabel={__('Edit Tiles', 'visual-portfolio')}
>
<div className="vpf-component-tiles-selector-items">
{options.map((data) => (
<Button
key={data.value}
onClick={() => onChange(data.value)}
className={classnames(
'vpf-tiles-preview-button',
value === data.value
? 'vpf-tiles-preview-button-active'
: ''
)}
>
{this.renderPreview(data.value)}
</Button>
))}
</div>
</ToggleModal>
<div className="vpf-tiles-preview-button">
{this.renderPreview(value)}
</div>
</div>
);
}
}

View File

@ -0,0 +1,91 @@
@import "../icons-selector/selector-placeholder.scss";
$tiles_selector_gap: 5px !default;
$tiles_selector_button_padding: 10px !default;
.vpf-component-tiles-selector,
.vpf-component-tiles-selector-items {
.vpf-tiles-preview-button {
@extend %vpf-icons-selector-item;
position: relative;
display: flex;
flex-direction: row;
align-items: flex-start;
width: 33.33%;
height: auto;
aspect-ratio: 1 / 1.25;
padding: 0;
overflow: hidden;
background: #efefef !important;
}
.vpf-tiles-preview-button-active {
@extend %vpf-icons-selector-item-active;
}
[data-tiles-preview] {
flex: 1;
margin-top: $tiles_selector_button_padding - $tiles_selector_gap;
margin-right: $tiles_selector_button_padding;
margin-left: $tiles_selector_button_padding - $tiles_selector_gap;
}
.vpf-tiles-preview-item-wrap {
float: left;
padding-top: $tiles_selector_gap;
padding-left: $tiles_selector_gap;
}
.vpf-tiles-preview-item {
position: relative;
background-color: #cacaca;
border-radius: 2px;
&::after {
display: block;
content: "";
}
}
}
.vpf-component-tiles-selector {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
.components-button {
display: inline-flex;
height: auto;
padding: 10px;
margin-right: auto;
color: #212121 !important;
text-align: left;
white-space: normal;
background: #f3f3f3;
border-color: #b3b3b3;
}
.vpf-tiles-preview-button {
flex: 0 0 80px;
width: 80px;
margin-left: 10px;
pointer-events: none;
background: #fff !important;
border: 1px solid #b3b3b3;
}
}
.vpf-component-tiles-selector-items {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
grid-gap: 15px;
width: 510px;
max-width: 100%;
margin: 12px 0;
.vpf-tiles-preview-button {
float: left;
width: 100%;
}
}

View File

@ -0,0 +1,60 @@
import './style.scss';
import {
__experimentalToggleGroupControl,
__experimentalToggleGroupControlOption,
ToggleGroupControl as __stableToggleGroupControl,
ToggleGroupControlOption as __stableToggleGroupControlOption,
} from '@wordpress/components';
import { Fragment, useState } from '@wordpress/element';
const ToggleGroupControl =
__stableToggleGroupControl || __experimentalToggleGroupControl;
const ToggleGroupControlOption =
__stableToggleGroupControlOption || __experimentalToggleGroupControlOption;
/**
* Component Class
*
* @param props
*/
export default function ToggleGroupCustomControl(props) {
const { children, options } = props;
const [collapsed, setCollapsed] = useState(options[0].category);
return (
<div className="vpf-component-toggle-group-control">
<ToggleGroupControl
className="vpf-component-toggle-group-control-toggle"
value={collapsed}
onChange={(val) => {
setCollapsed(val);
}}
isBlock
>
{options.map((option) => {
return (
<ToggleGroupControlOption
key={option.category}
value={option.category}
label={option.title}
/>
);
})}
</ToggleGroupControl>
{options.map((option) => {
if (collapsed === option.category) {
return (
<Fragment key={option.category}>
{children(option)}
</Fragment>
);
}
return null;
})}
</div>
);
}

View File

@ -0,0 +1,10 @@
.vpf-component-toggle-group-control {
> .components-base-control > .components-base-control__field {
margin-top: -8px;
margin-bottom: 0;
}
> .components-panel__body {
padding: 0;
}
}

View File

@ -0,0 +1,52 @@
import './style.scss';
import classnames from 'classnames/dedupe';
import { Button, Modal } from '@wordpress/components';
import { Component } from '@wordpress/element';
/**
* Component Class
*/
export default class ToggleModal extends Component {
constructor(...args) {
super(...args);
this.state = {
isOpened: false,
};
}
render() {
const { children, modalTitle, buttonLabel, size } = this.props;
const { isOpened } = this.state;
return (
<>
<Button
isSecondary
onClick={() => this.setState({ isOpened: !isOpened })}
>
{buttonLabel}
</Button>
{isOpened ? (
<Modal
title={modalTitle}
onRequestClose={() =>
this.setState({ isOpened: !isOpened })
}
className={classnames(
'vpf-component-modal',
size ? `vpf-component-modal-size-${size}` : ''
)}
>
{children}
</Modal>
) : (
''
)}
</>
);
}
}

View File

@ -0,0 +1,21 @@
.vpf-component-modal {
&.vpf-component-modal-size-sm {
width: 100%;
max-width: 360px;
}
@media (min-width: 600px) {
&.vpf-component-modal-size-md {
width: 100%;
max-width: 600px;
}
}
// size
@media (min-width: 840px) {
&.vpf-component-modal-size-lg {
width: 100%;
max-width: 800px;
}
}
}