Subversion Repositories web.creative

Rev

Blame | Last modification | View Log | Download

<?php namespace ProcessWire;

/**
 * Class InputfieldImage
 *
 * Inputfield for FieldtypeImage fields
 * 
 * 
 * Accessible Properties
 *
 * @property string $extensions Space separated list of allowed image extensions (default="JPG JPEG GIF PNG")
 * @property int|string $maxWidth Max width for uploaded images, larger will be sized down (default='')
 * @property int|string $maxHeight Max height for uploaded images, larger will be sized down (default='')
 * @property float $maxSize Maximum number of megapixels for client-side resize, i.e. 1.7 is ~1600x1000, alt. to maxWidth/maxHeight (default=0).
 * @property bool|int $maxReject Reject images that exceed max allowed size? (default=false)
 * @property int|string $minWidth Min width for uploaded images, smaller will be refused (default='')
 * @property int|string $minHeight Min height for uploaded images, smaller will be refused (default='')
 * @property bool|int $dimensionsByAspectRatio Switch min-/maxWidth and min-/maxHeight restriction for portrait images
 * @property string $itemClass Space separated CSS classes for items rendered by this Inputfield. Generally you should append rather than replace.
 * @property int|bool $useImageEditor Whether or not the modal image editor is allowed for this field (default=true)
 * @property int $adminThumbScale for backwards compatibility only
 * @property int|bool $resizeServer Resize to max width/height at server? 1=Server-only, 0=Use client-side resize when possible (default=0). 
 * @property int $clientQuality Quality setting to use for client-side resize. 60=60%, 90=90%, etc. (default=90). 
 *
 * The following properties default values are pulled from $config->adminThumbOptions and can be overridden
 * by setting directly to an instance of this Inputfield:
 *
 * @property int $gridSize squared size of the admin thumbnails (default=130)
 * @property string $gridMode Default grid mode in admin, one of "grid", "left" or "list" (default="grid")
 * @property string $focusMode May be 'on', 'off', or 'zoom'
 * @property array $imageSizerOptions Options to pass along to the ImageSizer class. See /wire/config.php $imageSizerOptions for details.
 * 
 * 
 * Hookable Methods
 * 
 * @method string render()
 * @method string renderItem(Pageimage $pagefile, $id, $n)
 * @method string renderList(Pageimages $value)
 * @method string renderUpload(Pageimages $value)
 * @method string renderSingleItem(Pageimage $pagefile, $id, $n)
 * @method string renderButtons(Pageimage $pagefile, $id, $n)
 * @method string renderAdditionalFields(Pageimage $pagefile, $id, $n)
 * @method array buildTooltipData(Pageimage $pagefile)
 * @method array getFileActions(Pagefile $pagefile)
 * @method bool|null processUnknownFileAction(Pageimage $pagefile, $action, $label)
 *
 *
 */

class InputfieldImage extends InputfieldFile implements InputfieldItemList, InputfieldHasSortableValue {

  public static function getModuleInfo() {
    return array(
      'title' => __('Images', __FILE__), // Module Title
      'summary' => __('One or more image uploads (sortable)', __FILE__), // Module Summary
      'version' => 123,
      'permanent' => true,
    );
  }

  /**
   * Default square grid item size
   * 
   */
  const defaultGridSize = 130;

  /**
   * Force render value mode for dev/debug purposes
   * 
   */
  const debugRenderValue = false;

  /**
   * Cached list of all image variations
   *
   * @var array
   *
   */
  protected $variations = array();
  
  /**
   * Class used for modal editor windows
   *
   * @var string
   *
   */
  protected $modalClass = 'pw-modal-large';
  

  public function init() {
    parent::init();

    $this->set('extensions', 'JPG JPEG GIF PNG');
    $this->set('maxWidth', '');
    $this->set('maxHeight', '');
    $this->set('maxSize', 0.0);
    $this->set('maxReject', 0);
    $this->set('minWidth', '');
    $this->set('minHeight', '');
    $this->set('resizeServer', 0); // 0=allow client resize, 1=resize at server only
    $this->set('clientQuality', 90);
    $this->set('dimensionsByAspectRatio', 0);
    $this->set('itemClass', 'gridImage ui-widget');

    $options = $this->wire('config')->adminThumbOptions;
    if(!is_array($options)) $options = array();
    $gridSize = empty($options['gridSize']) ? self::defaultGridSize : (int) $options['gridSize'];
    if($gridSize < 100) $gridSize = self::defaultGridSize; // establish min of 100
    if($gridSize >= (self::defaultGridSize * 2)) $gridSize = self::defaultGridSize; // establish max of 259
    $this->set('gridSize', $gridSize); 
    $this->set('gridMode', 'grid'); // one of "grid", "left" or "list"
    $this->set('focusMode', 'on');  // One of "on", "zoom" or "off" 
    
    // adminThumbScale is no longer in use (here in case descending module using it)
    $this->set('adminThumbScale', empty($options['scale']) ? 1.0 : (float) $options['scale']);

    if(empty($options['imageSizerOptions'])) {
      // properties specified in $options rather than $options['imageSizerOptions'], so we copy them
      $options['imageSizerOptions'] = array();
      foreach($options as $key => $value) {
        if($key == 'height' || $key == 'width' || $key == 'scale' || $key == 'gridSize') continue;
        $options['imageSizerOptions'][$key] = $value;
      }
    }
    $this->set('imageSizerOptions', empty($options['imageSizerOptions']) ? array() : $options['imageSizerOptions']);
    $this->set('useImageEditor', 1);
    
    $this->labels = array_merge($this->labels, array(
      'crop' => $this->_('Crop'), 
      'focus' => $this->_('Focus'), 
      'variations' => $this->_('Variations'), 
      'dimensions' => $this->_('Dimensions'), 
      'filesize' => $this->_('Filesize'), 
      'edit' => $this->_('Edit'),
      'drag-drop-in' => $this->_('drag and drop in new images above'), 
      'na' => $this->_('N/A'), // for JS
      'changes' => $this->_('This images field may have unsaved changes that could be lost after this action. Please save before cropping, or double-click the button proceed anyway.'),
    ));

    $themeDefaults = array(
      // 'error' => "<span class='ui-state-error-text'>{out}</span>", // provided by InputfieldFile
      'buttonClass' => "ui-button ui-corner-all ui-state-default",
      'buttonText' => "<span class='ui-button-text'>{out}</span>", 
      'selectClass' => '', 
    );
    $themeSettings = $this->wire('config')->InputfieldImage;
    $themeSettings = is_array($themeSettings) ? array_merge($themeDefaults, $themeSettings) : $themeDefaults;
    $this->themeSettings = array_merge($this->themeSettings, $themeSettings);
  }
  
  public function get($key) {
    if($key == 'themeSettings') return $this->themeSettings;
    return parent::get($key);
  }

