first commit

This commit is contained in:
Ryan Ariana
2024-05-06 11:04:37 +07:00
commit aee061ddba
7322 changed files with 2918816 additions and 0 deletions

View File

@@ -0,0 +1,99 @@
<?php
namespace ElementorPro\Core\App;
use Elementor\Core\Base\App as BaseApp;
use ElementorPro\Plugin;
use ElementorPro\Core\App\Modules\SiteEditor\Module as SiteEditor;
use ElementorPro\Core\App\Modules\KitLibrary\Module as KitLibrary;
use ElementorPro\Core\App\Modules\Onboarding\Module as Onboarding;
use ElementorPro\Core\App\Modules\ImportExport\Module as ImportExport;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
class App extends BaseApp {
/**
* Get module name.
*
* Retrieve the module name.
*
* @since 3.0.0
* @access public
*
* @return string Module name.
*/
public function get_name() {
return 'app-pro';
}
public function init() {
$this->enqueue_assets();
}
public function set_menu_url() {
Plugin::elementor()->app->set_settings( 'menu_url', Plugin::elementor()->app->get_base_url() . '#/site-editor' );
}
protected function get_init_settings() {
return [
'baseUrl' => $this->get_assets_base_url(),
];
}
protected function get_assets_base_url() {
return ELEMENTOR_PRO_URL;
}
private function enqueue_assets() {
wp_enqueue_style(
'elementor-pro-app',
$this->get_css_assets_url( 'app', null, 'default', true ),
[
'elementor-app',
'select2',
],
ELEMENTOR_VERSION
);
wp_enqueue_script(
'elementor-pro-app',
$this->get_js_assets_url( 'app' ),
[
'wp-i18n',
'elementor-app-packages',
'elementor-common',
'select2',
],
ELEMENTOR_PRO_VERSION,
true
);
wp_set_script_translations( 'elementor-pro-app', 'elementor-pro' );
}
private function enqueue_config() {
// If script didn't loaded, config is still relevant, enqueue without a file.
if ( ! wp_script_is( 'elementor-pro-app' ) ) {
wp_register_script( 'elementor-pro-app', false, [], ELEMENTOR_PRO_VERSION );
wp_enqueue_script( 'elementor-pro-app' );
}
$this->print_config( 'elementor-pro-app' );
}
public function __construct() {
$this->add_component( 'site-editor', new SiteEditor() );
$this->add_component( 'kit-library', new KitLibrary() );
$this->add_component( 'onboarding', new Onboarding() );
$this->add_component( 'import-export', new ImportExport() );
add_action( 'elementor/app/init', [ $this, 'init' ] );
add_action( 'elementor/common/after_register_scripts', function () {
$this->enqueue_config();
} );
add_action( 'elementor/init', [ $this, 'set_menu_url' ] );
}
}

View File

@@ -0,0 +1,22 @@
import ConnectButtonUI from '../ui/connect-button';
import { htmlDecodeTextContent, replaceUtmPlaceholders } from '../utils';
export default function useFeatureLock( featureName ) {
const appConfig = elementorAppProConfig[ featureName ] ?? {},
isLocked = appConfig.lock?.is_locked ?? false;
const buttonText = htmlDecodeTextContent( appConfig.lock?.button.text );
const buttonLink = replaceUtmPlaceholders(
appConfig.lock?.button.url ?? '',
appConfig.utms ?? {},
);
const ConnectButton = () => (
<ConnectButtonUI text={ buttonText } url={ buttonLink } />
);
return {
isLocked,
ConnectButton,
};
}

View File

@@ -0,0 +1,3 @@
import Module from '../../modules/site-editor/assets/js/site-editor';
new Module();

View File

@@ -0,0 +1,48 @@
import * as React from 'react';
import { useRef, useEffect } from 'react';
import { Button } from '@elementor/app-ui';
import { arrayToClassName } from '../utils.js';
const ConnectButton = ( props ) => {
const className = arrayToClassName( [
'e-app-connect-button',
props.className,
] );
const buttonRef = useRef( null );
useEffect( () => {
if ( ! buttonRef.current ) {
return;
}
jQuery( buttonRef.current ).elementorConnect();
}, [] );
return (
<Button
{ ...props }
elRef={ buttonRef }
className={ className }
/>
);
};
ConnectButton.propTypes = {
...Button.propTypes,
text: PropTypes.string.isRequired,
url: PropTypes.string.isRequired,
className: PropTypes.string,
};
ConnectButton.defaultProps = {
className: '',
variant: 'contained',
size: 'sm',
color: 'cta',
target: '_blank',
rel: 'noopener noreferrer',
text: __( 'Connect & Activate', 'elementor' ),
};
export default React.memo( ConnectButton );

View File

@@ -0,0 +1,29 @@
// Copied from Core.
export const arrayToClassName = ( array, action ) => {
return array
.filter( ( item ) => 'object' === typeof ( item ) ? Object.entries( item )[ 0 ][ 1 ] : item )
.map( ( item ) => {
const value = 'object' === typeof ( item ) ? Object.entries( item )[ 0 ][ 0 ] : item;
return action ? action( value ) : value;
} )
.join( ' ' );
};
export const htmlDecodeTextContent = ( input ) => {
const doc = new DOMParser().parseFromString( input, 'text/html' );
return doc.documentElement.textContent;
};
export const replaceUtmPlaceholders = ( link = '', utms = {} ) => {
if ( ! link || ! utms ) {
return link;
}
Object.keys( utms ).forEach( ( key ) => {
const match = new RegExp( `%%${ key }%%`, 'g' );
link = link.replace( match, utms[ key ] );
} );
return link;
};

View File

@@ -0,0 +1,8 @@
@import "../../modules/site-editor/assets/js/molecules/site-template.scss";
@import "../../modules/site-editor/assets/js/pages/add-new.scss";
@import "../../modules/site-editor/assets/js/pages/template-type.scss";
@import "../../modules/site-editor/assets/js/pages/conditions/conditions.scss";
@import "../../modules/site-editor/assets/js/molecules/back-button.scss";
@import "../../modules/site-editor/assets/js/atoms/indicator-bullet.scss";
@import "../../modules/site-editor/assets/js/atoms/preview-iframe.scss";
@import "../../modules/site-editor/assets/js/site-editor.scss";

View File