  /**
   * Called right before Inputfield render
   * 
   * @param Inputfield $parent Parent Inputfield
   * @param bool $renderValueMode Whether or not we are in renderValue mode
   * @return bool
   * 
   */
  public function renderReady(Inputfield $parent = null, $renderValueMode = false) {

    if(self::debugRenderValue) {
      // force render value mode for dev/debugging purposes
      $renderValueMode = true;
      $this->renderValueMode = true;
      $this->addClass('InputfieldRenderValueMode', 'wrapClass');
    }
  
    $config = $this->wire('config');
    $modules = $this->wire('modules');
    $jqueryCore = $modules->get('JqueryCore');
    $jqueryCore->use('simulate');
    $jqueryCore->use('cookie');
    $modules->loadModuleFileAssets('InputfieldFile');
    $modules->getInstall("JqueryMagnific");
    
    if(!$renderValueMode && $this->focusMode == 'zoom') {
      $this->addClass('InputfieldImageFocusZoom', 'wrapClass'); 
    }

    $settings = $config->get('InputfieldImage'); 
    if(!is_array($settings)) $settings = array();
    if(empty($settings['ready'])) {
      $settings['labels'] = $this->labels;
      $settings['ready'] = true;
      $config->js('InputfieldImage', $settings);
    }

    // client side image resize 
    if(!$this->resizeServer && ($this->maxWidth || $this->maxHeight || $this->maxSize)) {
      $thisURL = $config->urls->InputfieldImage;
      $jsExt = $config->debug ? "js" : "min.js";
      $config->scripts->add($thisURL . "exif.$jsExt");
      $config->scripts->add($thisURL . "PWImageResizer.$jsExt");
      $maxSize = str_replace(',', '.', $this->maxSize); 
      $quality = str_replace(',', '.', (float) ($this->clientQuality / 100));
      $this->wrapAttr('data-resize', "$this->maxWidth;$this->maxHeight;$maxSize;$quality");
    }
    
    if(!$renderValueMode && $this->value instanceof Pageimages) {
      $page = $this->hasPage;
      if(!$page || !$page->id) {
        $process = $this->wire('process');
        if($process instanceof WirePageEditor) {
          $page = $process->getPage();
        } else {
          $page = new NullPage();
        }
      }
      if(wireClassExists('RepeaterPage')) {
        /** @var RepeaterPage $page */
        while(wireInstanceOf($page, 'RepeaterPage')) $page = $page->getForPage();
      }
      if($page->id && $this->wire('user')->hasPermission('page-edit-images', $page)) {
        $modules->get('JqueryUI')->use('modal');
      } else {
        $this->useImageEditor = 0;
      }
    }
  
    if($this->value instanceof Pageimages) $this->variations = $this->value->getAllVariations();
    
    return parent::renderReady($parent, $renderValueMode);
  }

  /**
   * Render Inputfield
   * 
   * @return string
   * 
   */
  public function ___render() {
    if($this->isAjax) clearstatcache();
    $out = parent::___render();
    return $out;
  }
  
  /**
   * Render list of images
   * 
   * @param Pageimages|array $value
   * @return string
   * @throws WireException
   * 
   */
  protected function ___renderList($value) {
    
    //if(!$value) return '';
    $out = '';
    $n = 0;
  
    $this->renderListReady($value);
    
    if(!$this->uploadOnlyMode && WireArray::iterable($value)) {
      
      foreach($value as $k => $pagefile) {
        $id = $this->pagefileId($pagefile);
        $this->currentItem = $pagefile;

        $out .= $this->renderItemWrap($this->renderItem($pagefile, $id, $n++));
        /*
        if($this->maxFiles != 1) {
          $out .= $this->renderItemWrap($this->renderItem($pagefile, $id, $n++));
        } else {
          $out .= $this->renderSingleItem($pagefile, $id, $n++);
        }
        */
      }

      if(!$this->renderValueMode) {
        $dropNew = $this->wire('sanitizer')->entities1($this->_('drop in new image file to replace')); 
        $focus = $this->wire('sanitizer')->entities1($this->_('drag circle to center of focus'));
        $out .= "
          <div class='InputfieldImageEdit'>
            <div class='InputfieldImageEdit__inner'>
              <div class='InputfieldImageEdit__arrow'></div>
              <div class='InputfieldImageEdit__close'><span class='fa fa-times'></span></div>
              <div class='InputfieldImageEdit__imagewrapper'>
                <div>
                  <img class='InputfieldImageEdit__image' src='' alt=''>
                  <small class='detail detail-upload'>$dropNew</small>
                  <small class='detail detail-focus'>$focus</small>
                </div>
              </div>
              <div class='InputfieldImageEdit__edit'></div>
            </div>
          </div>
        ";
      }
    }

    $class = 'gridImages ui-helper-clearfix';
    if($this->uploadOnlyMode) $class .= " InputfieldImageUploadOnly";
    if($this->overwrite && !$this->renderValueMode) $class .= " InputfieldFileOverwrite";
    $out = "<ul class='$class' data-gridSize='$this->gridSize' data-gridMode='$this->gridMode'>$out</ul>";
    $out = "<ul class='InputfieldImageErrors'></ul>$out";

    return $out;
  }

  protected function renderItemWrap($out) {
    $item = $this->currentItem;
    $id = $item && !$this->renderValueMode ? " id='file_$item->hash'" : "";
    return "<li$id class='ImageOuter {$this->itemClass}'>$out</li>";
  }

  protected function ___renderUpload($value) {

    if($this->noUpload || $this->renderValueMode) return '';

    // enables user to choose more than one file
    if($this->maxFiles != 1) $this->setAttribute('multiple', 'multiple');

    $attrs = $this->getAttributes();
    unset($attrs['value']);
    if(substr($attrs['name'], -1) != ']') $attrs['name'] .= '[]';
    $attrStr = $this->getAttributesString($attrs);

    $extensions = $this->extensions;
    if($this->unzip && !$this->maxFiles) $extensions .= ' zip';
    $formatExtensions = $this->formatExtensions($extensions);
    $chooseLabel = $this->labels['choose-file'];
    
    $out = 
      "<div " . 
      "data-maxfilesize='{$this->maxFilesize}' " . 
      "data-extensions='{$extensions}' " . 
      "data-fieldname='$attrs[name]' " . 
      "class='InputfieldImageUpload'" . 
      ">";
    
    $out .= "
      <div class='InputMask ui-button ui-state-default'>
        <span class='ui-button-text'>
          <i class='fa fa-fw fa-folder-open-o'></i>$chooseLabel
        </span>
        <input $attrStr>
      </div>
      <span class='InputfieldImageValidExtensions detail'>$formatExtensions</span>
      <input type='hidden' class='InputfieldImageMaxFiles' value='{$this->maxFiles}' />
      ";

    if(!$this->noAjax) {

      $dropLabel = $this->uploadOnlyMode ? $this->labels['drag-drop'] : $this->labels['drag-drop-in'];
      // $refreshLabel = $this->('legacy thumbnails will be re-created on save');
      
      $out .= "
        <span class='AjaxUploadDropHere description'>
          <span>
            <i class='fa fa-cloud-upload'></i>&nbsp;$dropLabel
          </span>
        </span>";
    }
    
    if($this->get('_hasLegacyThumbs')) {
      $label = $this->_('There are older/low quality thumbnail preview images above – check this box to re-create them.');
      $out .= "
        <p class='InputfieldImageRefresh detail'>
          <label>
            <input type='checkbox' name='_refresh_thumbnails_$this->name' value='1' />
            $label
          </label>
        </p>
        ";
    }

    $out .= "</div>";

    return $out;
  }


  /**
   * Resize images to max width/height if specified in field config and image is larger than max
   * 
   * #pw-hooker
   * 
   * @param Pagefile $pagefile
   * @throws WireException
   *
   */
  protected function ___fileAdded(Pagefile $pagefile) {
    
    /** @var Pageimage $pagefile */
    
    if($pagefile->ext() == 'svg') {
      parent::___fileAdded($pagefile);
      return;
    }

    $pagefile2 = null;

    if(!$pagefile->width) {
      $pagefile->unlink();
      throw new WireException($this->_('Invalid image'));
    }

    $minWidth = $this->minWidth;
    $minHeight = $this->minHeight;

    if($this->dimensionsByAspectRatio && $pagefile->width < $pagefile->height){
      $minWidth = $this->minHeight;
      $minHeight = $this->minWidth;
    }

    if(
      ($minWidth && $pagefile->width < $minWidth) ||
      ($minHeight && $pagefile->height < $minHeight)
    ) {
      $actualDimensions = $pagefile->width . 'x' . $pagefile->height;
      $requiredDimensions = $minWidth . 'x' . $minHeight;
      throw new WireException(
        sprintf($this->_('Image of %s does not meet minimum size requirements'), $actualDimensions) . " ($requiredDimensions)"
      );
    }

    $maxWidth = $this->maxWidth;
    $maxHeight = $this->maxHeight;

    if($this->dimensionsByAspectRatio && $pagefile->width < $pagefile->height){
      $maxWidth = $this->maxHeight;
      $maxHeight = $this->maxWidth;
    }

    if(
      ($maxWidth && $pagefile->width > $maxWidth) ||
      ($maxHeight && $pagefile->height > $maxHeight)
    ) {
      if($this->maxReject) {
        $actualDimensions = $pagefile->width . '×' . $pagefile->height;
        $requiredDimensions = $maxWidth . '×' . $maxHeight;
        throw new WireException(
          sprintf($this->_('Image of %s exceeds maximum allowed size'), $actualDimensions) . " ($requiredDimensions)"
        );
      }
      $pagefile2 = $pagefile->size($maxWidth, $maxHeight, array('cropping' => false));
      if($pagefile->filename != $pagefile2->filename) {
        $this->wire('files')->unlink($pagefile->filename);
        $this->wire('files')->rename($pagefile2->filename, $pagefile->filename);
      }
      $pagefile->getImageInfo(true); // force it to reload its dimensions
    }

    if($pagefile2) {
      $this->message($this->_("Image resized to fit maximum allowed dimensions") . " ({$maxWidth}x{$maxHeight}");
    }

    parent::___fileAdded($pagefile);
  }

  protected function fileAddedGetMarkup(Pagefile $pagefile, $n) {
    /** @var Pageimage $pagefile */
    /*
    $markup = $this->maxFiles == 1
      ? $this->renderSingleItem($pagefile, $this->pagefileId($pagefile), $n)
      : $this->renderItemWrap($this->renderItem($pagefile, $this->pagefileId($pagefile), $n));
    */
    $markup = $this->renderItemWrap($this->renderItem($pagefile, $this->pagefileId($pagefile), $n));
    return $markup;
  }

  /**
   * Get thumbnail image info
   *
   * @param Pageimage $img Image to get thumb for
   * @param bool $useSizeAttributes Whether width and or height size attributes should be included in the <img> tag
   * @param bool $remove Specify true to remove legacy thumbnail file
   *
   * @return array of(
   *    'thumb' => Pageimage object,
   *    'attr' => associative array of image attributes
   *    'markup' => string of markup for <img>,
   *    'amarkup' => same as above but wrapped in <a> tag
   *    'error' => error message if applicable
   *    'title' => potential title attribute for <a> tag with image info 
   *    );
   *
   */
  public function getAdminThumb(Pageimage $img, $useSizeAttributes = true, $remove = false) {

    $thumb = $img;
    $error = '';
    $attr = array();

    $_thumbHeight = $thumb->height;
    $thumbHeight = $_thumbHeight;
    $_thumbWidth = $thumb->width;
    $thumbWidth = $_thumbWidth;
    $useResize = ($img->ext == 'svg' && $thumbHeight == '100%')
      || ($this->gridSize && $thumbHeight > $this->gridSize)
      || ($this->gridSize && $thumbWidth > $this->gridSize);

    if($useResize) {
      
      $imageSizerOptions = $this->imageSizerOptions;
      $imageSizerOptions['upscaling'] = true;
      $imageSizerOptions['focus'] = false; // disable focus since we show focus from JS/CSS in admin thumbs
      $adminThumbOptions = $this->wire('config')->adminThumbOptions;
      $gridSize2x = $this->gridSize * 2;
      // if($adminThumbOptions['scale'] === 1.0) $gridSize2x = $this->gridSize; // force non-HiDPI
      
      // check if there is an existing thumbnail using pre-gridSize legacy settings
      $h = (int) $adminThumbOptions['height'];
      $w = (int) $adminThumbOptions['width'];
      $f = $img->pagefiles->path() . basename($img->basename(), '.' . $img->ext()) . ".{$w}x{$h}." . $img->ext();
      $exists = is_file($f);
      
      if($exists && $remove) {
        $this->wire('files')->unlink($f); 
        $exists = false;
      }
      
      if($exists) {
        // use existing legacy thumbnail (upscaled in browser to gridSize)
        $thumb = $thumb->size($w, $h, $imageSizerOptions);
        if($thumbWidth > $thumbHeight) {
          $thumbHeight = $this->gridSize;
          $thumbWidth = 0;
        } else if($thumbWidth < $thumbHeight) {
          $thumbWidth = $this->gridSize;
          $thumbHeight = 0;
        } else {
          $thumbWidth = $this->gridSize;  
          $thumbHeight = $this->gridSize;
        }
        $this->set('_hasLegacyThumbs', true);
      } else {
        // use new thumbnail size, 260px (scaled to 130px in output)
        if($thumbWidth >= $thumbHeight) {
          if($thumbHeight > $gridSize2x) {
            $thumb = $thumb->height($gridSize2x, $imageSizerOptions);
            $thumbHeight = $this->gridSize;
            $thumbWidth = 0;
          }
        } else if($thumbWidth > $gridSize2x) {
          $thumb = $thumb->width($gridSize2x, $imageSizerOptions);
          $thumbWidth = $this->gridSize;
          $thumbHeight = 0;
        }
      }

      if($thumb->error) $error = $thumb->error;
    }

    if($useSizeAttributes) {
      if($thumb->get('_requireHeight')) {
        // _requireHeight set by InputfieldPageEditImageSelect
        if(!$thumbHeight || $thumbHeight > $this->gridSize) $thumbHeight = $this->gridSize;
        $attr['height'] = $thumbHeight;
      } else if($thumbHeight && $thumbWidth) {
        $attr['width'] = $thumbWidth;
        $attr['height'] = $thumbHeight;
      } else if($thumbHeight) {
        if(!$thumbHeight) $thumbHeight = $this->gridSize;
        $attr['height'] = $thumbHeight;
      } else if($thumbWidth) {
        $attr['width'] = $thumbWidth;
      }
    }

    $attr['src'] = $thumb->URL;
    $attr['alt'] = $this->wire('sanitizer')->entities1($img->description);
    $attr['data-w'] = $_thumbWidth;
    $attr['data-h'] = $_thumbHeight;
    $attr["data-original"] = $img->URL;
  
    $focus = $img->focus();
    $attr['data-focus'] = $focus['str'];
    
    $markup = "<img ";
    foreach($attr as $key => $value) $markup .= "$key=\"$value\" ";
    $markup .= " />";
    
    $title = $img->basename() . " ({$img->width}x{$img->height}) $img->filesizeStr";
    if($attr['alt']) $title .= ": $attr[alt]";
    
    $amarkup = "<a href='$img->url' title='$title'>$markup</a>";

    $a = array(
      'thumb' => $thumb,
      'attr' => $attr,
      'markup' => $markup,
      'amarkup' => $amarkup, 
      'error' => $error,
      'title' => $title, 
    );
    
    return $a;
  }