@@ -0,0 +1,70 @@
<?php
namespace ElementorPro\Core\App\Modules\ImportExport;
use Elementor\Core\Base\Module as BaseModule;
use ElementorPro\Plugin;
use ElementorPro\Modules\ThemeBuilder\Module as ThemeBuilderModule;
use Elementor\App\Modules\ImportExport\Processes\Export;
use Elementor\App\Modules\ImportExport\Processes\Import;
use Elementor\App\Modules\ImportExport\Processes\Revert;
use ElementorPro\Core\App\Modules\ImportExport\Runners\Import\Templates as ImportTemplates;
use ElementorPro\Core\App\Modules\ImportExport\Runners\Export\Templates as ExportTemplates;
use ElementorPro\Core\App\Modules\ImportExport\Runners\Revert\Templates as RevertTemplates;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
class Module extends BaseModule {
public function get_name() {
return 'import-export';
}
public function __construct() {
parent::__construct();
$this->add_actions();
}
private function add_actions() {
add_filter( 'elementor/import/get_default_settings_conflicts', function( array $conflicts, array $templates ) {
return $this->apply_conditions_conflicts( $conflicts, $templates );
}, 10, 2 );
add_action( 'elementor/import-export/import-kit', function( Import $import ) {
$this->register_import_kit_runners( $import );
} );
add_action( 'elementor/import-export/export-kit', function( Export $export ) {
$this->register_export_kit_runners( $export );
} );
add_action( 'elementor/import-export/revert-kit', function( Revert $revert ) {
$this->register_revert_kit_runners( $revert );
} );
}
private function apply_conditions_conflicts( $conflicts, $templates ) {
/** @var ThemeBuilderModule $theme_builder_module */
$theme_builder_module = Plugin::instance()->modules_manager->get_modules( 'theme-builder' );
if ( ! $theme_builder_module ) {
return $conflicts;
}
return $conflicts + $theme_builder_module->get_conditions_conflicts( $templates );
}
private function register_import_kit_runners( Import $import ) {
$import->register( new ImportTemplates() );
}
private function register_export_kit_runners( Export $export ) {
$export->register( new ExportTemplates() );
}
private function register_revert_kit_runners( Revert $revert ) {
$revert->register( new RevertTemplates() );
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace ElementorPro\Core\App\Modules\ImportExport\Runners\Export;
use Elementor\App\Modules\ImportExport\Runners\Export\Export_Runner_Base;
use Elementor\Core\Base\Document;
use Elementor\Plugin;
use Elementor\TemplateLibrary\Source_Local;
class Templates extends Export_Runner_Base {
public static function get_name() : string {
return 'templates';
}
public function should_export( array $data ) {
return (
isset( $data['include'] ) &&
in_array( 'templates', $data['include'], true )
);
}
public function export( array $data ) {
$template_types = array_values( Source_Local::get_template_types() );
$query_args = [
'post_type' => Source_Local::CPT,
'post_status' => 'publish',
'posts_per_page' => -1,
'meta_query' => [
[
'key' => Document::TYPE_META_KEY,
'value' => $template_types,
],
],
];
$templates_query = new \WP_Query( $query_args );
$templates_manifest_data = [];
$files = [];
foreach ( $templates_query->posts as $template_post ) {
$template_id = $template_post->ID;
$template_document = Plugin::$instance->documents->get( $template_id );
$templates_manifest_data[ $template_id ] = $template_document->get_export_summary();
$files[] = [
'path' => 'templates/' . $template_id,
'data' => $template_document->get_export_data(),
];
}
$manifest_data['templates'] = $templates_manifest_data;
return [
'files' => $files,
'manifest' => [
$manifest_data,
],
];
}
}

View File

@@ -0,0 +1,130 @@
<?php
namespace ElementorPro\Core\App\Modules\ImportExport\Runners\Import;
use Elementor\App\Modules\ImportExport\Runners\Import\Import_Runner_Base;
use Elementor\App\Modules\ImportExport\Utils as ImportExportUtils;
use Elementor\Core\Base\Document;
use Elementor\Plugin;
use ElementorPro\Modules\ThemeBuilder\Classes\Conditions_Manager;
use ElementorPro\Modules\ThemeBuilder\Module as ThemeBuilderModule;
use ElementorPro\Plugin as ProPlugin;
use Elementor\TemplateLibrary\Source_Local;
class Templates extends Import_Runner_Base {
private $import_session_id;
private $templates_conditions = [];
public static function get_name() : string {
return 'templates';
}
public function should_import( array $data ) {
return (
isset( $data['include'] ) &&
in_array( 'templates', $data['include'], true ) &&
! empty( $data['extracted_directory_path'] ) &&
! empty( $data['manifest']['templates'] )
);
}
public function import( array $data, array $imported_data ) {
$this->import_session_id = $data['session_id'];
$path = $data['extracted_directory_path'] . 'templates/';
$templates = $data['manifest']['templates'];
$result['templates'] = [
'succeed' => [],
'failed' => [],
];
foreach ( $templates as $id => $template_settings ) {
try {
$template_data = ImportExportUtils::read_json_file( $path . $id );
$import = $this->import_template( $id, $template_settings, $template_data );
$result['templates']['succeed'][ $id ] = $import;
} catch ( \Exception $error ) {
$result['templates']['failed'][ $id ] = $error->getMessage();
}
}
return $result;
}
private function import_template( $id, array $template_settings, array $template_data ) {
$doc_type = $template_settings['doc_type'];
$new_document = Plugin::$instance->documents->create(
$doc_type,
[
'post_title' => $template_settings['title'],
'post_type' => Source_Local::CPT,
'post_status' => 'publish',
]
);
if ( is_wp_error( $new_document ) ) {
throw new \Exception( $new_document->get_error_message() );
}
$template_data['import_settings'] = $template_settings;
$template_data['id'] = $id;
$this->set_templates_conditions( $template_data );
$new_attachment_callback = function( $attachment_id ) {
$this->set_session_post_meta( $attachment_id, $this->import_session_id );
};
add_filter( 'elementor/template_library/import_images/new_attachment', $new_attachment_callback );
$new_document->import( $template_data );
remove_filter( 'elementor/template_library/import_images/new_attachment', $new_attachment_callback );
$document_id = $new_document->get_main_id();
$this->set_session_post_meta( $document_id, $this->import_session_id );
return $document_id;
}
public function get_import_session_metadata() : array {
return [
'template_conditions' => $this->templates_conditions,
];
}
private function set_templates_conditions( $template_data ) {
$conditions = $template_data['import_settings']['conditions'] ?? [];
if ( empty( $conditions ) ) {
return;
}
$condition = $conditions[0];
$condition = rtrim( implode( '/', $condition ), '/' );
/** @var ThemeBuilderModule $theme_builder_module */
$theme_builder_module = ProPlugin::instance()->modules_manager->get_modules( 'theme-builder' );
$conditions_manager = $theme_builder_module->get_conditions_manager();
$conflicts = $conditions_manager->get_conditions_conflicts_by_location(
$condition,
$template_data['import_settings']['location']
);
foreach ( $conflicts as $template ) {
$template_document = Plugin::$instance->documents->get( $template['template_id'] );
$template_conditions = $theme_builder_module->get_conditions_manager()->get_document_conditions( $template_document );
$this->templates_conditions[ $template['template_id'] ] = $template_conditions;
}
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace ElementorPro\Core\App\Modules\ImportExport\Runners\Revert;
use Elementor\App\Modules\ImportExport\Runners\Revert\Revert_Runner_Base;
use Elementor\Core\Base\Document;
use Elementor\Plugin;
use ElementorPro\Modules\ThemeBuilder\Module as ThemeBuilderModule;
use ElementorPro\Plugin as ProPlugin;
use Elementor\TemplateLibrary\Source_Local;
class Templates extends Revert_Runner_Base {
public static function get_name() : string {
return 'templates';
}
public function should_revert( array $data ) : bool {
return (
isset( $data['runners'] ) &&
array_key_exists( static::get_name(), $data['runners'] )
);
}
public function revert( array $data ) {
$template_types = array_values( Source_Local::get_template_types() );
$query_args = [
'post_type' => Source_Local::CPT,
'post_status' => 'any',
'posts_per_page' => -1,
'meta_query' => [
[
'key' => Document::TYPE_META_KEY,
'value' => $template_types,
],
[
'key' => static::META_KEY_ELEMENTOR_IMPORT_SESSION_ID,
'value' => $data['session_id'],
],
],
];
$templates_query = new \WP_Query( $query_args );
foreach ( $templates_query->posts as $template_post ) {
$template_document = Plugin::$instance->documents->get( $template_post->ID );
$template_document->delete();
}
/** @var ThemeBuilderModule $theme_builder_module */
$theme_builder_module = ProPlugin::instance()->modules_manager->get_modules( 'theme-builder' );
$theme_builder_module->get_conditions_manager()->clear_cache();
$old_conditions = $data['runners']['templates']['template_conditions'] ?? [];
foreach ( $old_conditions as $template_id => $conditions ) {
$theme_builder_module->get_conditions_manager()->save_conditions( $template_id, $conditions );
}
}
}

View File

@@ -0,0 +1,88 @@
<?php
namespace ElementorPro\Core\App\Modules\KitLibrary;
use ElementorPro\Plugin;
use ElementorPro\License\API;
use ElementorPro\License\Admin;
use Elementor\Core\Base\Module as BaseModule;
use ElementorPro\Core\Connect\Apps\Activate;
use Elementor\Core\App\Modules\KitLibrary\Connect\Kit_Library;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
class Module extends BaseModule {
/**
* Get name.
*
* @access public
*
* @return string
*/
public function get_name() {
return 'kit-library';
}
private function set_kit_library_settings() {
$common = Plugin::elementor()->common;
$app = Plugin::elementor()->app;
$prev_settings = $app->get_settings( 'kit-library' );
// BC Support.
if ( ! $prev_settings || ! $common ) {
return;
}
/** @var Activate $activate */
$activate = $common->get_component( 'connect' )->get_app( 'activate' );
/** @var Kit_Library $kit_library */
$kit_library = $common->get_component( 'connect' )->get_app( 'kit-library' );
$app->set_settings( 'kit-library', array_merge( $prev_settings, [
'is_pro' => true,
'is_library_connected' => API::is_license_active() && $kit_library && $kit_library->is_connected(),
'library_connect_url' => $activate->get_admin_url( 'authorize', [
'utm_source' => 'kit-library',
'utm_medium' => 'wp-dash',
'utm_campaign' => 'connect-and-activate-license',
'utm_term' => '%%page%%', // Will be replaced in the frontend.
] ),
'access_level' => API::get_library_access_level( 'kit' ),
'access_tier' => API::get_access_tier(),
] ) );
}
/**
* @param array $connect_info
* @param $app
*
* @return array
*/
private function add_license_to_connect_info( array $connect_info, $app ) {
$license_key = Admin::get_license_key();
// In elementor 3.3.0-beta it does not send the $app parameter and it should add the license.
$bc_support = ! $app;
$is_kit_library_request = $app && Kit_Library::class === get_class( $app );
if ( ! empty( $license_key ) && ( $bc_support || $is_kit_library_request ) ) {
$connect_info['license'] = $license_key;
}
return $connect_info;
}
public function __construct() {
add_action( 'elementor/init', function () {
$this->set_kit_library_settings();
}, 13 /** after elementor core */ );
add_filter( 'elementor/connect/additional-connect-info', function ( array $connect_info, $app = null ) {
return $this->add_license_to_connect_info( $connect_info, $app );
}, 10, 2 );
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace ElementorPro\Core\App\Modules\Onboarding;
use Elementor\Core\App\Modules\Onboarding\Module as Core_Onboarding_Module;
use ElementorPro\Plugin;
use Elementor\Core\Base\Module as BaseModule;
use ElementorPro\Core\Connect\Apps\Activate;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
class Module extends BaseModule {
/**
* Get name
*
* @since 3.6.0
* @access public
*
* @return string
*/
public function get_name() {
return 'onboarding';
}
/**
* Set Onboarding Settings
*
* Overrides the Onboarding App's Core settings with updated settings to accommodate for Elementor Pro.
*
* @since 3.6.0
* @access private
*/
private function set_onboarding_settings() {
$common = Plugin::elementor()->common;
$app = Plugin::elementor()->app;
$onboarding_settings = $app->get_settings( 'onboarding' );
// If the installed Elementor Core version does not include the Onboarding module, exit here.
if ( ! $onboarding_settings ) {
return;
}
/** @var Activate $activate */
$activate = $common->get_component( 'connect' )->get_app( 'activate' );
$onboarding_settings['urls']['connect'] = $activate->get_admin_url( 'authorize', [
'utm_source' => 'editor-app',
'utm_campaign' => 'connect-account',
'utm_medium' => 'wp-dash',
'utm_term' => Core_Onboarding_Module::VERSION,
'source' => 'generic',
] );
$onboarding_settings['urls']['signUp'] = $activate->get_admin_url( 'authorize', [
'utm_source' => 'editor-app',
'utm_campaign' => 'connect-account',
'utm_medium' => 'wp-dash',
'utm_term' => Core_Onboarding_Module::VERSION,
'source' => 'generic',
'screen_hint' => 'signup',
] );
$app->set_settings( 'onboarding', $onboarding_settings );
}
public function __construct() {
add_action( 'elementor/init', function () {
$this->set_onboarding_settings();
}, 13 /** after elementor core */ );
}
}

View File

@@ -0,0 +1,15 @@
import './indicator-bullet.scss';
export const Indicator = ( props ) => {
let className = 'eps-indicator-bullet';
if ( props.active ) {
className += ` ${ className }--active`;
}
return <i className={ className } />;
};
Indicator.propTypes = {
active: PropTypes.bool,
};

View File

@@ -0,0 +1,34 @@
$eps-indicator-bullet-border-color: theme-colors(light);
$eps-indicator-bullet-dark-border-color: dark-tints(500);
:root {
--indicator-bullet-border-color: #{$eps-indicator-bullet-border-color};
}
.eps-theme-dark {
--indicator-bullet-border-color: #{$eps-indicator-bullet-dark-border-color};
}
$eps-indicator-bullet-size: spacing(16) * 0.75;
$eps-indicator-bullet-color: tints(300);
$eps-indicator-bullet-color-active: theme-colors(success);
$eps-indicator-bullet-box-shadow: $eps-box-shadow-1;
$eps-indicator-bullet-radius: 100%;
$eps-indicator-bullet-border: 2px solid var(--indicator-bullet-border-color);
$eps-indicator-bullet-spacing: spacing(10);
.eps-indicator-bullet {
display: block;
flex-shrink: 0;
width: $eps-indicator-bullet-size;
height: $eps-indicator-bullet-size;
box-shadow: $eps-indicator-bullet-box-shadow;
background-color: $eps-indicator-bullet-color;
border: $eps-indicator-bullet-border;
border-radius: $eps-indicator-bullet-radius;
margin-inline-end: $eps-indicator-bullet-spacing;
&--active {
background-color: $eps-indicator-bullet-color-active;
}
}

View File

@@ -0,0 +1,37 @@
import './preview-iframe.scss';
export default function PreviewIFrame( props ) {
const ref = React.useRef( null ),
previewBreakpoint = 1200,
[ scale, setScale ] = React.useState( 1 ),
[ height, setHeight ] = React.useState( 0 );
// In order to make sure that the iframe itself show the content in specific viewport,
// and it should fit to the size of the card, there is a use of css props `scale` and `height`,
// and another element that wraps the iframe to be the guidelines of the iframe sizes.
React.useEffect( () => {
const currentScale = ref.current.clientWidth / previewBreakpoint;
setScale( currentScale );
setHeight( ref.current.clientHeight / currentScale );
}, [] );
return (
<div
ref={ ref }
className={ `site-editor__preview-iframe site-editor__preview-iframe--${ props.templateType }` }
>
<iframe
title="preview"
src={ props.src }
className={ `site-editor__preview-iframe__iframe` }
style={ { transform: `scale(${ scale })`, height, width: previewBreakpoint } }
/>
</div>
);
}
PreviewIFrame.propTypes = {
src: PropTypes.string.isRequired,
templateType: PropTypes.string.isRequired,
};

View File

@@ -0,0 +1,17 @@
.site-editor__preview-iframe {
height: 50vh;
position: relative;
&__iframe {
top: 0;
left: 0;
position: absolute;
border: none;
transform-origin: 0 0;
height: 100%;
}
&--header, &--footer{
height: 15vh;
}
}

View File

@@ -0,0 +1,48 @@
export class BaseContext extends React.Component {
constructor( props ) {
super( props );
this.state = {
action: {
current: null,
loading: false,
error: null,
errorMeta: {},
},
updateActionState: this.updateActionState.bind( this ),
resetActionState: this.resetActionState.bind( this ),
};
}
executeAction( name, handler ) {
this.updateActionState( { current: name, loading: true, error: null, errorMeta: {} } );
return handler()
.then( ( response ) => {
this.resetActionState();
return Promise.resolve( response );
} )
.catch( ( error ) => {
this.updateActionState( { current: name, loading: false, error: error.message, errorMeta: error } );
return Promise.reject( error );
} );
}
updateActionState( data ) {
return this.setState( ( prev ) => ( {
action: {
...prev.action,
...data,
},
} ) );
}
resetActionState() {
this.updateActionState( { current: null, loading: false, error: null, errorMeta: {} } );
}
}
export default BaseContext;

View File

@@ -0,0 +1,296 @@
import Condition from './models/condition';
import ConditionsConfig from './services/conditions-config';
import BaseContext from './base-context';
import { TemplatesConditions, TemplatesConditionsConflicts } from '../data/commands';
export const Context = React.createContext();
export class ConditionsProvider extends BaseContext {
static propTypes = {
children: PropTypes.any.isRequired,
currentTemplate: PropTypes.object.isRequired,
onConditionsSaved: PropTypes.func.isRequired,
validateConflicts: PropTypes.bool,
};
static defaultProps = {
validateConflicts: true,
};
static actions = {
FETCH_CONFIG: 'fetch-config',
SAVE: 'save',
CHECK_CONFLICTS: 'check-conflicts',
};
/**
* Holds the conditions config object.
*
* @type {ConditionsConfig}
*/
conditionsConfig = null;
/**
* ConditionsProvider constructor.
*
* @param {any} props
*/
constructor( props ) {
super( props );
this.state = {
...this.state,
conditions: {},
updateConditionItemState: this.updateConditionItemState.bind( this ),
removeConditionItemInState: this.removeConditionItemInState.bind( this ),
createConditionItemInState: this.createConditionItemInState.bind( this ),
findConditionItemInState: this.findConditionItemInState.bind( this ),
saveConditions: this.saveConditions.bind( this ),
};
}
/**
* Fetch the conditions config, then normalize the conditions and then setup titles for
* the subIds.
*/
componentDidMount() {
this.executeAction( ConditionsProvider.actions.FETCH_CONFIG, () => ConditionsConfig.create() )
.then( ( conditionsConfig ) => this.conditionsConfig = conditionsConfig )
.then( this.normalizeConditionsState.bind( this ) )
.then( this.setSubIdTitles.bind( this ) );
}
/**
* Execute a request to save the template conditions.
*
* @return {any} Saved conditions
*/
saveConditions() {
const conditions = Object.values( this.state.conditions )
.map( ( condition ) => condition.forDb() );
return this.executeAction(
ConditionsProvider.actions.SAVE,
() => $e.data.update( TemplatesConditions.signature, { conditions }, { id: this.props.currentTemplate.id } ),
).then( () => {
const contextConditions = Object.values( this.state.conditions )
.map( ( condition ) => condition.forContext() );
this.props.onConditionsSaved( this.props.currentTemplate.id, {
conditions: contextConditions,
instances: this.conditionsConfig.calculateInstances( Object.values( this.state.conditions ) ),
isActive: !! ( Object.keys( this.state.conditions ).length && 'publish' === this.props.currentTemplate.status ),
} );
} );
}
/**
* Check for conflicts in the server and mark the condition if there
* is a conflict.
*
* @param {any} condition
*/
checkConflicts( condition ) {
return this.executeAction(
ConditionsProvider.actions.CHECK_CONFLICTS,
() => $e.data.get( TemplatesConditionsConflicts.signature, {
post_id: this.props.currentTemplate.id,
condition: condition.clone().toString(),
} ),
).then( ( response ) =>
this.updateConditionItemState( condition.id, { conflictErrors: Object.values( response.data ) }, false ),
);
}
/**
* Fetching subId titles.
*
* @param {any} condition
* @return {Promise<unknown>} Titles
*/
fetchSubIdsTitles( condition ) {
return new Promise( ( resolve ) => {
return elementorCommon.ajax.loadObjects( {
action: 'query_control_value_titles',
ids: _.isArray( condition.subId ) ? condition.subId : [ condition.subId ],
data: {
get_titles: condition.subIdAutocomplete,
unique_id: elementorCommon.helpers.getUniqueId(),
},
success( response ) {
resolve( response );
},
} );
} );
}
/**
* Get the conditions from the template and normalize it to data structure
* that the components can work with.
*/
normalizeConditionsState() {
this.updateConditionsState( () => {
return this.props.currentTemplate.conditions.reduce( ( current, condition ) => {
const conditionObj = new Condition( {
...condition,
default: this.props.currentTemplate.defaultCondition,
options: this.conditionsConfig.getOptions(),
subOptions: this.conditionsConfig.getSubOptions( condition.name ),
subIdAutocomplete: this.conditionsConfig.getSubIdAutocomplete( condition.sub ),
supIdOptions: condition.subId ? [ { value: condition.subId, label: condition.subId } ] : [],
} );
return {
...current,
[ conditionObj.id ]: conditionObj,
};
}, {} );
} ).then( () => {
Object.values( this.state.conditions ).forEach( ( condition ) => this.checkConflicts( condition ) );
} );
}
/**
* Set titles to the subIds,
* for the first render of the component.
*/
setSubIdTitles() {
return Object.values( this.state.conditions ).forEach( ( condition ) => {
if ( ! condition.subId ) {
return;
}
return this.fetchSubIdsTitles( condition )
.then( ( response ) =>
this.updateConditionItemState( condition.id, {
subIdOptions: [ {
label: Object.values( response )[ 0 ],
value: condition.subId,
} ],
}, false ),
);
} );
}
/**
* Update state of specific condition item.
*
* @param {any} id
* @param {any} args
* @param {boolean} shouldCheckConflicts
*/
updateConditionItemState( id, args, shouldCheckConflicts = true ) {
if ( args.name ) {
args.subOptions = this.conditionsConfig.getSubOptions( args.name );
}
if ( args.sub || args.name ) {
args.subIdAutocomplete = this.conditionsConfig.getSubIdAutocomplete( args.sub );
// In case that the condition has been changed, it will set the options of the subId
// to empty array to let select2 autocomplete handle the options.
args.subIdOptions = [];
}
this.updateConditionsState( ( prev ) => {
const condition = prev[ id ];
return {
...prev,
[ id ]: condition.clone().set( args ),
};
} ).then( () => {
if ( shouldCheckConflicts ) {
this.checkConflicts( this.findConditionItemInState( id ) );
}
} );
}
/**
* Remove a condition item from the state.
*
* @param {any} id
*/
removeConditionItemInState( id ) {
this.updateConditionsState( ( prev ) => {
const newConditions = { ...prev };
delete newConditions[ id ];
return newConditions;
} );
}
/**
* Add a new condition item into the state.
*
* @param {boolean} shouldCheckConflicts
*/
createConditionItemInState( shouldCheckConflicts = true ) {
const defaultCondition = this.props.currentTemplate.defaultCondition,
newCondition = new Condition( {
name: defaultCondition,
default: defaultCondition,
options: this.conditionsConfig.getOptions(),
subOptions: this.conditionsConfig.getSubOptions( defaultCondition ),
subIdAutocomplete: this.conditionsConfig.getSubIdAutocomplete( '' ),
} );
this.updateConditionsState( ( prev ) => ( { ...prev, [ newCondition.id ]: newCondition } ) )
.then( () => {
if ( shouldCheckConflicts ) {
this.checkConflicts( newCondition );
}
} );
}
/**
* Find a condition item from the conditions state.
*
* @param {any} id
* @return {Condition|null} Condition
*/
findConditionItemInState( id ) {
return Object.values( this.state.conditions ).find( ( c ) => c.id === id );
}
/**
* Update the whole conditions state.
*
* @param {Function} callback
* @return {Promise<undefined>} Conditions state
*/
updateConditionsState( callback ) {
return new Promise( ( resolve ) =>
this.setState( ( prev ) => ( { conditions: callback( prev.conditions ) } ), resolve ),
);
}
/**
* Renders the provider.
*
* @return {any} Element
*/
render() {
if ( this.state.action.current === ConditionsProvider.actions.FETCH_CONFIG ) {
if ( this.state.error ) {
return <h3>{ __( 'Error:', 'elementor-pro' ) } { this.state.error }</h3>;
}
if ( this.state.loading ) {
return <h3>{ __( 'Loading', 'elementor-pro' ) }...</h3>;
}
}
return (
<Context.Provider value={ this.state }>
{ this.props.children }
</Context.Provider>
);
}
}
export default ConditionsProvider;

View File

@@ -0,0 +1,72 @@
export default class Condition {
id = elementorCommon.helpers.getUniqueId();
default = '';
type = 'include';
name = '';
sub = '';
subId = '';
options = [];
subOptions = [];
subIdAutocomplete = [];
subIdOptions = [];
conflictErrors = [];
constructor( args ) {
this.set( args );
}
set( args ) {
Object.assign( this, args );
return this;
}
clone() {
return Object.assign( new Condition(), this );
}
remove( keys ) {
if ( ! Array.isArray( keys ) ) {
keys = [ keys ];
}
keys.forEach( ( key ) => {
delete this[ key ];
} );
return this;
}
only( keys ) {
if ( ! Array.isArray( keys ) ) {
keys = [ keys ];
}
const keysToRemove = Object.keys( this )
.filter( ( conditionKey ) => ! keys.includes( conditionKey ) );
this.remove( keysToRemove );
return this;
}
toJson() {
return JSON.stringify( this );
}
toString() {
return this.forDb().filter( ( item ) => item ).join( '/' );
}
forDb() {
return [ this.type, this.name, this.sub, this.subId ];
}
forContext() {
return {
type: this.type,
name: this.name,
sub: this.sub,
subId: this.subId,
};
}
}

View File

@@ -0,0 +1,130 @@
import { ConditionsConfig as ConditionsConfigCommand } from '../../data/commands';
export class ConditionsConfig {
static instance;
config = null;
constructor( config ) {
this.config = config;
}
/**
* @return {Promise<ConditionsConfig>} Conditions config
*/
static create() {
if ( ConditionsConfig.instance ) {
return Promise.resolve( ConditionsConfig.instance );
}
return $e.data.get( ConditionsConfigCommand.signature, {}, { refresh: true } )
.then( ( response ) => {
ConditionsConfig.instance = new ConditionsConfig( response.data );
return ConditionsConfig.instance;
} );
}
/**
* Get main options for condition name.
*
* @return {Array} Condition options
*/
getOptions() {
return this.getSubOptions( 'general', true )
.map( ( { label, value } ) => {
return {
label,
value,
};
} );
}
/**
* Get the sub options for the select.
*
* @param {string} itemName
* @param {boolean} isSubItem
* @return {Array} Sub options
*/
getSubOptions( itemName, isSubItem = false ) {
const config = this.config[ itemName ];
if ( ! config ) {
return [];
}
return [
{ label: config.all_label, value: isSubItem ? itemName : '' },
...config.sub_conditions.map( ( subName ) => {
const subConfig = this.config[ subName ];
return {
label: subConfig.label,
value: subName,
children: subConfig.sub_conditions.length ? this.getSubOptions( subName, true ) : null,
};
} ),
];
}
/**
* Get the autocomplete property from the conditions config
*
* @param {string} sub
* @return {{}|any} Conditions autocomplete
*/
getSubIdAutocomplete( sub ) {
const config = this.config[ sub ];
if ( ! config || ! ( 'object' === typeof ( config.controls ) ) ) {
return {};
}
const controls = Object.values( config.controls );
if ( ! controls?.[ 0 ]?.autocomplete ) {
return {};
}
return controls[ 0 ].autocomplete;
}
/**
* Calculate instances from the conditions.
*
* @param {Array} conditions
* @return {Object} Conditions Instances
*/
calculateInstances( conditions ) {
let instances = conditions.reduce( ( current, condition ) => {
if ( 'exclude' === condition.type ) {
return current;
}
const key = condition.sub || condition.name,
config = this.config[ key ];
if ( ! config ) {
return current;
}
const instanceLabel = condition.subId
? `${ config.label } #${ condition.subId }`
: config.all_label;
return {
...current,
[ key ]: instanceLabel,
};
}, {} );
if ( 0 === Object.keys( instances ).length ) {
instances = [ __( 'No instances', 'elementor-pro' ) ];
}
return instances;
}
}
export default ConditionsConfig;

View File

@@ -0,0 +1,152 @@
import BaseContext from './base-context';
import { Templates } from '../data/commands';
import Component from '../data/component';
export const Context = React.createContext();
export class TemplatesProvider extends BaseContext {
static propTypes = {
children: PropTypes.object.isRequired,
};
static actions = {
FETCH: 'fetch',
DELETE: 'delete',
UPDATE: 'update',
IMPORT: 'import',
};
constructor( props ) {
super( props );
this.state = {
...this.state,
action: {
...this.state.action,
current: TemplatesProvider.actions.FETCH,
loading: true,
},
templates: {},
updateTemplateItemState: this.updateTemplateItemState.bind( this ),
findTemplateItemInState: this.findTemplateItemInState.bind( this ),
fetchTemplates: this.fetchTemplates.bind( this ),
deleteTemplate: this.deleteTemplate.bind( this ),
updateTemplate: this.updateTemplate.bind( this ),
importTemplates: this.importTemplates.bind( this ),
};
}
componentDidMount() {
this.fetchTemplates();
}
importTemplates( { fileName, fileData } ) {
return this.executeAction(
TemplatesProvider.actions.IMPORT,
() => $e.data.create( Templates.signature, { fileName, fileData } ),
).then( ( response ) => {
this.updateTemplatesState( ( prev ) => (
{
...prev,
...Object.values( response.data ).reduce(
( current, template ) => {
if ( ! template.supportsSiteEditor ) {
return current;
}
return { ...current, [ template.id ]: template };
}, {},
),
}
) );
return response;
} );
}
deleteTemplate( id ) {
return this.executeAction(
TemplatesProvider.actions.DELETE,
() => $e.data.delete( Templates.signature, { id } ),
).then( () => {
this.updateTemplatesState( ( prev ) => {
const newTemplates = { ...prev };
delete newTemplates[ id ];
return newTemplates;
} );
} );
}
updateTemplate( id, args ) {
return this.executeAction(
TemplatesProvider.actions.UPDATE,
() => $e.data.update( Templates.signature, args, { id } ),
).then( ( response ) => {
this.updateTemplateItemState( id, response.data );
} );
}
fetchTemplates() {
return this.executeAction(
TemplatesProvider.actions.FETCH,
() => $e.data.get( Templates.signature, {}, { refresh: true } ),
).then( ( response ) => {
this.updateTemplatesState( () => Object.values( response.data ).reduce(
( current, template ) => ( { ...current, [ template.id ]: template } ), {},
), false );
} );
}
updateTemplateItemState( id, args ) {
return this.updateTemplatesState( ( prev ) => {
const template = {
...prev[ id ],
...args,
};
return {
...prev,
[ id ]: template,
};
} );
}
updateTemplatesState( callback, clearCache = true ) {
if ( clearCache ) {
$e.data.deleteCache( $e.components.get( Component.namespace ), Templates.signature );
}
return this.setState( ( prev ) => {
return { templates: callback( prev.templates ) };
} );
}
findTemplateItemInState( id ) {
return this.state.templates[ id ];
}
render() {
if ( this.state.action.current === TemplatesProvider.actions.FETCH ) {
if ( this.state.action.error ) {
return <h3>{ __( 'Error:', 'elementor-pro' ) } { this.state.action.error }</h3>;
}
if ( this.state.action.loading ) {
return <h3>{ __( 'Loading', 'elementor-pro' ) }...</h3>;
}
}
return (
<Context.Provider value={ this.state }>
{ this.props.children }
</Context.Provider>
);
}
}
export default TemplatesProvider;

View File

@@ -0,0 +1,9 @@
export class ConditionsConfig extends $e.modules.CommandData {
static signature = 'site-editor/conditions-config';
static getEndpointFormat() {
return 'site-editor/conditions-config/{id}';
}
}
export default ConditionsConfig;

View File

@@ -0,0 +1,4 @@
export { Templates } from './templates';
export { ConditionsConfig } from './conditions-config';
export { TemplatesConditions } from './templates-conditions';
export { TemplatesConditionsConflicts } from './templates-conditions-conflicts';

View File

@@ -0,0 +1,9 @@
export class TemplatesConditionsConflicts extends $e.modules.CommandData {
static signature = 'site-editor/templates-conditions-conflicts';
static getEndpointFormat() {
return `${ TemplatesConditionsConflicts.signature }/{id}`;
}
}
export default TemplatesConditionsConflicts;

View File

@@ -0,0 +1,9 @@
export class TemplatesConditions extends $e.modules.CommandData {
static signature = 'site-editor/templates-conditions';
static getEndpointFormat() {
return 'site-editor/templates-conditions/{id}';
}
}
export default TemplatesConditions;

View File

@@ -0,0 +1,9 @@
export class Templates extends $e.modules.CommandData {
static signature = 'site-editor/templates';
static getEndpointFormat() {
return 'site-editor/templates/{id}';
}
}
export default Templates;

View File

@@ -0,0 +1,13 @@
import * as dataCommands from './commands';
export default class Component extends $e.modules.ComponentBase {
static namespace = 'site-editor';
getNamespace() {
return this.constructor.namespace;
}
defaultData() {
return this.importCommands( dataCommands );
}
}

View File

@@ -0,0 +1,14 @@
import Component from './data/component';
import { Templates } from './data/commands';
export default class Module extends elementorModules.editor.utils.Module {
onElementorInit() {
const config = elementor.documents.getCurrent().config;
if ( config.support_site_editor ) {
$e.components.register( new Component() );
$e.data.deleteCache( $e.components.get( Component.namespace ), Templates.signature );
}
}
}

View File

@@ -0,0 +1,54 @@
import { Context as TemplatesContext } from '../context/templates';
import useScreenshot, { SCREENSHOT_STATUS_SUCCEED, SCREENSHOT_STATUS_FAILED } from 'modules/screenshots/app/assets/js/hooks/use-screenshot';
/**
* Wrapper function that was made to take screenshots specific for template.
* it will capture a screenshot and update the templates context with the new screenshot.
*
* @param {any} templateType
*/
export default function useTemplatesScreenshot( templateType = null ) {
const { updateTemplateItemState, templates } = React.useContext( TemplatesContext );
const templatesForScreenshot = Object.values( templates ).filter(
( template ) => shouldScreenshotTemplate( template, templateType ),
);
// Start to capture screenshots.
const screenshot = useScreenshot( templatesForScreenshot );
// Update the thumbnail url when screenshot created.
React.useEffect( () => {
screenshot.posts
.filter( ( post ) => post.status === SCREENSHOT_STATUS_SUCCEED )
.forEach( ( post ) => updateTemplateItemState( post.id, { thumbnail: post.imageUrl } ) );
}, [ screenshot.succeed ] );
// Update the screenshot url that was failed.
// When the user will hit the route on the second time it will avoid trying to take another screenshot.
React.useEffect( () => {
screenshot.posts
.filter( ( post ) => post.status === SCREENSHOT_STATUS_FAILED )
.forEach( ( post ) => updateTemplateItemState( post.id, { screenshot_url: null } ) );
}, [ screenshot.failed ] );
return screenshot;
}
/**
* Filter handler.
* will remove all the drafts and private and also will filter by template type if exists.
*
* @param {any} template
* @param {any} templateType
* @return {boolean} should screenshot template
*/
function shouldScreenshotTemplate( template, templateType = null ) {
if ( templateType ) {
return false;
}
return 'publish' === template.status &&
! template.thumbnail &&
template.screenshot_url;
}

View File

@@ -0,0 +1,24 @@
import { Button } from '@elementor/app-ui';
import './back-button.scss';
export default function BackButton( props ) {
return (
<div className="back-button-wrapper">
<Button
className="eps-back-button"
text={ __( 'Back', 'elementor-pro' ) }
icon="eicon-chevron-left"
onClick={ props.onClick }
/>
</div>
);
}
BackButton.propTypes = {
onClick: PropTypes.func,
};
BackButton.defaultProps = {
onClick: () => history.back(),
};

View File

@@ -0,0 +1,8 @@
.#{$eps-prefix}back-button {
font-size: 14px;
margin-block-end: spacing(24);
.#{$eps-prefix}icon {
transform: getValueByDirection(rotate(0deg), rotate(180deg));
}
}

View File

@@ -0,0 +1,33 @@
import { CardBody } from '@elementor/app-ui';
import SiteTemplateThumbnail from './site-template-thumbnail';
import PreviewIFrame from '../atoms/preview-iframe';
export const SiteTemplateBody = ( props ) => {
return (
<CardBody>
{
props.extended
? <PreviewIFrame src={ props.previewUrl } templateType={ props.type } />
: (
<SiteTemplateThumbnail
id={ props.id }
title={ props.title }
type={ props.type }
thumbnail={ props.thumbnail }
placeholder={ props.placeholderUrl }
/>
)
}
</CardBody>
);
};
SiteTemplateBody.propTypes = {
extended: PropTypes.bool,
id: PropTypes.number,
title: PropTypes.string,
thumbnail: PropTypes.string,
placeholderUrl: PropTypes.string,
type: PropTypes.string,
previewUrl: PropTypes.string,
};

View File

@@ -0,0 +1,23 @@
import { Button, CardFooter, Icon, Text } from '@elementor/app-ui';
export const SiteTemplateFooter = ( props ) => {
const instances = Object.values( props.instances ).join( ', ' );
return (
<CardFooter>
<div className="e-site-template__instances">
<Icon className="eicon-flow" />
<Text tag="span" variant="sm"><b>{ __( 'Instances', 'elementor-pro' ) }:</b></Text>
<Text className="e-site-template__instances-list" tag="span" variant="xxs"> { instances }</Text>
<Button text={ __( 'Edit Conditions', 'elementor-pro' ) }
className="e-site-template__edit-conditions"
url={ `/site-editor/conditions/${ props.id }` } />
</div>
</CardFooter>
);
};
SiteTemplateFooter.propTypes = {
id: PropTypes.number.isRequired,
instances: PropTypes.any,
};

View File

@@ -0,0 +1,47 @@
import { Button, CardHeader, Heading, Icon, Text } from '@elementor/app-ui';
import PartActionsButtons from '../part-actions/dialogs-and-buttons';
import { Indicator } from '../atoms/indicator-bullet';
export const SiteTemplateHeader = ( props ) => {
const status = props.status && 'publish' !== props.status ? ` (${ props.status })` : '',
title = props.title + status,
ActionButtons = () => (
<>
<Button text={ __( 'Edit', 'elementor-pro' ) } icon="eicon-edit" className="e-site-template__edit-btn" size="sm" url={ props.editURL } />
<PartActionsButtons { ... props } />
</>
),
MetaDataIcon = ( innerProps ) => (
<Text tag="span" className="e-site-template__meta-data">
<Icon className={ innerProps.icon } />
{ innerProps.content }
</Text>
),
MetaData = () => (
<>
<MetaDataIcon icon="eicon-user-circle-o" content={ props.author } />
<MetaDataIcon icon="eicon-clock-o" content={ props.modifiedDate } />
</>
),
IndicatorDot = props.showInstances ? <Indicator active={ props.isActive } /> : '';
return (
<CardHeader>
{ IndicatorDot }
<Heading tag="h1" title={ title } variant="text-sm" className="eps-card__headline">{ title }</Heading>
{ props.extended && <MetaData /> }
{ props.extended && <ActionButtons /> }
</CardHeader>
);
};
SiteTemplateHeader.propTypes = {
isActive: PropTypes.bool,
author: PropTypes.string,
editURL: PropTypes.string,
extended: PropTypes.bool,
modifiedDate: PropTypes.string,
status: PropTypes.string,
title: PropTypes.string,
showInstances: PropTypes.bool,
};

View File

@@ -0,0 +1,28 @@
import { Button, CardImage, CardOverlay } from '@elementor/app-ui';
export default function SiteTemplateThumbnail( props ) {
return (
<CardImage
alt={ props.title }
src={ props.thumbnail || props.placeholder }
className={ ! props.thumbnail ? 'e-site-template__placeholder' : '' }
>
<CardOverlay className="e-site-template__overlay-preview">
<Button
className="e-site-template__overlay-preview-button"
text={ __( 'Preview', 'elementor-pro' ) }
icon="eicon-preview-medium"
url={ `/site-editor/templates/${ props.type }/${ props.id }` }
/>
</CardOverlay>
</CardImage>
);
}
SiteTemplateThumbnail.propTypes = {
id: PropTypes.number,
title: PropTypes.string,
type: PropTypes.string,
thumbnail: PropTypes.string,
placeholder: PropTypes.string,
};

View File

@@ -0,0 +1,59 @@
import { Card } from '@elementor/app-ui';
import { SiteTemplateHeader } from './site-template-header';
import { SiteTemplateBody } from './site-template-body';
import { SiteTemplateFooter } from './site-template-footer';
import './site-template.scss';
export default function SiteTemplate( props ) {
const baseClassName = 'e-site-template',
classes = [ baseClassName ],
ref = React.useRef( null );
React.useEffect( () => {
if ( ! props.isSelected ) {
return;
}
ref.current.scrollIntoView( {
behavior: 'smooth',
block: 'start',
} );
}, [ props.isSelected ] );
if ( props.extended ) {
classes.push( `${ baseClassName }--extended` );
}
if ( props.aspectRatio ) {
classes.push( `${ baseClassName }--${ props.aspectRatio }` );
}
const CardFooter = props.extended && props.showInstances ? <SiteTemplateFooter { ...props } /> : '';
return (
<Card className={ classes.join( ' ' ) } ref={ ref }>
<SiteTemplateHeader { ... props } />
<SiteTemplateBody { ... props } />
{ CardFooter }
</Card>
);
}
SiteTemplate.propTypes = {
aspectRatio: PropTypes.string,
className: PropTypes.string,
extended: PropTypes.bool,
id: PropTypes.number.isRequired,
isActive: PropTypes.bool.isRequired,
status: PropTypes.string,
thumbnail: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
isSelected: PropTypes.bool,
type: PropTypes.string.isRequired,
showInstances: PropTypes.bool,
};
SiteTemplate.defaultProps = {
isSelected: false,
};

View File

@@ -0,0 +1,117 @@
$eps-meta-icon-color: tints(400);
$eps-meta-icon-dark-color: dark-tints(200);
$preview-button-height: spacing(30);
$image-aspect-ratio: var(--card-image-aspect-ratio, #{$ratio-portrait});
$overlay-preview-padding-block-start: calc(#{$image-aspect-ratio} - #{$preview-button-height});
$aspect-ratio-wide: calc(100% * 0.1235);
:root {
--eps-meta-icon-color: #{$eps-meta-icon-color};
}
.eps-theme-dark {
--eps-meta-icon-color: #{$eps-meta-icon-dark-color};
}
.e-site-template {
&__meta-data {
margin-inline-start: spacing(10);
@include text-truncate();
font-size: type(text, xxs);
&:last-of-type {
margin-inline-end: auto;
}
&:first-of-type {
margin-inline-start: spacing(16);
}
.#{$eps-prefix}icon {
margin-inline-end: spacing(5);
color: var(--eps-meta-icon-color);
font-size: type(text, sm);
}
}
&__placeholder {
.#{$eps-prefix}card__image {
filter: var(--placeholder-filter, none);
}
}
&__overlay-preview {
padding-block-start: $overlay-preview-padding-block-start;
position: relative;
&-button {
font-weight: bold;
display: flex;
flex-wrap: wrap;
height: $preview-button-height;
width: 100%;
background-color: var(--card-background-color-hover);
justify-content: center;
align-items: center;
padding-block-start: spacing(10);
line-height: spacing(20);
--button-background-color: var(--card-headline-color);
&::before {
content: '';
position: absolute;
display: block;
width: 100%;
top: 0;
left: 0;
padding-block-start: $overlay-preview-padding-block-start;
}
& > :not(:first-child) {
margin-inline-start: spacing(5);
}
}
}
&__edit-btn {
margin-inline-end: spacing(20);
.#{$eps-prefix}icon {
margin-inline-end: spacing(5);
}
}
&__instances {
.#{$eps-prefix}icon {
margin-inline-end: spacing(5);
color: var(--eps-meta-icon-color);
font-size: type(text, sm)
}
&-list {
@include text-truncate();
}
}
&__edit-conditions {
margin-inline-start: spacing(16);
text-decoration: underline;
font-style: italic;
}
&--extended {
.#{$eps-prefix}card {
&__figure {
overflow: auto;
}
&__headline {
flex-grow: 0;
}
}
}
&--wide {
--card-image-aspect-ratio: #{$aspect-ratio-wide};
}
}

View File

@@ -0,0 +1,97 @@
import { CssGrid, Dialog } from '@elementor/app-ui';
import SiteTemplate from '../molecules/site-template';
import { PartActionsDialogs } from '../part-actions/dialogs-and-buttons';
import { Context as TemplatesContext } from '../context/templates';
import useTemplatesScreenshot from '../hooks/use-templates-screenshot';
export default function SiteTemplates( props ) {
const { templates: contextTemplates, action, resetActionState } = React.useContext( TemplatesContext );
let gridColumns, templates;
// Make the templates object a memorize value, will re run again only if
// templates has been changed, also sort the templates by `isActive`.
templates = React.useMemo( () => {
return Object.values( contextTemplates )
.sort( ( a, b ) => {
// This sort make sure to show first the active templates, second the
// inactive templates that are not draft, and then the drafts,
// in each category it sorts it inside by date.
if ( ! b.isActive && ! a.isActive ) {
if (
( 'draft' === b.status && 'draft' === a.status ) ||
( 'draft' !== b.status && 'draft' !== a.status )
) {
return b.date < a.date ? 1 : -1;
}
return 'draft' === a.status ? 1 : -1;
}
if ( b.isActive && a.isActive ) {
return b.date < a.date ? 1 : -1;
}
return b.isActive ? 1 : -1;
} );
}, [ contextTemplates ] );
// Start to capture screenshots.
useTemplatesScreenshot( props.type );
const siteTemplateConfig = {};
if ( props.type ) {
templates = templates.filter( ( item ) => item.type === props.type );
siteTemplateConfig.extended = true;
siteTemplateConfig.type = props.type;
switch ( props.type ) {
case 'header':
case 'footer':
gridColumns = 1;
siteTemplateConfig.aspectRatio = 'wide';
break;
default:
gridColumns = 2;
}
}
if ( ! templates || ! templates.length ) {
return <h3>{ __( 'No Templates found. Want to create one?', 'elementor-pro' ) }...</h3>;
}
return (
<section className="e-site-editor__site-templates">
<PartActionsDialogs />
{
action.error &&
<Dialog
text={ action.error }
dismissButtonText={ __( 'Go Back', 'elementor-pro' ) }
dismissButtonOnClick={ resetActionState }
approveButtonText={ __( 'Learn More', 'elementor-pro' ) }
approveButtonColor="link"
approveButtonUrl="https://go.elementor.com/app-theme-builder-template-load-issue"
approveButtonTarget="_target"
/>
}
<CssGrid columns={ gridColumns } spacing={ 24 } colMinWidth={ 200 }>
{
templates.map( ( item ) =>
<SiteTemplate
key={ item.id }
{ ... item }
{ ... siteTemplateConfig }
isSelected={ parseInt( props.id ) === item.id } />,
)
}
</CssGrid>
</section>
);
}
SiteTemplates.propTypes = {
type: PropTypes.string,
id: PropTypes.string,
};

View File

@@ -0,0 +1,53 @@
import { AddNewButton, Heading, Grid, CardOverlay } from '@elementor/app-ui';
import { SiteParts } from '@elementor/site-editor';
import './add-new.scss';
import { Context as TemplatesContext } from '../context/templates';
import BackButton from '../molecules/back-button';
import useFeatureLock from 'elementor-pro-app/hooks/use-feature-lock';
export default function AddNew() {
const { templates } = React.useContext( TemplatesContext ),
hasTemplates = 1 <= Object.keys( templates ).length;
const { isLocked, ConnectButton } = useFeatureLock( 'site-editor' );
/**
* An hover element for each site part.
*
* @param {any} props
*/
const HoverElement = ( props ) => {
if ( isLocked ) {
return (
<CardOverlay className="e-site-editor__promotion-overlay">
<div className="e-site-editor__promotion-overlay__link">
<i className="e-site-editor__promotion-overlay__icon eicon-lock" />
</div>
</CardOverlay>
);
}
return (
<a href={ props.urls.create } className="eps-card__image-overlay eps-add-new__overlay">
<AddNewButton hideText={ true } />
</a>
);
};
HoverElement.propTypes = {
urls: PropTypes.object.isRequired,
};
return (
<section className="e-site-editor__add-new">
<Grid container direction="column" className="e-site-editor__header">
{ hasTemplates && <Grid item><BackButton /></Grid> }
<Grid item container justify="space-between" alignItems="start">
<Heading variant="h1">{ __( 'Start customizing every part of your site', 'elementor-pro' ) }</Heading>
{ isLocked && <ConnectButton /> }
</Grid>
</Grid>
<SiteParts hoverElement={ HoverElement } />
</section>
);
}

View File

@@ -0,0 +1,7 @@
.eps-add-new__overlay {
display: flex;
align-items: center;
justify-content: center;
opacity: 1;
--card-image-overlay-background-color: transparent;
}

View File

@@ -0,0 +1,28 @@
import { Button, Text } from '@elementor/app-ui';
export default function ConditionConflicts( props ) {
if ( ! props.conflicts.length ) {
return '';
}
const conflictLinks = props.conflicts.map( ( conflict ) => {
return (
<Button
key={ conflict.template_id }
target="_blank"
url={ conflict.edit_url }
text={ conflict.template_title }
/>
);
} );
return (
<Text className="e-site-editor-conditions__conflict" variant="sm">
{ __( 'Elementor recognized that you have set this location for other templates: ', 'elementor-pro' ) } { conflictLinks }
</Text>
);
}
ConditionConflicts.propTypes = {
conflicts: PropTypes.array.isRequired,
};

View File

@@ -0,0 +1,28 @@
import { Select } from '@elementor/app-ui';
export default function ConditionName( props ) {
// Hide for template types that has another default, like single & archive.
if ( 'general' !== props.default ) {
return '';
}
const onChange = ( e ) => props.updateConditions( props.id, { name: e.target.value, sub: '', subId: '' } );
return (
<div className="e-site-editor-conditions__input-wrapper">
<Select options={ props.options } value={ props.name } onChange={ onChange } />
</div>
);
}
ConditionName.propTypes = {
updateConditions: PropTypes.func.isRequired,
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
options: PropTypes.array.isRequired,
default: PropTypes.string.isRequired,
};
ConditionName.defaultProps = {
name: '',
};

View File

@@ -0,0 +1,85 @@
import { Select2 } from '@elementor/app-ui';
/**
* Main component.
*
* @param {any} props
* @return {any} Element
* @class
*/
export default function ConditionSubId( props ) {
const settings = React.useMemo( () => (
Object.keys( props.subIdAutocomplete ).length
? getSettings( props.subIdAutocomplete )
: null
), [ props.subIdAutocomplete ] );
if ( ! props.sub || ! settings ) {
return '';
}
const onChange = ( e ) => props.updateConditions( props.id, { subId: e.target.value } );
return (
<div className="e-site-editor-conditions__input-wrapper">
<Select2
onChange={ onChange }
value={ props.subId }
settings={ settings }
options={ props.subIdOptions }
/>
</div>
);
}
/**
* Get settings for the select2 base on the autocomplete settings,
* that passes as a prop
*
* @param {any} autocomplete
* @return {Object} Settings
*/
function getSettings( autocomplete ) {
return {
allowClear: false,
placeholder: __( 'All', 'elementor-pro' ),
dir: elementorCommon.config.isRTL ? 'rtl' : 'ltr',
ajax: {
transport( params, success, failure ) {
return elementorCommon.ajax.addRequest( 'pro_panel_posts_control_filter_autocomplete', {
data: {
q: params.data.q,
autocomplete,
},
success,
error: failure,
} );
},
data( params ) {
return {
q: params.term,
page: params.page,
};
},
cache: true,
},
escapeMarkup( markup ) {
return markup;
},
minimumInputLength: 1,
};
}
ConditionSubId.propTypes = {
subIdAutocomplete: PropTypes.object,
id: PropTypes.string.isRequired,
sub: PropTypes.string,
subId: PropTypes.string,
updateConditions: PropTypes.func,
subIdOptions: PropTypes.array,
};
ConditionSubId.defaultProps = {
subId: '',
subIdOptions: [],
};

View File

@@ -0,0 +1,28 @@
import { Select } from '@elementor/app-ui';
export default function ConditionSub( props ) {
if ( 'general' === props.name || ! props.subOptions.length ) {
return '';
}
const onChange = ( e ) => props.updateConditions( props.id, { sub: e.target.value, subId: '' } );
return (
<div className="e-site-editor-conditions__input-wrapper">
<Select options={ props.subOptions } value={ props.sub } onChange={ onChange } />
</div>
);
}
ConditionSub.propTypes = {
updateConditions: PropTypes.func.isRequired,
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
sub: PropTypes.string.isRequired,
subOptions: PropTypes.array.isRequired,
};
ConditionSub.defaultProps = {
sub: '',
subOptions: {},
};

View File

@@ -0,0 +1,40 @@
import { Select } from '@elementor/app-ui';
export default function ConditionType( props ) {
const wrapperRef = React.createRef();
const options = [
{
label: __( 'Include', 'elementor-pro' ),
value: 'include',
},
{
label: __( 'Exclude', 'elementor-pro' ),
value: 'exclude',
},
];
const onChange = ( e ) => {
props.updateConditions( props.id, { type: e.target.value } );
};
React.useEffect( () => {
wrapperRef.current.setAttribute( 'data-elementor-condition-type', props.type );
} );
return (
<div className="e-site-editor-conditions__input-wrapper e-site-editor-conditions__input-wrapper--condition-type" ref={ wrapperRef }>
<Select options={ options } value={ props.type } onChange={ onChange } />
</div>
);
}
ConditionType.propTypes = {
updateConditions: PropTypes.func.isRequired,
id: PropTypes.string.isRequired,
type: PropTypes.string.isRequired,
};
ConditionType.defaultProps = {
type: '',
};

View File

@@ -0,0 +1,95 @@
$e-site-editor-conditions-header-image-button-spacing: spacing(44);
$e-site-editor-conditions-header-image-width: px-to-rem(70);
$e-site-editor-conditions-rows-max-width: px-to-rem(700);
$e-site-editor-conditions-rows-y-spacing: spacing(44);
$e-site-editor-conditions-row-block-start-spacing: spacing(12);
$e-site-editor-conditions-remove-condition-color: tints(400);
$e-site-editor-conditions-remove-condition-font-size: type(size, '18');
$e-site-editor-conditions-row-controls-spacing-end: spacing(10);
$e-site-editor-conditions-row-controls-background: theme-colors(light);
$e-site-editor-conditions-row-controls-dark-background: dark-tints(600);
$e-site-editor-conditions-row-controls-radius: $eps-radius;
$e-site-editor-conditions-row-controls-border: $eps-border-width $eps-border-style tints(100);
$e-site-editor-conditions-row-controls-dark-border: $eps-border-width $eps-border-style tints(700);
//$e-site-editor-conditions-row-controls-dark-border: $eps-border-width $eps-border-style tints(725); //merge after 3.12 is out
$e-site-editor-conditions-row-controls-error-border: $eps-border-width $eps-border-style theme-colors(danger);
$e-site-editor-conditions-conflict-block-start-spacing: spacing(5);
$e-site-editor-conditions-conflict-color: theme-colors(danger);
$e-site-editor-add-button-margin-block-start: spacing(44);
$e-site-editor-add-button-background-color: tints(500);
$e-site-editor-add-button-background-dark-color: dark-tints(500);
$e-site-editor-add-button-color: theme-colors(light);
$e-site-editor-add-button-color-hover-background-color: tints(600);
$e-site-editor-add-button-color-hover-dark-background-color: dark-tints(600);
$e-site-editor-add-button-color-hover-color: theme-colors(light);
$e-site-editor-save-button-container-spacing: spacing(8);
$e-site-editor-input-wrapper-border-width: $eps-border-width;
$e-site-editor-input-wrapper-border-style: $eps-border-style;
$e-site-editor-input-wrapper-border-color: tints(100);
$e-site-editor-input-wrapper-border-dark-color: dark-tints(700);
//$e-site-editor-input-wrapper-border-dark-color: dark-tints(725); //merge after 3.12 is out
$e-site-editor-input-wrapper-select-font-size: type(size, "12");
$e-site-editor-input-wrapper-select-height: px-to-rem(40);
$e-site-editor-input-wrapper-select-y-padding: spacing(10);
$e-site-editor-input-wrapper-select-color: tints(700);
//$e-site-editor-input-wrapper-select-color: tints(725); //merge after 3.12 is out
$e-site-editor-input-wrapper-select-dark-color: dark-tints(200);
$e-site-editor-input-wrapper-select-arrow-font-size: type(size, "12");
$e-site-editor-input-wrapper-select-arrow-margin-end: spacing(10);
$e-site-editor-input-wrapper-condition-type-icon-start-spacing: spacing(12);
$e-site-editor-input-wrapper-condition-type-icon-font-size: type(size, "15");
$e-site-editor-input-wrapper-condition-type-icon-color: theme-colors(light);
$e-site-editor-input-wrapper-condition-type-icon-z-index: z-index(dropdown);
$e-site-editor-input-wrapper-condition-type-arrow-color: theme-colors(light);
$e-site-editor-input-wrapper-condition-type-start-padding: px-to-rem(34);
$e-site-editor-input-wrapper-condition-type-width: px-to-rem(120);
$e-site-editor-input-wrapper-condition-type-font-size: type(size, '12');
$e-site-editor-input-wrapper-condition-type-color: theme-colors(light);
$e-site-editor-input-wrapper-condition-include-background-color: tints(500);
$e-site-editor-input-wrapper-condition-include-background-dark-color: dark-tints(600);
$e-site-editor-input-wrapper-condition-exclude-background-color: tints(400);
$e-site-editor-input-wrapper-condition-exclude-background-dark-color: dark-tints(600);
$e-site-editor-input-select2-search-field-color: theme-elements-colors(text-base-color);
$e-site-editor-input-select2-search-field-dark-color: theme-colors(light);
:root {
--e-site-editor-conditions-row-controls-background: #{$e-site-editor-conditions-row-controls-background};
--e-site-editor-input-wrapper-border-color: #{$e-site-editor-input-wrapper-border-color};
--e-site-editor-input-wrapper-select-color: #{$e-site-editor-input-wrapper-select-color};
--e-site-editor-conditions-row-controls-border: #{$e-site-editor-conditions-row-controls-border};
--e-site-editor-add-button-background-color: #{$e-site-editor-add-button-background-color};
--e-site-editor-add-button-color-hover-background-color: #{$e-site-editor-add-button-color-hover-background-color};
--e-site-editor-input-wrapper-condition-include-background-color:
#{$e-site-editor-input-wrapper-condition-include-background-color};
--e-site-editor-input-wrapper-condition-exclude-background-color:
#{$e-site-editor-input-wrapper-condition-exclude-background-color};
--e-site-editor-input-select2-search-field-color: #{$e-site-editor-input-select2-search-field-color}
}
.eps-theme-dark {
--select2-selection-background-color: tints(600);
--e-site-editor-conditions-row-controls-background: #{$e-site-editor-conditions-row-controls-dark-background};
--e-site-editor-input-wrapper-border-color: #{$e-site-editor-input-wrapper-border-dark-color};
--e-site-editor-input-wrapper-select-color: #{$e-site-editor-input-wrapper-select-dark-color};
--e-site-editor-conditions-row-controls-border: #{$e-site-editor-conditions-row-controls-dark-border};
--e-site-editor-add-button-background-color: #{$e-site-editor-add-button-background-dark-color};
--e-site-editor-add-button-color-hover-background-color: #{$e-site-editor-add-button-color-hover-dark-background-color};
--e-site-editor-input-wrapper-condition-include-background-color:
#{$e-site-editor-input-wrapper-condition-include-background-dark-color};
--e-site-editor-input-wrapper-condition-exclude-background-color:
#{$e-site-editor-input-wrapper-condition-exclude-background-dark-color};
--e-site-editor-input-select2-search-field-color: #{$e-site-editor-input-select2-search-field-dark-color}
}

View File

@@ -0,0 +1,89 @@
import { Context as ConditionsContext, ConditionsProvider } from '../../context/conditions';
import { Button, Dialog } from '@elementor/app-ui';
import ConditionType from './condition-type';
import ConditionName from './condition-name';
import ConditionSub from './condition-sub';
import ConditionSubId from './condition-sub-id';
import ConditionConflicts from './condition-conflicts';
export default function ConditionsRows( props ) {
const {
conditions,
createConditionItemInState: create,
updateConditionItemState: update,
removeConditionItemInState: remove,
saveConditions: save,
action,
resetActionState,
} = React.useContext( ConditionsContext );
const rows = Object.values( conditions ).map( ( condition ) =>
<div key={ condition.id }>
<div className="e-site-editor-conditions__row">
<div
className={ `e-site-editor-conditions__row-controls ${ condition.conflictErrors.length && 'e-site-editor-conditions__row-controls--error' }` }>
<ConditionType { ...condition } updateConditions={ update } />
<div className="e-site-editor-conditions__row-controls-inner">
<ConditionName { ...condition } updateConditions={ update } />
<ConditionSub { ...condition } updateConditions={ update } />
<ConditionSubId { ...condition } updateConditions={ update } />
</div>
</div>
<Button
className="e-site-editor-conditions__remove-condition"
text={ __( 'Delete', 'elementor-pro' ) }
icon="eicon-close"
hideText={ true }
onClick={ () => remove( condition.id ) }
/>
</div>
<ConditionConflicts conflicts={ condition.conflictErrors } />
</div>,
);
const isSaving = action.current === ConditionsProvider.actions.SAVE && action.loading;
return (
<>
{
action.error &&
<Dialog
text={ action.error }
dismissButtonText={ __( 'Go Back', 'elementor-pro' ) }
dismissButtonOnClick={ resetActionState }
approveButtonText={ __( 'Learn More', 'elementor-pro' ) }
approveButtonColor="link"
approveButtonUrl="https://go.elementor.com/app-theme-builder-conditions-load-issue"
approveButtonTarget="_target"
/>
}
<div className="e-site-editor-conditions__rows">
{ rows }
</div>
<div className="e-site-editor-conditions__add-button-container">
<Button
className="e-site-editor-conditions__add-button"
variant="contained"
size="lg"
text={ __( 'Add Condition', 'elementor-pro' ) }
onClick={ create }
/>
</div>
<div className="e-site-editor-conditions__footer">
<Button
variant="contained"
color="primary"
size="lg"
hideText={ isSaving }
icon={ isSaving ? 'eicon-loading eicon-animation-spin' : '' }
text={ __( 'Save & Close', 'elementor-pro' ) }
onClick={ () => save().then( props.onAfterSave ) }
/>
</div>
</>
);
}
ConditionsRows.propTypes = {
onAfterSave: PropTypes.func,
};

View File

@@ -0,0 +1,44 @@
import { Heading, Text } from '@elementor/app-ui';
import ConditionsProvider from '../../context/conditions';
import { Context as TemplatesContext } from '../../context/templates';
import ConditionsRows from './conditions-rows';
import './conditions.scss';
import BackButton from '../../molecules/back-button';
export default function Conditions( props ) {
const { findTemplateItemInState, updateTemplateItemState } = React.useContext( TemplatesContext ),
template = findTemplateItemInState( parseInt( props.id ) );
if ( ! template ) {
return <div>{ __( 'Not Found', 'elementor-pro' ) }</div>;
}
return (
<section className="e-site-editor-conditions">
<BackButton />
<div className="e-site-editor-conditions__header">
<img
className="e-site-editor-conditions__header-image"
src={ `${ elementorAppProConfig.baseUrl }/modules/theme-builder/assets/images/conditions-tab.svg` }
alt={ __( 'Import template', 'elementor-pro' ) }
/>
<Heading variant="h1" tag="h1">
{ __( 'Where Do You Want to Display Your Template?', 'elementor-pro' ) }
</Heading>
<Text variant="p">
{ __( 'Set the conditions that determine where your template is used throughout your site.', 'elementor-pro' ) }
<br />
{ __( 'For example, choose \'Entire Site\' to display the template across your site.', 'elementor-pro' ) }
</Text>
</div>
<ConditionsProvider currentTemplate={ template } onConditionsSaved={ updateTemplateItemState }>
<ConditionsRows onAfterSave={ () => history.back() } />
</ConditionsProvider>
</section>
);
}
Conditions.propTypes = {
id: PropTypes.string,
};

View File

@@ -0,0 +1,187 @@
@import "conditions-api";
.e-site-editor-conditions {
&__header {
text-align: center;
}
&__header-image {
display: block;
margin: 0 auto $e-site-editor-conditions-header-image-button-spacing;
width: $e-site-editor-conditions-header-image-width;
}
&__rows {
margin: $e-site-editor-conditions-rows-y-spacing auto;
max-width: $e-site-editor-conditions-rows-max-width;
}
&__row {
display: flex;
flex-grow: 1;
margin-block-start: $e-site-editor-conditions-row-block-start-spacing;
}
&__remove-condition {
color: $e-site-editor-conditions-remove-condition-color;
font-size: $e-site-editor-conditions-remove-condition-font-size;
display: flex;
align-items: center;
justify-content: center;
}
&__row-controls {
overflow: hidden;
margin-inline-end: $e-site-editor-conditions-row-controls-spacing-end;
background-color: var(--e-site-editor-conditions-row-controls-background);
display: flex;
width: 100%;
border: var(--e-site-editor-conditions-row-controls-border);
border-radius: $e-site-editor-conditions-row-controls-radius;
&--error {
border: $e-site-editor-conditions-row-controls-error-border;
}
}
&__conflict {
text-align: center;
margin-block-start: $e-site-editor-conditions-conflict-block-start-spacing;
color: $e-site-editor-conditions-conflict-color;
}
&__row-controls-inner {
width: 100%;
display: flex;
div {
flex: 1;
}
}
&__add-button-container {
text-align: center;
}
&__add-button {
margin-block-start: $e-site-editor-add-button-margin-block-start;
background-color: var(--e-site-editor-add-button-background-color);
color: $e-site-editor-add-button-color;
text-transform: uppercase;
&:hover {
background-color: var(--e-site-editor-add-button-color-hover-background-color);
color: $e-site-editor-add-button-color-hover-color;
}
}
&__footer {
display: flex;
justify-content: flex-end;
position: absolute;
bottom: 0;
right: 0;
left: 0;
padding: $e-site-editor-save-button-container-spacing;
border-block-start: 1px solid var(--hr-color);
}
&__input-wrapper {
position: relative;
padding-inline-start: $e-site-editor-input-wrapper-border-width $e-site-editor-input-wrapper-border-style;
border-color: var(--e-site-editor-input-wrapper-border-color);
&:first-child {
border: none;
}
select {
appearance: none;
-webkit-appearance: none;
font-size: $e-site-editor-input-wrapper-select-font-size;
height: $e-site-editor-input-wrapper-select-height;
border-width: 0;
padding: 0 $e-site-editor-input-wrapper-select-y-padding;
width: 100%;
position: relative;
color: var(--e-site-editor-input-wrapper-select-color);
outline: none;
background: transparent;
}
&:after {
font-family: eicons;
content: '\e8ad';
font-size: $e-site-editor-input-wrapper-select-arrow-font-size;
pointer-events: none;
position: absolute;
top: 50%;
transform: translateY(-50%);
@include end($e-site-editor-input-wrapper-select-arrow-margin-end);
}
.select2-container--default .select2-selection--single {
border: none;
line-height: $e-site-editor-input-wrapper-select-height;
}
.select2-container--default .select2-selection--single .select2-selection__rendered {
line-height: $e-site-editor-input-wrapper-select-height;
font-size: $e-site-editor-input-wrapper-select-font-size;
}
.select2-selection {
outline: none;
background: transparent;
height: $e-site-editor-input-wrapper-select-height;
}
.select2-selection__arrow {
display: none;
}
}
&__input-wrapper--condition-type {
position: relative;
&:before {
font-family: eicons;
position: absolute;
top: 50%;
transform: translateY(-50%);
@include start($e-site-editor-input-wrapper-condition-type-icon-start-spacing);
font-size: $e-site-editor-input-wrapper-condition-type-icon-font-size;
//color: $e-site-editor-input-wrapper-condition-type-icon-color;
pointer-events: none;
z-index: $e-site-editor-input-wrapper-condition-type-icon-z-index;
}
select {
text-transform: uppercase;
padding-inline-start: $e-site-editor-input-wrapper-condition-type-start-padding;
width: $e-site-editor-input-wrapper-condition-type-width;
font-size: $e-site-editor-input-wrapper-condition-type-font-size;
border-inline-end: $e-site-editor-input-wrapper-border-width $e-site-editor-input-wrapper-border-style;
border-color: var(--e-site-editor-input-wrapper-border-color);
}
&[data-elementor-condition-type="include"] {
&:before {
content: '\e8cc';
}
}
&[data-elementor-condition-type="exclude"] {
&:before {
content: '\e8cd';
}
}
}
}
// This is a temporary fix that handles dark mode in select2.
// TODO: Remove it if already handled by the Select2 component.
.select2-search__field {
background-color: transparent;
color: var(--e-site-editor-input-select2-search-field-color);
}

View File

@@ -0,0 +1,119 @@
import { DropZone, Dialog, Checkbox } from '@elementor/app-ui';
import { Context as TemplatesContext, TemplatesProvider } from '../context/templates';
import BackButton from '../molecules/back-button';
import { useConfirmAction as useConfirmActionBase } from '@elementor/hooks';
// The hook `useConfirmAction` comes from the core plugin, so it is possible that it is not available.
const useConfirmActionFallback = ( { action } ) => ( {
runAction: action,
dialog: { isOpen: false },
} );
const useConfirmAction = useConfirmActionBase ?? useConfirmActionFallback;
export default function Import() {
const { importTemplates, action, resetActionState } = React.useContext( TemplatesContext ),
[ importedTemplate, setImportedTemplate ] = React.useState( null ),
isImport = action.current === TemplatesProvider.actions.IMPORT,
isUploading = isImport && action.loading,
hasError = isImport && action.error;
const upload = React.useCallback( ( file ) => {
if ( isUploading ) {
return;
}
readFile( file )
.then( ( fileData ) => importTemplates( { fileName: file.name, fileData } ) )
.then( ( response ) => {
// For now it show a dialog for the first template ONLY!
setImportedTemplate( response.data[ 0 ] );
} );
}, [] );
const {
runAction: uploadFile,
dialog,
checkbox,
} = useConfirmAction( {
doNotShowAgainKey: 'upload_json_warning_generic_message',
action: upload,
} );
return (
<section className="site-editor__import">
{
importedTemplate &&
<Dialog
title={ __( 'Your template was imported', 'elementor-pro' ) }
approveButtonText={ __( 'Preview', 'elementor-pro' ) }
approveButtonUrl={ importedTemplate.url }
approveButtonTarget="_blank"
dismissButtonText={ __( 'Edit', 'elementor-pro' ) }
dismissButtonUrl={ importedTemplate.editURL }
dismissButtonTarget="_top"
onClose={ () => setImportedTemplate( null ) }
/>
}
{
hasError &&
<Dialog
title={ action.error }
approveButtonText={ __( 'Learn More', 'elementor-pro' ) }
approveButtonUrl="https://go.elementor.com/app-theme-builder-import-issue"
approveButtonTarget="_blank"
approveButtonColor="link"
dismissButtonText={ __( 'Go Back', 'elementor-pro' ) }
dismissButtonOnClick={ resetActionState }
onClose={ resetActionState }
/>
}
{
dialog.isOpen &&
<Dialog
title={ __( 'Warning: JSON or ZIP files may be unsafe', 'elementor-pro' ) }
text={ __( 'Uploading JSON or ZIP files from unknown sources can be harmful and put your site at risk. For maximum safety, upload only JSON or ZIP files from trusted sources.', 'elementor-pro' ) }
approveButtonColor="link"
approveButtonText={ __( 'Continue', 'elementor-pro' ) }
approveButtonOnClick={ dialog.approve }
dismissButtonText={ __( 'Cancel', 'elementor-pro' ) }
dismissButtonOnClick={ dialog.dismiss }
onClose={ dialog.dismiss }
>
<label htmlFor="do-not-show-upload-json-warning-again" style={ { display: 'flex', alignItems: 'center', gap: '5px' } }>
<Checkbox
id="do-not-show-upload-json-warning-again"
type="checkbox"
value={ checkbox.isChecked }
onChange={ ( event ) => checkbox.setIsChecked( !! event.target.checked ) }
/>
{ __( 'Do not show this message again', 'elementor-pro' ) }
</label>
</Dialog>
}
<BackButton />
<DropZone
heading={ __( 'Import Template To Your Library', 'elementor-pro' ) }
text={ __( 'Drag & Drop your .JSON or .zip template file', 'elementor-pro' ) }
secondaryText={ __( 'or', 'elementor-pro' ) }
onFileSelect={ uploadFile }
isLoading={ isUploading }
filetypes={ [ 'zip', 'json' ] }
/>
</section>
);
}
function readFile( file ) {
return new Promise( ( ( resolve ) => {
const fileReader = new FileReader();
fileReader.readAsDataURL( file );
fileReader.onload = ( event ) => {
// Replace the mime type that prepended to the base64 with empty string and return a
// resolved promise only with the base64 string.
resolve( event.target.result.replace( /^[^,]+,/, '' ) );
};
} ) );
}

View File

@@ -0,0 +1,37 @@
import { TemplateTypesContext } from '@elementor/site-editor';
import { AddNewButton, Grid, Heading, NotFound } from '@elementor/app-ui';
import SiteTemplates from '../organisms/site-templates';
import useFeatureLock from 'elementor-pro-app/hooks/use-feature-lock';
import './template-type.scss';
export default function TemplateType( props ) {
const { templateTypes } = React.useContext( TemplateTypesContext ),
currentType = templateTypes.find( ( item ) => item.type === props.type ),
{ isLocked, ConnectButton } = useFeatureLock( 'site-editor' );
if ( ! currentType ) {
return <NotFound />;
}
return (
<section className={ `e-site-editor__templates e-site-editor__templates--type-${ props.type }` }>
<Grid className="page-header" container justify="space-between">
<Heading variant="h1">{ currentType.page_title }</Heading>
{
isLocked
? <ConnectButton />
: <AddNewButton url={ currentType.urls.create } text={ __( 'Add New', 'elementor-pro' ) } />
}
</Grid>
<hr className="eps-separator" />
<SiteTemplates type={ currentType.type } id={ props.id } />
</section>
);
}
TemplateType.propTypes = {
type: PropTypes.string,
page_title: PropTypes.string,
id: PropTypes.string,
};

View File

@@ -0,0 +1,13 @@
.e-site-editor__templates {
.page-header {
margin-block-end: spacing(10);
> a {
align-self: baseline;
}
}
.eps-separator {
margin-block-end: spacing(44);
}
}

View File

@@ -0,0 +1,22 @@
import SiteTemplates from '../organisms/site-templates';
import { AddNewButton, Grid } from '@elementor/app-ui';
import useFeatureLock from 'elementor-pro-app/hooks/use-feature-lock';
export default function Templates() {
const { isLocked, ConnectButton } = useFeatureLock( 'site-editor' );
return (
<section className="e-site-editor__site-templates">
<Grid container justify="space-between" alignItems="start" className="page-header">
<h1>{ __( 'Your Site\'s Global Parts', 'elementor-pro' ) }</h1>
{
isLocked
? <ConnectButton />
: <AddNewButton url="/site-editor/add-new" />
}
</Grid>
<hr className="eps-separator" />
<SiteTemplates />
</section>
);
}

View File

@@ -0,0 +1,38 @@
import { Dialog } from '@elementor/app-ui';
import { Context as TemplatesContext } from '../context/templates';
export default function DialogDelete( props ) {
const { deleteTemplate, findTemplateItemInState } = React.useContext( TemplatesContext );
const closeDialog = ( shouldUpdate ) => {
props.setId( null );
if ( shouldUpdate ) {
deleteTemplate( props.id );
}
};
if ( ! props.id ) {
return '';
}
const template = findTemplateItemInState( props.id );
return (
<Dialog
title={ __( 'Move Item To Trash', 'elementor-pro' ) }
text={ __( 'Are you sure you want to move this item to trash:', 'elementor-pro' ) + ` "${ template.title }"` }
onSubmit={ () => closeDialog( true ) }
approveButtonText={ __( 'Move to Trash', 'elementor-pro' ) }
approveButtonOnClick={ () => closeDialog( true ) }
approveButtonColor="danger"
dismissButtonText={ __( 'Cancel', 'elementor-pro' ) }
dismissButtonOnClick={ () => closeDialog() }
onClose={ () => closeDialog() }
/>
);
}
DialogDelete.propTypes = {
id: PropTypes.number,
setId: PropTypes.func.isRequired,
};

View File

@@ -0,0 +1,56 @@
import { useEffect } from 'react';
import { Dialog } from '@elementor/app-ui';
import { Context as TemplatesContext } from '../context/templates';
export default function DialogRename( props ) {
const { findTemplateItemInState, updateTemplate } = React.useContext( TemplatesContext ),
template = findTemplateItemInState( props.id );
const [ title, setTitle ] = React.useState( '' );
useEffect( () => {
// The "title" state should be updated if the template title changed.
if ( template ) {
setTitle( template.title );
}
}, [ template ] );
const closeDialog = ( shouldUpdate ) => {
props.setId( null );
if ( shouldUpdate ) {
updateTemplate( props.id, { post_title: title } );
}
};
if ( ! props.id ) {
return '';
}
return (
<Dialog
title={ __( 'Rename Site Part', 'elementor-pro' ) }
approveButtonText={ __( 'Change', 'elementor-pro' ) }
onSubmit={ () => closeDialog( true ) }
approveButtonOnClick={ () => closeDialog( true ) }
approveButtonColor="primary"
dismissButtonText={ __( 'Cancel', 'elementor-pro' ) }
dismissButtonOnClick={ () => closeDialog() }
onClose={ () => closeDialog() }
>
<input
type="text"
className="eps-input eps-input-text eps-input--block"
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
value={ title }
onChange={ ( e ) => setTitle( e.target.value ) }
/>
</Dialog>
);
}
DialogRename.propTypes = {
id: PropTypes.number,
setId: PropTypes.func.isRequired,
};

View File

@@ -0,0 +1,79 @@
import DialogRename from './dialog-rename';
import DialogDelete from './dialog-delete';
import { Button, Popover } from '@elementor/app-ui';
export const handlers = {
rename: null,
delete: null,
};
// TODO: Think about refactor to portals: https://reactjs.org/docs/portals.html
export function PartActionsDialogs() {
const [ DialogRenameId, setDialogRenameId ] = React.useState( null );
const [ DialogDeleteId, setDialogDeleteId ] = React.useState( null );
handlers.rename = setDialogRenameId;
handlers.delete = setDialogDeleteId;
return (
<>
<DialogRename id={ DialogRenameId } setId={ setDialogRenameId } />
<DialogDelete id={ DialogDeleteId } setId={ setDialogDeleteId } />
</>
);
}
export default function PartActionsButtons( props ) {
const [ showMenu, setShowMenu ] = React.useState( false );
let SiteTemplatePopover = '';
if ( showMenu ) {
SiteTemplatePopover = (
<Popover closeFunction={ () => setShowMenu( ! showMenu ) }>
<li>
<Button
className="eps-popover__item"
icon="eicon-sign-out"
text={ __( 'Export', 'elementor-pro' ) }
url={ props.exportLink }
/>
</li>
<li>
<Button
className="eps-popover__item eps-popover__item--danger"
icon="eicon-trash-o"
text={ __( 'Trash', 'elementor-pro' ) }
onClick={ () => handlers.delete( props.id ) }
/>
</li>
<li>
<Button
className="eps-popover__item"
icon="eicon-edit"
text={ __( 'Rename', 'elementor-pro' ) }
onClick={ () => handlers.rename( props.id ) }
/>
</li>
</Popover>
);
}
return (
<div className="eps-popover__container">
<Button
text={ __( 'Toggle', 'elementor-pro' ) }
hideText={ true }
icon="eicon-ellipsis-h"
size="lg"
onClick={ () => setShowMenu( ! showMenu ) }
/>
{ SiteTemplatePopover }
</div>
);
}
PartActionsButtons.propTypes = {
id: PropTypes.number.isRequired,
exportLink: PropTypes.string.isRequired,
};

View File

@@ -0,0 +1,90 @@
import { Router, LocationProvider, Redirect } from '@reach/router';
import Templates from './pages/templates';
import TemplateType from './pages/template-type';
import AddNew from './pages/add-new';
import Conditions from './pages/conditions/conditions';
import Import from './pages/import';
import TemplatesProvider, { Context as TemplatesContext } from './context/templates';
import { Layout, AllPartsButton, NotFound } from '@elementor/site-editor';
import { ErrorBoundary, Grid, Button } from '@elementor/app-ui';
import router from '@elementor/router';
import Component from './data/component';
import useFeatureLock from 'elementor-pro-app/hooks/use-feature-lock';
import './site-editor.scss';
function SiteEditor() {
const { isLocked } = useFeatureLock( 'site-editor' );
const basePath = 'site-editor';
const headerButtons = [
{
id: 'import',
text: __( 'import', 'elementor-pro' ),
hideText: true,
icon: 'eicon-upload-circle-o',
onClick: () => router.appHistory.navigate( basePath + '/import' ),
},
];
// Remove Core cache.
elementorCommon.ajax.invalidateCache( {
unique_id: 'app_site_editor_template_types',
} );
const SiteEditorDefault = () => {
const { templates } = React.useContext( TemplatesContext );
if ( Object.keys( templates ).length ) {
return <Redirect from={ '/' } to={ '/' + basePath + '/templates' } noThrow={ true } />;
}
return <Redirect from={ '/' } to={ '/' + basePath + '/add-new' } noThrow={ true } />;
};
return (
<ErrorBoundary
title={ __( 'Theme Builder could not be loaded', 'elementor-pro' ) }
learnMoreUrl="https://go.elementor.com/app-theme-builder-load-issue"
>
<Layout allPartsButton={ <AllPartsButton url={ '/' + basePath } /> } headerButtons={ headerButtons } titleRedirectRoute={ '/' + basePath } promotion={ isLocked }>
<Grid container className="e-site-editor__content_container">
<Grid item className="e-site-editor__content_container_main">
<TemplatesProvider>
<LocationProvider history={ router.appHistory }>
<Router>
<SiteEditorDefault path={ basePath } />
<Templates path={ basePath + '/templates' } />
<TemplateType path={ basePath + '/templates/:type/*id' } />
<AddNew path={ basePath + '/add-new' } />
<Conditions path={ basePath + '/conditions/:id' } />
<Import path={ basePath + '/import' } />
<NotFound default />
</Router>
</LocationProvider>
</TemplatesProvider>
</Grid>
<Grid item className="e-site-editor__content_container_secondary">
<Button
text={ __( 'Switch to table view', 'elementor-pro' ) }
url={ elementorAppProConfig[ 'site-editor' ]?.urls?.legacy_view }
/>
</Grid>
</Grid>
</Layout>
</ErrorBoundary>
);
}
export default class Module {
constructor() {
elementorCommon.debug.addURLToWatch( 'elementor-pro/assets' );
$e.components.register( new Component() );
router.addRoute( {
path: '/site-editor/*',
component: SiteEditor,
} );
}
}

View File

@@ -0,0 +1,14 @@
.e-site-editor__content_container {
flex-direction: column;
min-height: 100%;
flex-wrap: nowrap;
}
.e-site-editor__content_container_main {
flex: 1;
}
.e-site-editor__content_container_secondary {
margin: 0 auto;
padding-block-start: spacing(44);
}

View File

@@ -0,0 +1,26 @@
<?php
namespace ElementorPro\Core\App\Modules\SiteEditor\Data;
use ElementorPro\Plugin;
use Elementor\Data\Base\Controller as Controller_Base;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
class Controller extends Controller_Base {
public function get_name() {
return 'site-editor';
}
public function register_endpoints() {
$this->register_endpoint( Endpoints\Templates::class );
$this->register_endpoint( Endpoints\Conditions_Config::class );
$this->register_endpoint( Endpoints\Templates_Conditions::class );
$this->register_endpoint( Endpoints\Templates_Conditions_Conflicts::class );
}
public function get_permission_callback( $request ) {
return Plugin::elementor()->kits_manager->get_active_kit()->is_editable_by_current_user();
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace ElementorPro\Core\App\Modules\SiteEditor\Data\Endpoints;
use Elementor\Data\Base\Endpoint;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
abstract class Base_Endpoint extends Endpoint {
/**
* Check if post is lock.
*
* @param $post_id
*
* @return bool|false|int
*/
protected function is_post_lock( $post_id ) {
require_once ABSPATH . 'wp-admin/includes/post.php';
return wp_check_post_lock( $post_id );
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace ElementorPro\Core\App\Modules\SiteEditor\Data\Endpoints;
use ElementorPro\Modules\ThemeBuilder\Module as ThemeBuilderModule;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
class Conditions_Config extends Base_Endpoint {
/**
* @return string
*/
public function get_name() {
return 'conditions-config';
}
public function get_items( $request ) {
$conditions_manager = ThemeBuilderModule::instance()->get_conditions_manager();
return $conditions_manager->get_conditions_config();
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace ElementorPro\Core\App\Modules\SiteEditor\Data\Endpoints;
use ElementorPro\Plugin;
use ElementorPro\Core\App\Modules\SiteEditor\Module;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
class Template_Types extends Base_Endpoint {
/**
* @return string
*/
public function get_name() {
return 'template-types';
}
public function get_items( $request ) {
/** @var Module $site_editor_module */
$site_editor_module = Plugin::instance()->app->get_component( 'site-editor' );
return $site_editor_module->get_template_types();
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace ElementorPro\Core\App\Modules\SiteEditor\Data\Endpoints;
use ElementorPro\Plugin;
use ElementorPro\Modules\ThemeBuilder\Module;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
class Templates_Conditions_Conflicts extends Base_Endpoint {
/**
* @return string
*/
public function get_name() {
return 'templates-conditions-conflicts';
}
public function get_items( $request ) {
/** @var Module $theme_builder */
$theme_builder = Plugin::instance()->modules_manager->get_modules( 'theme-builder' );
return $theme_builder
->get_conditions_manager()
->get_conditions_conflicts( intval( $request['post_id'] ), $request['condition'] );
}
}

View File

@@ -0,0 +1,85 @@
<?php
namespace ElementorPro\Core\App\Modules\SiteEditor\Data\Endpoints;
use ElementorPro\Plugin;
use Elementor\Core\Utils\Exceptions;
use ElementorPro\Modules\ThemeBuilder\Module;
use ElementorPro\Core\App\Modules\SiteEditor\Data\Responses\Lock_Error_Response;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
class Templates_Conditions extends Base_Endpoint {
/**
* @return string
*/
public function get_name() {
return 'templates-conditions';
}
protected function register() {
$this->register_item_route();
$this->register_item_route( \WP_REST_Server::EDITABLE );
}
public function get_item( $template_id, $request ) {
return $this->get_conditions( $template_id );
}
public function update_item( $template_id, $request ) {
$lock_by_user_id = $this->is_post_lock( $template_id );
if ( $lock_by_user_id ) {
return new Lock_Error_Response( $lock_by_user_id );
}
$data = $request->get_body_params();
if ( ! isset( $data['conditions'] ) ) {
$data['conditions'] = [];
}
$is_saved = $this->save_conditions( $template_id, $data['conditions'] );
if ( ! $is_saved ) {
return new \WP_Error(
'conditions',
__( 'Error while saving conditions.', 'elementor-pro' ),
[ 'status' => Exceptions::INTERNAL_SERVER_ERROR ]
);
}
return true;
}
protected function get_conditions( $post_id ) {
$document = \Elementor\Plugin::$instance->documents->get( $post_id );
/** @var Module $theme_builder */
$theme_builder = Plugin::instance()->modules_manager->get_modules( 'theme-builder' );
return $theme_builder
->get_conditions_manager()
->get_document_conditions( $document );
}
protected function save_conditions( $post_id, $conditions ) {
/** @var Module $theme_builder */
$theme_builder = Plugin::instance()->modules_manager->get_modules( 'theme-builder' );
$is_saved = $theme_builder
->get_conditions_manager()
->save_conditions( $post_id, $conditions );
if ( ! $is_saved ) {
return new \WP_Error(
'conditions_save',
__( 'Cannot save those conditions.', 'elementor-pro' ),
[ 'status' => Exceptions::INTERNAL_SERVER_ERROR ]
);
}
return true;
}
}

View File

@@ -0,0 +1,238 @@
<?php
namespace ElementorPro\Core\App\Modules\SiteEditor\Data\Endpoints;
use ElementorPro\Plugin;
use Elementor\Core\Utils\Exceptions;
use Elementor\TemplateLibrary\Manager as TemplateManager;
use ElementorPro\Modules\ThemeBuilder\Documents\Theme_Document;
use ElementorPro\Modules\ThemeBuilder\Classes\Conditions_Manager;
use ElementorPro\Modules\ThemeBuilder\Module as ThemeBuilderModule;
use ElementorPro\Modules\ThemeBuilder\Classes\Templates_Types_Manager;
use ElementorPro\Core\App\Modules\SiteEditor\Render_Mode_Template_Preview;
use ElementorPro\Core\App\Modules\SiteEditor\Data\Responses\Lock_Error_Response;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
class Templates extends Base_Endpoint {
/**
* @var TemplateManager
*/
private $templates_manager;
/**
* @var array
*/
private $document_types;
public function __construct( $controller ) {
parent::__construct( $controller );
$this->templates_manager = Plugin::elementor()->templates_manager;
}
/**
* @return string
*/
public function get_name() {
return 'templates';
}
protected function register() {
parent::register();
$this->register_item_route( \WP_REST_Server::DELETABLE );
$this->register_item_route( \WP_REST_Server::EDITABLE );
$this->register_items_route( \WP_REST_Server::CREATABLE );
}
public function get_items( $request ) {
$templates = $this->templates_manager->get_source( 'local' )->get_items( [
'type' => array_keys( $this->get_documents_types() ),
'post_status' => 'any',
'orderby' => 'post_date',
'order' => 'DESC',
] );
return $this->normalize_templates_json( $templates );
}
public function create_items( $request ) {
$response = $this->templates_manager->import_template( $request->get_body_params() );
if ( is_wp_error( $response ) ) {
return new \WP_Error( 'file', $response->get_error_message(), [ 'status' => Exceptions::BAD_REQUEST ] );
}
return $this->normalize_templates_json( $response );
}
public function update_item( $id, $request ) {
$lock_by_user_id = $this->is_post_lock( $id );
if ( $lock_by_user_id ) {
return new Lock_Error_Response( $lock_by_user_id );
}
wp_update_post( array_merge( [
'ID' => $id,
], $request->get_body_params() ) );
return $this->normalize_template_json_item(
$this->templates_manager->get_source( 'local' )->get_item( $id )
);
}
public function delete_item( $id, $request ) {
$lock_by_user_id = $this->is_post_lock( $id );
if ( $lock_by_user_id ) {
return new Lock_Error_Response( $lock_by_user_id );
}
return ! ! wp_trash_post( $id );
}
/**
* @return array
*/
private function get_documents_types() {
if ( ! $this->document_types ) {
/** @var Templates_Types_Manager $types_manager */
$types_manager = ThemeBuilderModule::instance()->get_types_manager();
$this->document_types = $types_manager->get_types_config( [
'support_site_editor' => true,
] );
}
return $this->document_types;
}
/**
* @param $templates
*
* @return array
*/
private function normalize_templates_json( $templates ) {
return array_map( [ $this, 'normalize_template_json_item' ], $templates );
}
/**
* @param $template
*
* @return array
*/
private function normalize_template_json_item( $template ) {
/** @var Conditions_Manager $conditions_manager */
$conditions_manager = Plugin::instance()->modules_manager->get_modules( 'theme-builder' )->get_conditions_manager();
/** @var Theme_Document $document */
$document = Plugin::elementor()->documents->get( $template['template_id'] );
$supports_site_editor = $document::get_property( 'support_site_editor' );
// Supports also a non site editor parts.
if ( ! $supports_site_editor ) {
return [
'id' => $template['template_id'],
'url' => $template['url'],
'editURL' => $document->get_edit_url(),
'supportsSiteEditor' => false,
];
}
$types = $this->get_documents_types();
$template['instances'] = $conditions_manager->get_document_instances( $template['template_id'] );
$template['defaultCondition'] = $types[ $template['type'] ]['condition_type'];
$has_instances = ! empty( $template['instances'] );
$is_active = false;
if ( ! $has_instances ) {
$template['instances'] = [ 'no_instances' => esc_html__( 'No instances', 'elementor-pro' ) ];
} else {
$is_active = 'publish' === $template['status'];
}
if ( ! $template['thumbnail'] ) {
$template['thumbnail'] = '';
}
$site_editor_config = $document->get_site_editor_config();
$data = array_merge( $template, [
'id' => $template['template_id'],
'exportLink' => $template['export_link'],
'modifiedDate' => $template['human_modified_date'],
'editURL' => $document->get_edit_url(),
'conditions' => array_map( function ( $condition ) {
return array_merge( $condition, [
'sub' => $condition['sub_name'],
'subId' => $condition['sub_id'],
] );
}, $conditions_manager->get_document_conditions( $document ) ),
'isActive' => $is_active,
'type' => $this->calculate_template_type( $template['type'], $template['instances'] ),
'previewUrl' => $this->get_preview_url( $template['template_id'] ),
'placeholderUrl' => $site_editor_config['urls']['thumbnail'],
'pageLayout' => $site_editor_config['page_layout'],
'supportsSiteEditor' => true,
'showInstances' => $site_editor_config['show_instances'],
] );
/**
* Template data.
*
* Filters the data returned by Elementor API as JSON.
*
* By default Elementor API returns data in a JSON format that enables the
* builder to work properly. This hook allows developers to alter the data
* returned by the API to add new elements.
*
* @param array $data Template data.
*/
$data = apply_filters( 'elementor-pro/site-editor/data/template', $data );
return $data;
}
/**
* @param $type
* @param $instances
*
* @return string
*/
private function calculate_template_type( $type, $instances ) {
$condition_to_type_map = [
'front_page' => 'single-page',
'child_of' => 'single-page',
'page' => 'single-page',
'not_found404' => 'error-404',
'search' => 'search-results',
];
// "single" type was split into "single-page", "single-post" and "404".
// this section supports all the old templates that was created as "single".
if ( 'single' === $type ) {
// By default show it under single-post.
$type = 'single-post';
foreach ( $instances as $condition_name => $condition_label ) {
if ( isset( $condition_to_type_map[ $condition_name ] ) ) {
$type = $condition_to_type_map[ $condition_name ];
break;
}
}
}
return $type;
}
private function get_preview_url( $post_id ) {
return Render_Mode_Template_Preview::get_url( $post_id );
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace ElementorPro\Core\App\Modules\SiteEditor\Data\Responses;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
class Lock_Error_Response extends \WP_Error {
public function __construct( $user_id ) {
$user = get_user_by( 'ID', $user_id );
parent::__construct(
'post_lock',
sprintf(
/* translators: %s: User display name. */
esc_html__( '%s is currently editing this template, please try again later', 'elementor-pro' ),
$user->display_name
),
[
'status' => 403,
'locked_by_user_id' => $user_id,
'locked_by_user_name' => $user->display_name,
]
);
}
}

View File

@@ -0,0 +1,221 @@
<?php
namespace ElementorPro\Core\App\Modules\SiteEditor;
use Elementor\Core\Admin\Menu\Admin_Menu_Manager;
use Elementor\Core\Experiments\Manager as ExperimentsManager;
use Elementor\Core\Frontend\Render_Mode_Manager;
use Elementor\Core\Base\Module as BaseModule;
use Elementor\Core\Common\Modules\Ajax\Module as Ajax;
use Elementor\TemplateLibrary\Source_Local;
use ElementorPro\Core\App\Modules\SiteEditor\Data\Controller;
use ElementorPro\Core\Behaviors\Feature_Lock;
use ElementorPro\Modules\ThemeBuilder\AdminMenuItems\Theme_Builder_Menu_Item;
use ElementorPro\Modules\ThemeBuilder\Module as Theme_Builder_Table_View;
use ElementorPro\Modules\ThemeBuilder\Module as ThemeBuilderModule;
use ElementorPro\Plugin;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly
}
/**
* Site Editor Module
*
* Responsible for initializing Elementor Pro App functionality
*/
class Module extends BaseModule {
/**
* @var Feature_Lock
*/
private $lock;
/**
* Get name.
*
* @access public
*
* @return string
*/
public function get_name() {
return 'site-editor';
}
/**
* @throws \Exception
*/
public function get_template_types() {
// Same as admin menu capabilities.
if ( ! current_user_can( 'publish_posts' ) ) {
throw new \Exception( 'Access denied' );
}
$document_types = Plugin::elementor()->documents->get_document_types( [
'support_site_editor' => true,
] );
// Keep 404 at end of array.
$error_404 = $document_types['error-404'];
unset( $document_types['error-404'] );
$document_types['error-404'] = $error_404;
// Currently the `single` itself is not supported in site editor.
// Don't use `support_site_editor=false` in order to support documents that extend it.
unset( $document_types['single'] );
$types = [];
foreach ( $document_types as $type => $class ) {
$types[] = $class::get_site_editor_config();
}
return $types;
}
/**
* Register ajax actions.
*
* @access public
*
* @param Ajax $ajax
*/
public function register_ajax_actions( Ajax $ajax ) {
$ajax->register_ajax_action( 'app_site_editor_template_types', [ $this, 'get_template_types' ] );
}
/**
* @param Render_Mode_Manager $manager
*
* @throws \Exception
*/
public function register_render_mode( Render_Mode_Manager $manager ) {
$manager->register_render_mode( Render_Mode_Template_Preview::class );
}
protected function get_init_settings() {
$settings = [
'urls' => [
'legacy_view' => add_query_arg( 'tabs_group', ThemeBuilderModule::ADMIN_LIBRARY_TAB_GROUP, admin_url( Source_Local::ADMIN_MENU_SLUG ) ),
],
'utms' => [
'utm_source' => 'theme-builder',
'utm_medium' => 'wp-dash',
],
];
if ( $this->lock->is_locked() ) {
$settings['lock'] = $this->lock->get_config();
}
return $settings;
}
private function add_default_new_site_editor_experiments( ExperimentsManager $manager ) {
$manager->add_feature( [
'name' => 'theme_builder_v2',
'title' => __( 'Default to New Theme Builder', 'elementor-pro' ),
'description' => __( 'Entering the Theme Builder through WP Dashboard > Templates > Theme Builder opens the New theme builder by default. But dont worry, you can always view the WP styled version of the screen with a simple click of a button.', 'elementor-pro' ),
'release_status' => ExperimentsManager::RELEASE_STATUS_STABLE,
'default' => ExperimentsManager::STATE_ACTIVE,
] );
}
/**
* Get site editor url.
*
* @return string
*/
private function get_site_editor_url() : string {
return Plugin::elementor()->app->get_base_url() . '#/site-editor';
}
private function register_site_editor_menu() {
$experiments_manager = Plugin::elementor()->experiments;
// Unique case when the experiments manager is not initialized yet.
if ( ! $experiments_manager || ! $experiments_manager->is_feature_active( 'theme_builder_v2' ) ) {
return;
}
// Remove the old theme builder link and add the new one.
remove_submenu_page(
Source_Local::ADMIN_MENU_SLUG,
add_query_arg( 'tabs_group', ThemeBuilderModule::ADMIN_LIBRARY_TAB_GROUP, Source_Local::ADMIN_MENU_SLUG )
);
add_submenu_page(
Source_Local::ADMIN_MENU_SLUG,
'',
__( 'Theme Builder', 'elementor-pro' ),
'publish_posts',
$this->get_site_editor_url()
);
}
private function register_admin_menu( Admin_Menu_Manager $admin_menu_manager ) {
$experiments_manager = Plugin::elementor()->experiments;
// Unique case when the experiments manager is not initialized yet.
if ( ! $experiments_manager || ! $experiments_manager->is_feature_active( 'theme_builder_v2' ) ) {
return;
}
$admin_menu_manager->unregister( add_query_arg( 'tabs_group', ThemeBuilderModule::ADMIN_LIBRARY_TAB_GROUP, Source_Local::ADMIN_MENU_SLUG ) );
$admin_menu_manager->register(
$this->get_site_editor_url(),
new Theme_Builder_Menu_Item()
);
}
private function add_finder_item( array $categories ) {
if ( ! Plugin::elementor()->experiments->is_feature_active( 'theme_builder_v2' ) ) {
return $categories;
}
// Replace the old theme builder "create-new" link with the new site-editor.
$categories['create']['items']['theme-template'] = [
'title' => __( 'Add New Theme Template', 'elementor-pro' ),
'icon' => 'plus-circle-o',
'url' => $this->get_site_editor_url() . '/add-new',
'keywords' => [ 'template', 'theme', 'new', 'create' ],
];
return $categories;
}
/**
* Module constructor.
*
* @access public
*/
public function __construct() {
$this->lock = new Feature_Lock( [ 'type' => 'theme-builder' ] );
Plugin::elementor()->data_manager->register_controller( Controller::class );
add_action( 'elementor/ajax/register_actions', [ $this, 'register_ajax_actions' ], 11 /* Override core actions */ );
add_action( 'elementor/frontend/render_mode/register', [ $this, 'register_render_mode' ] );
add_action( 'elementor/experiments/default-features-registered', function ( ExperimentsManager $manager ) {
$this->add_default_new_site_editor_experiments( $manager );
} );
add_action( 'elementor/admin/menu/register', function ( Admin_Menu_Manager $admin_menu ) {
$this->register_admin_menu( $admin_menu );
}, Theme_Builder_Table_View::ADMIN_MENU_PRIORITY + 1 );
// TODO: BC - Remove after `Admin_Menu_Manager` will be the standard.
add_action( 'admin_menu', function () {
if ( did_action( 'elementor/admin/menu/register' ) ) {
return;
}
$this->register_site_editor_menu();
}, 23 /* After old theme builder */ );
add_filter( 'elementor/finder/categories', function ( array $categories ) {
return $this->add_finder_item( $categories );
}, 11 /* After old theme builder */ );
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace ElementorPro\Core\App\Modules\SiteEditor;
use Elementor\Core\Frontend\RenderModes\Render_Mode_Base;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
class Render_Mode_Template_Preview extends Render_Mode_Base {
/**
* @return string
*/
public static function get_name() {
return 'template-preview';
}
public function filter_template() {
return ELEMENTOR_PATH . 'modules/page-templates/templates/canvas.php';
}
public function prepare_render() {
parent::prepare_render();
show_admin_bar( false );
remove_filter(
'the_content',
[ \ElementorPro\Modules\ThemeBuilder\Module::instance()->get_locations_manager(), 'builder_wrapper' ],
9999999
);
add_filter( 'template_include', [ $this, 'filter_template' ] );
add_action( 'wp_head', [ $this, 'render_pointer_event_style' ] );
}
/**
* disable all the interactions in the preview render mode.
*/
public function render_pointer_event_style() {
echo '<style> html { pointer-events: none; } </style>';
}
public function is_static() {
return true;
}
}