  /**
   * Get Pagefile to pull description and tags from 
   * 
   * @param Pagefile $pagefile
   * @return Pageimage|Pagefile
   * 
   */
  protected function getMetaPagefile(Pagefile $pagefile) {
    if(!$this->isAjax || !isset($_SERVER['HTTP_X_REPLACENAME'])) return $pagefile;
    $metaFilename = $_SERVER['HTTP_X_REPLACENAME'];
    if(strpos($metaFilename, '?')) list($metaFilename,) = explode('?', $metaFilename); 
    $metaFilename = $this->wire('sanitizer')->name($metaFilename);
    $metaPagefile = $this->attr('value')->get($metaFilename);
    if(!$metaPagefile instanceof Pagefile) $metaPagefile = $pagefile;
    return $metaPagefile;
  }

  /**
   * Render a Pageimage item
   *
   * @param Pagefile|Pageimage $pagefile
   * @param string $id
   * @param int $n
   *
   * @return string
   *
   */
  protected function ___renderItem($pagefile, $id, $n) {

    $sanitizer = $this->wire('sanitizer');
    $thumb = $this->getAdminThumb($pagefile, false);
    $fileStats = str_replace(' ', '&nbsp;', $pagefile->filesizeStr) . ", {$pagefile->width}&times;{$pagefile->height} ";
    
    foreach($pagefile->extras() as $name => $extra) {
      if($extra->exists()) $fileStats .= " &bull; $extra->filesizeStr $name ($extra->savingsPct)";
    }
    
    // $gridSize = $this->gridSize;

    // <div class='gridImage__overflow' style='width: {$gridSize}px; height: {$gridSize}px'>
    $out = $this->getTooltip($pagefile) . "
      <div class='gridImage__overflow'>
        $thumb[markup]
      </div>
      ";
    
    if(!$this->isEditableInRendering($pagefile)) return $out;

    if($this->uploadOnlyMode) {
      $out .= "
        <div class='ImageData'>
          <input class='InputfieldFileSort' type='text' name='sort_$id' value='$n' />
        </div>
      ";
    } else {
      $buttons = $pagefile->ext() == 'svg' ? '' : $this->renderButtons($pagefile, $id, $n);
      $metaPagefile = $this->getMetaPagefile($pagefile);
      $description = $this->renderItemDescriptionField($metaPagefile, $id, $n);
      $additional = $this->renderAdditionalFields($metaPagefile, $id, $n);
      $actions = $this->renderFileActionSelect($metaPagefile, $id); 
      $error = '';
      if($thumb['error']) {
        $error = str_replace('{out}', $sanitizer->entities($thumb['error']), $this->themeSettings['error']);
      } 
      $labels = $this->labels;
      $out .= "
        <div class='gridImage__hover'>
          <div class='gridImage__inner'>
            <label for='' class='gridImage__trash'>
              <input class='gridImage__deletebox' type='checkbox' name='delete_$id' value='1' title='$labels[delete]' />
              <span class='fa fa-trash-o'></span>
            </label>
            <a class='gridImage__edit'>
              <span>$labels[edit]</span>
            </a>
          </div>
        </div>
      ";

      $ext = $pagefile->ext();
      $basename = $pagefile->basename(false);
      $focus = $pagefile->focus();

      $inputfields = $this->getItemInputfields($pagefile);
      if($inputfields) $additional .= $inputfields->render();
        
      $out .= "
        <div class='ImageData'>
          <h2 class='InputfieldImageEdit__name'><span contenteditable='true'>$basename</span>.$ext</h2>
          <span class='InputfieldImageEdit__info'>$fileStats</span>
          <div class='InputfieldImageEdit__errors'>$error</div>
          <div class='InputfieldImageEdit__buttons'><small>$buttons</small> $actions</div>
          <div class='InputfieldImageEdit__core'>$description</div>
          <div class='InputfieldImageEdit__additional'>$additional</div>
          <input class='InputfieldFileSort' type='text' name='sort_$id' value='$n' />
          <input class='InputfieldFileReplace' type='hidden' name='replace_$id' />
          <input class='InputfieldFileRename' type='hidden' name='rename_$id' />
          <input class='InputfieldImageFocus' type='hidden' name='focus_$id' value='$focus[str]' />
        </div>
      ";
    }

    return $out;
  }

  /**
   * Render a Pageimage item
   *
   * @deprecated No longer used by core. Left for a little while longer in case any extending module uses it. 
   * @param Pagefile|Pageimage $pagefile
   * @param string $id
   * @param int $n
   * @return string
   *
   */
  protected function ___renderSingleItem($pagefile, $id, $n) {
    
    $editable = $this->isEditableInRendering($pagefile);
    $fileStats = str_replace(' ', '&nbsp;', $pagefile->filesizeStr) . ", {$pagefile->width}&times;{$pagefile->height} ";
    $description = $this->wire('sanitizer')->entities($pagefile->description);
    $deleteLabel = $this->labels['delete'];
    
    if($editable) {
      $buttons = $this->renderButtons($pagefile, $id, $n);
      $metaPagefile = $this->getMetaPagefile($pagefile);
      $descriptionField = $this->renderItemDescriptionField($metaPagefile, $id, $n);
      $additional = $this->renderAdditionalFields($metaPagefile, $id, $n);
      $editableOut = "
        <div class='InputfieldImageEdit__buttons'>$buttons</div>
        <div class='InputfieldImageEdit__core'>$descriptionField</div>
        <div class='InputfieldImageEdit__additional'>$additional</div>
        <input class='InputfieldFileSort' type='hidden' name='sort_$id' value='$n' />
        <input class='InputfieldFileReplace' type='hidden' name='replace_$id' />
        <input class='InputfieldFileRename' type='hidden' name='rename_$id' />
      ";
    } else {
      $editableOut = '';
      //$editableOut = "<p>" . $this->_("Not editable.") . "</p>";
    }
  
    $trashOut = '';
    if($editable && !$this->renderValueMode) $trashOut = "
          <div class='InputfieldImageEdit__trash-single'>
            <label for='' class='gridImage__trash gridImage__trash--single'>
              <input class='gridImage__deletebox' type='checkbox' name='delete_$id' value='1' title='$deleteLabel' />
              <span class='fa fa-trash-o'></span>
            </label>
          </div>
    ";
    
    $out = "
      <div class='ImageOuter InputfieldImageEdit InputfieldImageEditSingle' id='file_$pagefile->hash'>
        <div class='InputfieldImageEdit__inner'>
          $trashOut
          <div class='InputfieldImageEdit__imagewrapper'>
            <div>
              <img class='InputfieldImageEdit__image' src='$pagefile->URL' alt='$description'>
            </div>
          </div>
          <div class='InputfieldImageEdit__edit'>
            <h2 class='InputfieldImageEdit__name'>$pagefile->name</h2>
            <span class='InputfieldImageEdit__info'>$fileStats</span>
            $editableOut
          </div>
        </div>
      </div>
      ";

    return $out;
  }

  /**
   * Render buttons for image edit mode 
   * 
   * #pw-hooker
   * 
   * @param Pagefile|Pageimage $pagefile
   * @param string $id
   * @param int $n
   * @return string
   * 
   */
  protected function ___renderButtons($pagefile, $id, $n) {
    
    if(!$this->useImageEditor) return '';
  
    if($n) {} // ignore, $n is for hooks
    $pageID = $pagefile->pagefiles->page->id;
    $variationCount = $pagefile->variations()->count();
    // if($pagefile->webp()->exists()) $variationCount++;
    $editUrl = $this->getEditUrl($pagefile, $pageID); 
    $variationUrl = $this->getVariationUrl($pagefile, $id);
    $buttonClass = $this->themeSettings['buttonClass'];
    $modalButtonClass = trim("$buttonClass $this->modalClass pw-modal");
    $modalAttrs = "data-buttons='#non_rte_dialog_buttons button' data-autoclose='1' data-close='#non_rte_cancel'";
    $labels = $this->labels;
    $out = '';
    
  
    // Crop
    $buttonText = str_replace('{out}', "<i class='fa fa-crop'></i> $labels[crop]", $this->themeSettings['buttonText']);
    $out .= "<button type='button' data-href='$editUrl' class='InputfieldImageButtonCrop $modalButtonClass' $modalAttrs>$buttonText</button>";
  
    // Focus
    if($this->focusMode && $this->focusMode != 'off') {
      $iconA = $pagefile->hasFocus ? 'fa-check-circle-o' : 'fa-circle-o';
      $iconB = $pagefile->hasFocus ? 'fa-check-circle' : 'fa-dot-circle-o';
      $buttonText = str_replace('{out}', "<i class='fa $iconA' data-toggle='$iconA $iconB'></i> $labels[focus]", $this->themeSettings['buttonText']);
      $out .= "<button type='button' class='InputfieldImageButtonFocus $buttonClass'>$buttonText</button>";
    }
  
    // Variations
    $buttonText = "<i class='fa fa-files-o'></i> $labels[variations] <span class='ui-priority-secondary'>($variationCount)</span>";
    $buttonText = str_replace('{out}', $buttonText, $this->themeSettings['buttonText']);
    $out .= "<button type='button' data-href='$variationUrl' class='$modalButtonClass' data-buttons='button'>$buttonText</button>";
    
    return $out;
  }

  /**
   * Render an image action select for given Pageimage
   * 
   * @param Pagefile $pagefile
   * @param string $id
   * @return string
   * 
   */
  protected function renderFileActionSelect(Pagefile $pagefile, $id) {

    if(!$this->useImageEditor) return '';
  
    static $hooked = null; 
    
    if($hooked === null) $hooked = 
      $this->wire('hooks')->isHooked('InputfieldImage::getFileActions()') ||
      $this->wire('hooks')->isHooked('InputfieldFile::getFileActions()');
    
    $actions = $hooked ? $this->getFileActions($pagefile) : $this->___getFileActions($pagefile);
    
    if(empty($actions)) return '';
    
    $selectClass = trim($this->themeSettings['selectClass'] . ' InputfieldFileActionSelect');
    /** @var Sanitizer $sanitizer */
    $sanitizer = $this->wire('sanitizer');
    
    $out =
      "<select class='$selectClass' name='act_$id'>" .
      "<option value=''>" . $this->_('Actions') . "</option>";
    
    foreach($actions as $name => $label) {
      $out .= "<option value='$name'>" . $sanitizer->entities1($label) . "</option>";
    }

    $out .= "</select> ";
    $out .= "<span class='InputfieldFileActionNote detail'>" . $this->_('Action applied at save.') . "</span>";
    
    return $out;
  }

  /**
   * Get array of actions available for given Pagefile
   * 
   * @param Pagefile|Pageimage $pagefile
   * @return array Associative array of ('action_name' => 'Action Label')
   * 
   */
  public function ___getFileActions(Pagefile $pagefile) {
    
    static $labels = null;
    static $hasIMagick = null;
    
    if($hasIMagick === null) {
      $hasIMagick = $this->wire('modules')->isInstalled('ImageSizerEngineIMagick');
    }
    
    if($labels === null) $labels = array(
      'flip' => $this->_('Flip'),
      'rotate' => $this->_('Rotate'),
      'dup' => $this->_('Duplicate'),
      'rmv' => $this->_('Remove variations'),
      'rbv' => $this->_('Rebuild variations'),
      'rmf' => $this->_('Remove focus'), 
      'vertical' => $this->_('vert'),
      'horizontal' => $this->_('horiz'), 
      'both' => $this->_('both'), 
      'cop' => $this->_('Copy'), 
      'pas' => $this->_('Paste'),
      'x50' => $this->_('Reduce 50%'), 
      'bw' => $this->_('B&W'), // Black and White
      'sep' => $this->_('Sepia'), 
    );
    
    $actions = array(
      'dup' => $labels['dup'],
    );
    
    if($this->maxFiles && count($pagefile->pagefiles) >= $this->maxFiles) {
      unset($actions['dup']); 
    }

    if($pagefile->ext() != 'svg') {
      
      // $actions['rmv'] = $labels['rmv'];
      // $actions['rbv'] = $labels['rbv'];
      
      $actions['fv'] = "$labels[flip] $labels[vertical]";
      $actions['fh'] = "$labels[flip] $labels[horizontal]";
      $actions['fb'] = "$labels[flip] $labels[both]";
      
      foreach(array(90, 180, 270, -90, -180, -270) as $degrees) {
        $actions["r$degrees"] = "$labels[rotate] {$degrees}°";
      }
      
      if($hasIMagick) {
        $actions['x50'] = $labels['x50'];
      }
      
      $actions['bw'] = $labels['bw'];
      $actions['sep'] = $labels['sep'];
      
      if($pagefile->hasFocus) {
        $actions['rmf'] = $labels['rmf'];
      }
    }
    
    return $actions;
  }

  /**
   * Render non-editable value
   * 
   * @return string
   * 
  public function ___renderValue() {
    $value = $this->value;
    if(!$value instanceof Pageimages) return '';
    $out = '';
    foreach($value as $img) {
      $info = $this->getAdminThumb($img);
      $out .= $info['amarkup'];
      
    }
    return $out; 
  }
   */

  /**
   * Render any additional fields (for hooks)
   * 
   * #pw-hooker
   * 
   * @param Pageimage|Pagefile $pagefile
   * @param string $id
   * @param int $n
   * 
   */
  protected function ___renderAdditionalFields($pagefile, $id, $n) { }

  /*
  protected function ___renderClipboard() {
    $clipboard = $this->wire('session')->getFor('Pagefiles', 'clipboard');
    if(!is_array($clipboard)) return '';
    foreach($clipboard as $key) {
      list($type, $pageID, $fieldName, $file) = explode(':', $key);
      $page = $this->wire('pages')->get((int) $pageID);
      $field = $this->wire('fields')->get($fieldName);
    }
  }
  */

  /**
   * Template method: allow items to be collapsed? Override default from InputfieldFile
   *
   * @return bool
   *
   */
  protected function allowCollapsedItems() {
    return false;
  }

  /**
   * Configure field
   *
   * @return InputfieldWrapper
   *
   */
  public function ___getConfigInputfields() {

    $inputfields = parent::___getConfigInputfields();
  
    /** @var InputfieldRadios $f */
    $f = $this->wire('modules')->get('InputfieldRadios');
    $f->attr('name', 'gridMode');
    $f->label = $this->_('Default image grid mode');
    $f->description = $this->_('In the admin, the list of images will appear in this mode by default. The user can change it at any time by clicking the icons in the top right corner of the field.');
    $f->notes = $this->_('If you have recently used this images field, you will have to clear your cookies before seeing any changes to this setting.');
    $f->icon = 'photo';
    $f->addOption('grid', '[i.fa.fa-th][/i] ' . $this->_('Square grid images'));
    $f->addOption('left', '[i.fa.fa-tasks][/i] ' . $this->_('Proportional grid images'));
    $f->addOption('list', '[i.fa.fa-th-list][/i] ' . $this->_('Vertical list (verbose)'));
    $f->attr('value', $this->gridMode);
    $f->columnWidth = 50;
    $inputfields->add($f);

    /** @var InputfieldRadios $f */
    $f = $this->wire('modules')->get('InputfieldRadios'); 
    $f->attr('name', 'focusMode'); 
    $f->label = $this->_('Focus point selection'); 
    $f->description = $this->_('Enables a draggable focus point to select the subject of an image. This helps to generate non-proportional crops.') . ' ' . 
      $this->_('A preview of the focus point is also shown when images are in the “Square grid images” mode.');
    $f->addOption('on', $this->_('Focus point')); 
    $f->addOption('zoom', $this->_('Focus point and zoom'));
    $f->addOption('off', $this->_('Disabled'));
    $f->attr('value', $this->focusMode); 
    $f->icon = 'crosshairs';
    $f->columnWidth = 50;
    $inputfields->add($f);

    /** @var InputfieldFieldset $fieldset */
    $fieldset = $this->modules->get('InputfieldFieldset');
    $fieldset->label = $this->_("Maximum image dimensions");
    $fieldset->icon = 'expand';
    //$fieldset->collapsed = $this->maxWidth || $this->maxHeight ? Inputfield::collapsedNo : Inputfield::collapsedYes;
    $fieldset->description = $this->_("Optionally enter the max width and/or height of uploaded images. If specified, images will be resized at upload time when they exceed either the max width or height. The resize is performed at upload time, and thus does not affect any images in the system, or images added via the API."); // Max image dimensions description
    $fieldset->description .= ' ' . $this->_('Applies to JPG, PNG and GIF images.');

    $description = $this->_("Enter the value in number of pixels or leave blank for no limit."); // Min/Max width/height description
    /** @var InputfieldInteger $field */
    $field = $this->modules->get("InputfieldInteger");
    $field->attr('name', 'maxWidth');
    $field->attr('value', $this->maxWidth ? (int) $this->maxWidth : '');
    $field->label = $this->_("Max width for uploaded images");
    $field->icon = 'arrows-h';
    $field->description = $description;
    $field->columnWidth = 50;
    $field->appendMarkup = '&nbsp;px';
    $fieldset->add($field);

    $field = $this->modules->get("InputfieldInteger");
    $field->attr('name', 'maxHeight');
    $field->attr('value', $this->maxHeight ? (int) $this->maxHeight : '');
    $field->label = $this->_("Max height for uploaded images");
    $field->icon = 'arrows-v';
    $field->description = $description;
    $field->columnWidth = 50;
    $field->appendMarkup = '&nbsp;px';
    $fieldset->add($field);
  
    /** @var InputfieldRadios $field */
    $field = $this->modules->get('InputfieldRadios');
    $field->attr('name', 'resizeServer');
    $field->label = $this->_('How to resize to max dimensions'); 
    $field->description = $this->_('Using client-side resize enables you to reduce the file size and dimensions before uploading.');
    $field->notes = $this->_('When using client-side resize, please specify both max width *and* max height in the fields above, or max megapixels in the field below.');
    $field->icon = 'object-group';
    $field->addOption(0, $this->_('Use client-side resize when possible'));
    $field->addOption(1, $this->_('Use only server-side resize'));
    $field->attr('value', (int) $this->resizeServer); 
    $fieldset->add($field);
    
    $field = $this->modules->get('InputfieldFloat');
    $field->attr('name', 'maxSize'); 
    $field->label = $this->_('Max megapixels for uploaded images'); 
    $field->description = $this->_('This can be used as an alternative to max width/height. Specify a floating point value.');  
    $field->description .= ' ' . $this->_('Applicable to client-side resize only.'); 
    $field->notes = $this->_('A good value for websites is 1.7 which is roughly 1600x1000 pixels, where 1600 and 1000 can be either width or height.');
    $field->notes .= ' ' . $this->_('Other examples:') . ' 0.2=516x387, 2.0=1633x1225, 3.0=2000x1500, 12.0=4000x3000'; 
    $field->icon = 'camera';
    $field->attr('value', (float) $this->maxSize > 0 ? (float) $this->maxSize : ''); 
    $field->showIf = 'resizeServer=0';
    //$field->collapsed = Inputfield::collapsedBlank;
    $field->columnWidth = 50;
    $fieldset->add($field); 
  
    /** @var InputfieldInteger $field */
    $field = $this->modules->get('InputfieldInteger');
    $field->attr('name', 'clientQuality');
    $field->label = $this->_('Client-side resize quality percent for JPEGs');
    $field->description = $this->_('Specify a number between 10 (lowest quality/smallest file size) and 100 (highest quality/largest file size). Default is 90.');
    $field->icon = 'signal';
    $field->min = 10;
    $field->max = 100;
    $field->attr('size', 4); 
    $field->attr('value', (int) $this->clientQuality);
    $field->showIf = 'resizeServer=0';
    $field->columnWidth = 50;
    $field->appendMarkup = '&nbsp;%';
    $fieldset->add($field);

    // maxReject option comes from @JanRomero PR #1051
    /** @var InputfieldCheckbox $field */
    $field = $this->modules->get("InputfieldCheckbox");
    $field->attr('name', 'maxReject');
    $field->attr('value', (int) $this->maxReject);
    $field->attr('checked', ((int) $this->maxReject) ? 'checked' : '');
    $field->label = $this->_('Refuse images exceeding max dimensions?');
    $field->showIf = 'maxWidth|maxHeight>0';
    $field->icon = 'ban';
    $field->description = $this->_('If checked, images that exceed max width/height (that cannot be resized client-side) will be refused rather than resized.');
    if(!$this->maxReject) $field->collapsed = Inputfield::collapsedYes;
    $fieldset->add($field);

    $inputfields->add($fieldset);

    // min image dimensions
    /** @var InputfieldFieldset $fieldset */
    $fieldset = $this->modules->get('InputfieldFieldset');
    $fieldset->label = $this->_("Minimum image dimensions");
    $fieldset->icon = 'compress';
    $fieldset->collapsed = $this->minWidth || $this->minHeight ? Inputfield::collapsedNo : Inputfield::collapsedYes;
    $fieldset->description = $this->_("Optionally enter the minimum width and/or height of uploaded images. If specified, images that don't meet these minimums will be refused."); // Max image dimensions description

    /** @var InputfieldInteger $field */
    $field = $this->modules->get("InputfieldInteger");
    $field->attr('name', 'minWidth');
    $field->attr('value', $this->minWidth ? (int) $this->minWidth : '');
    $field->label = $this->_("Min width for uploaded images");
    $field->description = $description;
    $field->columnWidth = 50;
    $field->icon = 'arrows-h';
    $field->appendMarkup = '&nbsp;px';
    $fieldset->add($field);

    $field = $this->modules->get("InputfieldInteger");
    $field->attr('name', 'minHeight');
    $field->attr('value', $this->minHeight ? (int) $this->minHeight : '');
    $field->label = $this->_("Min height for uploaded images");
    $field->description = $description;
    $field->columnWidth = 50;
    $field->icon = 'arrows-v';
    $field->appendMarkup = '&nbsp;px';
    $fieldset->add($field);

    $inputfields->add($fieldset);

    $field = $this->modules->get("InputfieldCheckbox");
    $field->attr('name', 'dimensionsByAspectRatio');
    $field->attr('value', (int) $this->dimensionsByAspectRatio);
    $field->attr('checked', ((int) $this->dimensionsByAspectRatio) ? 'checked' : '');
    $field->label = $this->_("Swap min/max dimensions for portrait images?");
    $field->showIf = 'minWidth|minHeight|maxWidth|maxHeight>0';
    $field->description = $this->_('If checked, minimum width/height and maximum width/height dimensions will be swapped for portrait images to accommodate for the different aspect ratio.');
    $field->description .= ' ' . $this->_('Applies to server-side resizes only.');
    $field->collapsed = $this->dimensionsByAspectRatio ? Inputfield::collapsedNo : Inputfield::collapsedYes;
    $field->icon = 'exchange';
    $inputfields->add($field);

    return $inputfields;
  }

  /**
   * Is the given image editable during rendering?
   * 
   * @param Pagefile|Pageimage $pagefile
   * @return bool|int
   * 
   */
  protected function isEditableInRendering($pagefile) {
    //$editable = (int) $this->useImageEditor;
    //if($editable) {
    if($this->renderValueMode) {
      $editable = false;
    } else if($pagefile->ext == 'svg') {
      $editable = true;
    } else {
      $editable = true;
    }
    // if(strpos($this->name, '_repeater') && preg_match('/_repeater\d+$/', $this->name)) {
    //   $editable = false;
    // }
    return $editable;
  }

  /**
   * Get URL for viewing image variations
   * 
   * @param Pageimage $pagefile
   * @param string $id
   * @return string
   * 
   */
  protected function getVariationUrl($pagefile, $id) {
    return $this->wire('config')->urls->admin . "page/image/variations/" . 
      "?id={$pagefile->page->id}" . 
      "&file=$pagefile->name" . 
      "&modal=1" . 
      "&varcnt=varcnt_$id";
  }

  /**
   * Get variations for the given Pagefile
   * 
   * @param Pagefile|Pageimage $pagefile
   * @return array
   * 
   */
  protected function getPagefileVariations(Pagefile $pagefile) {
    return isset($this->variations[$pagefile->name]) ? $this->variations[$pagefile->name] : array();
  }

  /**
   * Get the image editor URL
   * 
   * @param Pagefile|Pageimage $pagefile
   * @param int $pageID
   * @return string
   * 
   */
  protected function getEditUrl(Pagefile $pagefile, $pageID) {
    return $this->wire('config')->urls->admin . "page/image/edit/" . 
      "?id=$pageID" . 
      "&file=$pageID,$pagefile->name" . 
      "&rte=0" . 
      "&field=$this->name";
  }

  /**
   * Render the description field input
   * 
   * @param Pagefile|Pageimage $pagefile
   * @param string $id
   * @param int $n
   * @return string
   * 
   */
  protected function renderItemDescriptionField(Pagefile $pagefile, $id, $n) {
    return parent::renderItemDescriptionField($pagefile, $id, $n); // TODO: Change the autogenerated stub
  }
  
  /**
   * Get the hover tooltip that appears above thumbnails
   *
   * @param Pageimage $pagefile
   * @return string
   *
   */
  protected function getTooltip($pagefile) {

    $data = $this->buildTooltipData($pagefile);
    $rows = "";

    foreach($data as $row) {
      $rows .= "<tr><th>$row[0]</th><td>$row[1]</td></tr>";
    }

    $tooltip = "<div class='gridImage__tooltip'><table>$rows</table></div>";

    return $tooltip;
  }


  /**
   * Build data for the tooltip that appears above the thumbnails
   * 
   * #pw-hooker
   * 
   * @param Pagefile|Pageimage $pagefile
   * @return array
   * 
   */
  protected function ___buildTooltipData($pagefile) {
    
    $data = array(
      array(
        $this->labels['dimensions'],
        "{$pagefile->width}x{$pagefile->height}"
      ),
      array(
        $this->labels['filesize'],
        str_replace(' ', '&nbsp;', $pagefile->filesizeStr)
      ),
      array(
        $this->labels['variations'],
        count($this->getPagefileVariations($pagefile))
      )
    );
    
    if(strlen($pagefile->description)) {
      $data[] = array(
        $this->labels['description'],
        "<span class='fa fa-check'></span>"
      );
    }
    
    if($this->useTags && strlen($pagefile->tags)) {
      $data[] = array(
        $this->labels['tags'], 
        "<span class='fa fa-check'></span>"
      );
    }

    return $data;
  }



  /**
   * Return whether or not admin thumbs should be scaled
   * 
   * @return bool
   * @deprecated
   * 
   */
  protected function getAdminThumbScale() {
    return $this->adminThumbScale > 0 && ((float) $this->adminThumbScale) != 1.0;
  }

  /**
   * Process input
   * 
   * @param WireInputData $input
   * @return $this
   * 
   */
  public function ___processInput(WireInputData $input) {
  
    /** @var Sanitizer $sanitizer */
    $sanitizer = $this->wire('sanitizer');
    $page = $this->hasPage;
  
    if($page && $page->id) { 
      if(!$this->wire()->user->hasPermission('page-edit-images', $page)) $this->useImageEditor = 0;
    }
    
    parent::___processInput($input);
    
    if((int) $this->wire('input')->post("_refresh_thumbnails_$this->name")) {
      foreach($this->value as $img) {
        $this->getAdminThumb($img, false, true);
      }
      $this->message($this->_('Recreated all legacy thumbnails') . " - $this->name"); 
    }

    if(!$this->isAjax && !$this->wire('config')->ajax) {
      // process actions, but only on non-ajax save requests
      foreach($this->value as $k => $pagefile) {
        $id = $this->pagefileId($pagefile);
        $action = $input->{"act_$id"};
        if(empty($action)) continue;
        $action = $sanitizer->pageName($action);
        $actions = $this->getFileActions($pagefile);
        if(!isset($actions[$action])) continue; // action not available for this file
        $success = $this->processFileAction($pagefile, $action, $actions[$action]);
        if($success === null) {
          // action was not handled
        }
      }
    }
    
    return $this;
  }

  /**
   * Process input for a given Pageimage
   * 
   * @param WireInputData $input
   * @param Pagefile|Pageimage $pagefile
   * @param int $n
   * @return bool
   * 
   */
  protected function ___processInputFile(WireInputData $input, Pagefile $pagefile, $n) {

    $changed = false;
    
    $id = $this->name . '_' . $pagefile->hash;
    $key = "focus_$id";
    $val = $input->$key;
    
    if($val !== null) {
      if(!strlen($val)) $val = '50 50 0';
      $focus = $pagefile->focus();
      if($focus['str'] !== $val) {
        $pagefile->focus($val);
        $changed = true;
        $focus = $pagefile->focus();
        $rebuild = $pagefile->rebuildVariations();
        // @todo rebuild variations only for images that specify both width and height
        $this->message(
          "Updated focus for $pagefile to: top=$focus[top]%, left=$focus[left]%, zoom=$focus[zoom] " . 
          "and rebuilt " . count($rebuild['rebuilt']) . " variations", 
          Notice::debug
        );
      }
    }
  
    if(parent::___processInputFile($input, $pagefile, $n)) $changed = true;
    
    return $changed;
  }

  /**
   * Process an action on a Pagefile/Pageimage
   * 
   * @param Pageimage $pagefile Image file to process
   * @param string $action Action to execute
   * @param string $label Label that was provided to describe action
   * @return bool|null Returns true on success, false on fail, or null if action was not handled or recognized
   * 
   */
  protected function processFileAction(Pageimage $pagefile, $action, $label) {

    if(!$this->useImageEditor) return null;
    
    $success = null;
    $showSuccess = true; 
    $rebuildVariations = false;
    
    if($action == 'dup') {
      // duplicate image file
      $_pagefile = $pagefile->pagefiles->clone($pagefile);
      $success = $_pagefile ? true : false;
      if($success) {
        $this->wire('session')->message(
          sprintf($this->_('Duplicated file %1$s => %2$s'), $pagefile->basename(), $_pagefile->basename())
        );
        $showSuccess = false;
      }
    } else if($action == 'cop') {
      // copy to another page and/or field
      /*
      $key = 'cop:' . $pagefile->page->id . ':' . $pagefile->field->name . ':' . $pagefile->basename();
      $clipboard = $this->wire('session')->getFor('Pagefiles', 'clipboard'); 
      if(!is_array($clipboard)) $clipboard = array();
      if(!in_array($key, $clipboard)) $clipboard[] = $key;
      $this->wire('session')->setFor('Pagefiles', 'clipboard', $clipboard); 
      */
    } else if($action == 'rbv') {
      // rebuild variations
    } else if($action == 'rmv') {
      // remove variations
    } else if($action == 'rmf') {
      // remove focus
      $pagefile->focus(false); 
      $success = true; 
    } else {
      /** @var ImageSizer $sizer Image sizer actions */
      $sizer = $this->wire(new ImageSizer($pagefile->filename()));
      $rebuildVariations = true;

      if($action == 'fv') {
        $success = $sizer->flipVertical();
      } else if($action == 'fh') {
        $success = $sizer->flipHorizontal();
      } else if($action == 'fb') {
        $success = $sizer->flipBoth();
      } else if($action == 'bw') {
        $success = $sizer->convertToGreyscale();
      } else if($action == 'sep') {
        $success = $sizer->convertToSepia();
      } else if($action == 'x50') {
        /** @var ImageSizerEngineIMagick $engine */
        $engine = $sizer->getEngine();
        if(method_exists($engine, 'reduceByHalf')) {
          $success = $engine->reduceByHalf($pagefile->filename());
          $rebuildVariations = false;
        }
      } else if(strpos($action, 'r') === 0 && preg_match('/^r(-?\d+)$/', $action, $matches)) {
        $deg = (int) $matches[1];
        $success = $sizer->rotate($deg);
      }
      
    }
    
    if($success && $rebuildVariations) $pagefile->rebuildVariations();
    
    if($success === null) {
      // for hooks
      $success = $this->processUnknownFileAction($pagefile, $action, $label); 
    }
    
    if($success) {
      $pagefile->trackChange("action-$action");
      $this->trackChange('value');
    }
    
    if($success && $showSuccess) {
      $this->message(sprintf($this->_('Executed action “%1$s” on file %2$s'), $label, $pagefile->basename));
    } else if($success === false) {
      $this->error(sprintf($this->_('Failed action “%1$s” on file %2$s'), $label, $pagefile->basename));
    } else if($success === null) {
      $this->error(sprintf($this->_('No handler found for action “%1$s” on file %2$s'), $label, $pagefile->basename));
    }
    
    return $success;
  }

  /**
   * Called when an action was received that InputfieldImage does not recognize (for hooking purposes)
   *
   * @param Pageimage $pagefile Image file to process
   * @param string $action Action to execute
   * @param string $label Label that was provided to describe action
   * @return bool|null Returns true on success, false on fail, or null if action was not handled or recognized
   *
   */
  protected function ___processUnknownFileAction(Pageimage $pagefile, $action, $label) {
    if($pagefile && $action && $label) {}
    return null;
  }

}