From c91867bf56af8135ecab3f1d781caaa2d06c2084 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Rabbi=20Islam=20Rony=20=E2=9A=A1=EF=B8=8F?=
<35329385+RabbiIslamRony@users.noreply.github.com>
Date: Sun, 9 Aug 2026 16:29:30 +0600
Subject: [PATCH] improve: add listing image focal point controls
---
assets/src/js/global/add-listing.js | 19 +
.../js/global/components/image-focal-point.js | 838 ++++++++++++++++++
assets/src/scss/component/_add-listing.scss | 433 +++++++++
.../component/listings/_listing-card.scss | 9 +
includes/asset-loader/localized_data.php | 24 +-
includes/classes/class-add-listing.php | 32 +-
.../classes/class-submission-controller.php | 36 +-
includes/helper-functions.php | 340 +++++++
includes/model/ListingDashboard.php | 25 +-
includes/model/Listings.php | 31 +-
includes/model/SingleListing.php | 15 +-
.../builder-preset-fields.php | 6 +
.../Version1/class-listings-controller.php | 1 +
.../Version2/class-listings-controller.php | 1 +
templates/archive/fields/google-map.php | 10 +-
templates/archive/fields/openstreet-map.php | 12 +-
templates/archive/fields/thumb-card.php | 73 +-
.../listing-form/fields/image_upload.php | 11 +-
18 files changed, 1810 insertions(+), 106 deletions(-)
create mode 100644 assets/src/js/global/components/image-focal-point.js
diff --git a/assets/src/js/global/add-listing.js b/assets/src/js/global/add-listing.js
index b0b09330bc..e2b1a88070 100644
--- a/assets/src/js/global/add-listing.js
+++ b/assets/src/js/global/add-listing.js
@@ -15,6 +15,11 @@ import {
watchFieldChanges as watchFieldChangesBase,
} from './components/conditional-logic';
import debounce from './components/debounce';
+import {
+ appendImageFocalPointData,
+ initImageFocalPointDeepLink,
+ initImageFocalPointPicker,
+} from './components/image-focal-point';
/* eslint-disable */
const $ = jQuery;
@@ -561,11 +566,17 @@ $(function () {
});
EzUploader.init();
+ initImageFocalPointPicker(
+ EzUploader,
+ localized_data.i18n_text
+ );
// mediaUploaders[i].media_uploader.init();
}
}
}
+ initImageFocalPointDeepLink();
+
let FORM_ON_PROCESSING = false;
let has_media = true;
let quickLoginModalSuccessCallback = null;
@@ -816,6 +827,14 @@ $(function () {
});
}
+ mediaUploaders.forEach(function (uploaderEntry) {
+ appendImageFocalPointData(
+ form_data,
+ uploaderEntry,
+ uploadedImages
+ );
+ });
+
// categories
var categories = $form.find('#at_biz_dir-categories').val();
if (Array.isArray(categories) && categories.length) {
diff --git a/assets/src/js/global/components/image-focal-point.js b/assets/src/js/global/components/image-focal-point.js
new file mode 100644
index 0000000000..dc6a09d796
--- /dev/null
+++ b/assets/src/js/global/components/image-focal-point.js
@@ -0,0 +1,838 @@
+const DEFAULT_FOCAL_POINT = { x: 50, y: 50 };
+
+let modalIndex = 0;
+
+function clamp( value ) {
+ return Math.min( 100, Math.max( 0, Number( value ) || 0 ) );
+}
+
+function normalizeFocalPoint( focalPoint = DEFAULT_FOCAL_POINT ) {
+ return {
+ x: Math.round( clamp( focalPoint.x ) * 100 ) / 100,
+ y: Math.round( clamp( focalPoint.y ) * 100 ) / 100,
+ };
+}
+
+function isDefaultFocalPoint( focalPoint = DEFAULT_FOCAL_POINT ) {
+ const normalizedPoint = normalizeFocalPoint( focalPoint );
+
+ return (
+ normalizedPoint.x === DEFAULT_FOCAL_POINT.x &&
+ normalizedPoint.y === DEFAULT_FOCAL_POINT.y
+ );
+}
+
+function isEnabled( value ) {
+ return ! [ false, 0, '0', 'false' ].includes( value );
+}
+
+function getUploaderConfig( mediaUploader ) {
+ const container = mediaUploader?.container?.[ 0 ];
+
+ if ( ! container ) {
+ return {};
+ }
+
+ try {
+ return JSON.parse( container.getAttribute( 'data-uploader' ) || '{}' );
+ } catch ( error ) {
+ return {};
+ }
+}
+
+function getMetaById( mediaUploader, id ) {
+ return mediaUploader.filesMeta.find(
+ ( fileMeta ) => String( fileMeta.id ) === String( id )
+ );
+}
+
+function createButton( className, label ) {
+ const button = document.createElement( 'button' );
+ button.type = 'button';
+ button.className = className;
+ button.textContent = label;
+
+ return button;
+}
+
+function getText( dictionary, key, fallback ) {
+ return dictionary?.[ key ] || fallback;
+}
+
+function normalizePreviewDisplay( display = {} ) {
+ const allowedModes = [ 'cover', 'contain', 'full' ];
+ const allowedSizeMethods = [ 'px', 'ratio' ];
+ const allowedBackgrounds = [ 'blur', 'color' ];
+
+ return {
+ mode: allowedModes.includes( display.mode ) ? display.mode : 'cover',
+ sizeBy: allowedSizeMethods.includes( display.size_by )
+ ? display.size_by
+ : 'px',
+ width: Math.max( 1, Number( display.width ) || 360 ),
+ height: Math.max( 1, Number( display.height ) || 300 ),
+ backgroundType: allowedBackgrounds.includes( display.background_type )
+ ? display.background_type
+ : 'blur',
+ backgroundColor: display.background_color || '#fff',
+ };
+}
+
+function getModeHelp( dictionary, mode ) {
+ const help = {
+ contain: getText(
+ dictionary,
+ 'image_focus_help_contain',
+ 'The complete image remains visible. Focus controls its alignment and is also used on cropped thumbnail surfaces.'
+ ),
+ cover: getText(
+ dictionary,
+ 'image_focus_help_cover',
+ 'Focus controls which part of the Preview image remains visible when the card crops it.'
+ ),
+ full: getText(
+ dictionary,
+ 'image_focus_help_full',
+ 'The Original archive image is uncropped. Focus is saved for maps, dashboard thumbnails, and other cropped surfaces.'
+ ),
+ };
+
+ return help[ mode ] || help.cover;
+}
+
+function createFocalPointToolbar( dictionary, display ) {
+ const toolbar = document.createElement( 'div' );
+ toolbar.className = 'directorist-image-focus-toolbar';
+ toolbar.hidden = true;
+ toolbar.setAttribute(
+ 'aria-label',
+ getText( dictionary, 'image_focus_status_label', 'Image focus' )
+ );
+
+ const summary = document.createElement( 'div' );
+ summary.className = 'directorist-image-focus-toolbar__summary';
+
+ const heading = document.createElement( 'div' );
+ heading.className = 'directorist-image-focus-toolbar__heading';
+
+ const label = document.createElement( 'strong' );
+ label.className = 'directorist-image-focus-toolbar__label';
+ label.textContent = getText(
+ dictionary,
+ 'image_focus_status_label',
+ 'Image focus'
+ );
+
+ const status = document.createElement( 'span' );
+ status.className = 'directorist-image-focus-toolbar__status';
+ status.setAttribute( 'aria-live', 'polite' );
+
+ const description = document.createElement( 'p' );
+ description.className = 'directorist-image-focus-toolbar__description';
+ description.textContent = getModeHelp( dictionary, display.mode );
+
+ heading.append( label, status );
+ summary.append( heading, description );
+
+ const actions = document.createElement( 'div' );
+ actions.className = 'directorist-image-focus-toolbar__actions';
+
+ const editButton = createButton(
+ 'directorist-btn directorist-btn-sm directorist-btn-primary directorist-image-focus-toolbar__edit',
+ getText( dictionary, 'set_image_focus', 'Set image focus' )
+ );
+ const resetButton = createButton(
+ 'directorist-btn directorist-btn-sm directorist-btn-outline-primary directorist-image-focus-toolbar__reset',
+ getText( dictionary, 'image_focus_reset', 'Reset to center' )
+ );
+
+ actions.append( editButton, resetButton );
+ toolbar.append( summary, actions );
+
+ return {
+ editButton,
+ resetButton,
+ status,
+ toolbar,
+ };
+}
+
+function createFocalPointModal( dictionary, display ) {
+ modalIndex += 1;
+
+ const titleId = `directorist-image-focus-title-${ modalIndex }`;
+ const modal = document.createElement( 'div' );
+ modal.className = 'directorist-image-focus-modal';
+ modal.hidden = true;
+ modal.setAttribute( 'role', 'dialog' );
+ modal.setAttribute( 'aria-modal', 'true' );
+ modal.setAttribute( 'aria-labelledby', titleId );
+
+ const backdrop = document.createElement( 'div' );
+ backdrop.className = 'directorist-image-focus-modal__backdrop';
+
+ const dialog = document.createElement( 'div' );
+ dialog.className = 'directorist-image-focus-modal__dialog';
+
+ const header = document.createElement( 'div' );
+ header.className = 'directorist-image-focus-modal__header';
+
+ const headingWrap = document.createElement( 'div' );
+ const title = document.createElement( 'h2' );
+ title.id = titleId;
+ title.className = 'directorist-image-focus-modal__title';
+ title.textContent = getText(
+ dictionary,
+ 'image_focus_title',
+ 'Image focal point'
+ );
+
+ const description = document.createElement( 'p' );
+ description.className = 'directorist-image-focus-modal__description';
+ description.textContent = getModeHelp( dictionary, display.mode );
+
+ headingWrap.append( title, description );
+
+ const closeButton = createButton(
+ 'directorist-image-focus-modal__close',
+ '×'
+ );
+ closeButton.setAttribute(
+ 'aria-label',
+ getText( dictionary, 'image_focus_close', 'Close focal point editor' )
+ );
+
+ header.append( headingWrap, closeButton );
+
+ const body = document.createElement( 'div' );
+ body.className = 'directorist-image-focus-modal__body';
+
+ const editorColumn = document.createElement( 'div' );
+ editorColumn.className = 'directorist-image-focus-modal__editor';
+
+ const stage = createButton( 'directorist-image-focus-stage', '' );
+ stage.setAttribute(
+ 'aria-label',
+ getText(
+ dictionary,
+ 'image_focus_move',
+ 'Move the focal point. Use the arrow keys for precise adjustment.'
+ )
+ );
+
+ const stageImage = document.createElement( 'img' );
+ stageImage.className = 'directorist-image-focus-stage__image';
+ stageImage.alt = '';
+
+ const marker = document.createElement( 'span' );
+ marker.className = 'directorist-image-focus-stage__marker';
+ marker.setAttribute( 'aria-hidden', 'true' );
+ stage.append( stageImage, marker );
+
+ const controls = document.createElement( 'div' );
+ controls.className = 'directorist-image-focus-controls';
+
+ const xLabel = document.createElement( 'label' );
+ xLabel.className = 'directorist-image-focus-controls__label';
+ const xLabelText = document.createElement( 'span' );
+ xLabelText.textContent = getText(
+ dictionary,
+ 'image_focus_horizontal',
+ 'Horizontal position'
+ );
+ const xValue = document.createElement( 'output' );
+ const xRange = document.createElement( 'input' );
+ xRange.type = 'range';
+ xRange.min = '0';
+ xRange.max = '100';
+ xRange.step = '1';
+ xRange.className = 'directorist-image-focus-controls__range';
+ xLabel.append( xLabelText, xValue, xRange );
+
+ const yLabel = document.createElement( 'label' );
+ yLabel.className = 'directorist-image-focus-controls__label';
+ const yLabelText = document.createElement( 'span' );
+ yLabelText.textContent = getText(
+ dictionary,
+ 'image_focus_vertical',
+ 'Vertical position'
+ );
+ const yValue = document.createElement( 'output' );
+ const yRange = document.createElement( 'input' );
+ yRange.type = 'range';
+ yRange.min = '0';
+ yRange.max = '100';
+ yRange.step = '1';
+ yRange.className = 'directorist-image-focus-controls__range';
+ yLabel.append( yLabelText, yValue, yRange );
+
+ controls.append( xLabel, yLabel );
+ editorColumn.append( stage, controls );
+
+ const previewColumn = document.createElement( 'div' );
+ previewColumn.className = 'directorist-image-focus-modal__preview-column';
+
+ const previewTitle = document.createElement( 'h3' );
+ previewTitle.className = 'directorist-image-focus-modal__preview-title';
+ previewTitle.textContent = getText(
+ dictionary,
+ 'image_focus_preview',
+ 'Listing card preview'
+ );
+
+ const preview = document.createElement( 'div' );
+ preview.className = `directorist-image-focus-preview directorist-image-focus-preview--${ display.mode }`;
+
+ if ( display.mode !== 'full' ) {
+ preview.style.aspectRatio = `${ display.width } / ${ display.height }`;
+ }
+
+ if ( display.mode === 'contain' && display.backgroundType === 'color' ) {
+ preview.style.backgroundColor = display.backgroundColor;
+ }
+
+ const previewBackground = document.createElement( 'img' );
+ previewBackground.className = 'directorist-image-focus-preview__background';
+ previewBackground.alt = '';
+
+ const previewImage = document.createElement( 'img' );
+ previewImage.className = 'directorist-image-focus-preview__image';
+ previewImage.alt = '';
+
+ if ( display.mode === 'contain' && display.backgroundType === 'blur' ) {
+ preview.appendChild( previewBackground );
+ }
+
+ preview.appendChild( previewImage );
+
+ const presets = document.createElement( 'div' );
+ presets.className = 'directorist-image-focus-presets';
+
+ [
+ {
+ label: getText( dictionary, 'image_focus_top', 'Top' ),
+ x: 50,
+ y: 0,
+ },
+ {
+ label: getText( dictionary, 'image_focus_center', 'Center' ),
+ x: 50,
+ y: 50,
+ },
+ {
+ label: getText( dictionary, 'image_focus_bottom', 'Bottom' ),
+ x: 50,
+ y: 100,
+ },
+ ].forEach( ( preset ) => {
+ const button = createButton(
+ 'directorist-image-focus-presets__button',
+ preset.label
+ );
+ button.dataset.x = preset.x;
+ button.dataset.y = preset.y;
+ presets.appendChild( button );
+ } );
+
+ const resetButton = createButton(
+ 'directorist-image-focus-modal__reset',
+ getText( dictionary, 'image_focus_reset', 'Reset to center' )
+ );
+
+ previewColumn.append( previewTitle, preview, presets, resetButton );
+ body.append( editorColumn, previewColumn );
+
+ const footer = document.createElement( 'div' );
+ footer.className = 'directorist-image-focus-modal__footer';
+ const cancelButton = createButton(
+ 'directorist-btn directorist-btn-sm directorist-btn-outline-primary',
+ getText( dictionary, 'image_focus_cancel', 'Cancel' )
+ );
+ const doneButton = createButton(
+ 'directorist-btn directorist-btn-sm directorist-btn-primary',
+ getText( dictionary, 'image_focus_done', 'Done' )
+ );
+ footer.append( cancelButton, doneButton );
+
+ dialog.append( header, body, footer );
+ modal.append( backdrop, dialog );
+ document.body.appendChild( modal );
+
+ return {
+ backdrop,
+ cancelButton,
+ closeButton,
+ doneButton,
+ marker,
+ modal,
+ previewBackground,
+ previewImage,
+ presets,
+ resetButton,
+ stage,
+ stageImage,
+ xRange,
+ xValue,
+ yRange,
+ yValue,
+ };
+}
+
+/**
+ * Add the focal-point editor to an initialized EZ Media Uploader instance.
+ *
+ * @param {Object} mediaUploader EZ Media Uploader instance.
+ * @param {Object} dictionary Localized UI strings.
+ * @return {void}
+ */
+export function initImageFocalPointPicker( mediaUploader, dictionary = {} ) {
+ const config = getUploaderConfig( mediaUploader );
+
+ if (
+ ! Object.prototype.hasOwnProperty.call(
+ config,
+ 'enable_image_focus'
+ ) ||
+ ! isEnabled( config.enable_image_focus ) ||
+ ! mediaUploader?.container?.[ 0 ]
+ ) {
+ return;
+ }
+
+ const savedFocalPoints = config.image_focal_points || {};
+ const display = normalizePreviewDisplay( config.focus_preview_display );
+
+ mediaUploader.filesMeta.forEach( ( fileMeta ) => {
+ if (
+ fileMeta.attachmentID &&
+ savedFocalPoints[ fileMeta.attachmentID ]
+ ) {
+ const savedFocalPoint = normalizeFocalPoint(
+ savedFocalPoints[ fileMeta.attachmentID ]
+ );
+
+ if ( ! isDefaultFocalPoint( savedFocalPoint ) ) {
+ fileMeta.directoristFocalPoint = savedFocalPoint;
+ }
+ }
+ } );
+
+ mediaUploader.directoristImageFocusEnabled = true;
+
+ const ui = createFocalPointModal( dictionary, display );
+ const toolbarUi = createFocalPointToolbar( dictionary, display );
+ let activeMeta = null;
+ let activeTrigger = null;
+ let previewMeta = null;
+ let workingPoint = { ...DEFAULT_FOCAL_POINT };
+ let isDragging = false;
+
+ mediaUploader.container?.[ 0 ]?.insertAdjacentElement(
+ 'afterend',
+ toolbarUi.toolbar
+ );
+
+ function getFocalPoint( fileMeta ) {
+ return normalizeFocalPoint(
+ fileMeta?.directoristFocalPoint || DEFAULT_FOCAL_POINT
+ );
+ }
+
+ function setFocalPoint( fileMeta, focalPoint ) {
+ if ( ! fileMeta ) {
+ return;
+ }
+
+ const normalizedPoint = normalizeFocalPoint( focalPoint );
+
+ if ( isDefaultFocalPoint( normalizedPoint ) ) {
+ delete fileMeta.directoristFocalPoint;
+ return;
+ }
+
+ fileMeta.directoristFocalPoint = normalizedPoint;
+ }
+
+ function renderWorkingPoint() {
+ workingPoint = normalizeFocalPoint( workingPoint );
+ ui.marker.style.left = `${ workingPoint.x }%`;
+ ui.marker.style.top = `${ workingPoint.y }%`;
+ ui.previewImage.style.objectPosition = `${ workingPoint.x }% ${ workingPoint.y }%`;
+ ui.previewBackground.style.objectPosition = `${ workingPoint.x }% ${ workingPoint.y }%`;
+ ui.xRange.value = workingPoint.x;
+ ui.yRange.value = workingPoint.y;
+ ui.xValue.textContent = `${ workingPoint.x }%`;
+ ui.yValue.textContent = `${ workingPoint.y }%`;
+ }
+
+ function updatePointFromPointer( event ) {
+ const bounds = ui.stage.getBoundingClientRect();
+
+ if ( ! bounds.width || ! bounds.height ) {
+ return;
+ }
+
+ workingPoint = {
+ x: ( ( event.clientX - bounds.left ) / bounds.width ) * 100,
+ y: ( ( event.clientY - bounds.top ) / bounds.height ) * 100,
+ };
+ renderWorkingPoint();
+ }
+
+ function updateThumbnailPreview( fileMeta, focalPoint ) {
+ const featuredItem = mediaUploader.container[ 0 ]
+ .querySelector( '.ezmu__featured_tag' )
+ ?.closest( '.ezmu__thumbnail-list-item' );
+
+ if ( ! featuredItem || ! fileMeta ) {
+ return;
+ }
+
+ const itemMeta = getMetaById( mediaUploader, featuredItem.dataset.id );
+
+ if ( itemMeta !== fileMeta ) {
+ return;
+ }
+
+ const thumbnail = featuredItem.querySelector( '.ezmu__thumbnail-img' );
+
+ if ( thumbnail ) {
+ const normalizedPoint = normalizeFocalPoint( focalPoint );
+ thumbnail.style.objectPosition = `${ normalizedPoint.x }% ${ normalizedPoint.y }%`;
+ }
+ }
+
+ function updateToolbar( fileMeta ) {
+ previewMeta = fileMeta || null;
+ toolbarUi.toolbar.hidden = ! previewMeta;
+
+ if ( ! previewMeta ) {
+ return;
+ }
+
+ const focalPoint = getFocalPoint( previewMeta );
+ const hasCustomFocus = ! isDefaultFocalPoint( focalPoint );
+
+ toolbarUi.status.textContent = getText(
+ dictionary,
+ hasCustomFocus
+ ? 'image_focus_custom_status'
+ : 'image_focus_default_status',
+ hasCustomFocus ? 'Custom' : 'Center (default)'
+ );
+ toolbarUi.status.classList.toggle(
+ 'directorist-image-focus-toolbar__status--custom',
+ hasCustomFocus
+ );
+ toolbarUi.editButton.textContent = getText(
+ dictionary,
+ hasCustomFocus ? 'edit_image_focus' : 'set_image_focus',
+ hasCustomFocus ? 'Edit focus' : 'Set image focus'
+ );
+ toolbarUi.resetButton.disabled = ! hasCustomFocus;
+ updateThumbnailPreview( previewMeta, focalPoint );
+ }
+
+ function closeModal( commit = false ) {
+ if ( commit && activeMeta ) {
+ setFocalPoint( activeMeta, workingPoint );
+ updateToolbar( activeMeta );
+ }
+
+ ui.modal.hidden = true;
+ document.body.classList.remove( 'directorist-image-focus-modal-open' );
+
+ if ( activeTrigger ) {
+ activeTrigger.focus();
+ }
+
+ activeMeta = null;
+ activeTrigger = null;
+ }
+
+ function openModal( fileMeta, trigger ) {
+ const thumbnailItem = Array.from(
+ mediaUploader.container[ 0 ].querySelectorAll(
+ '.ezmu__thumbnail-list-item'
+ )
+ ).find(
+ ( item ) =>
+ getMetaById( mediaUploader, item.dataset.id ) === fileMeta
+ );
+ const renderedThumbnail = thumbnailItem?.querySelector(
+ '.ezmu__thumbnail-img'
+ );
+ const imageSource =
+ fileMeta.blob ||
+ fileMeta.url ||
+ renderedThumbnail?.currentSrc ||
+ renderedThumbnail?.src;
+
+ if ( ! imageSource ) {
+ return;
+ }
+
+ activeMeta = fileMeta;
+ activeTrigger = trigger;
+ workingPoint = getFocalPoint( fileMeta );
+ ui.stageImage.onload = () => {
+ if ( ui.stageImage.naturalWidth && ui.stageImage.naturalHeight ) {
+ ui.stage.style.aspectRatio = `${ ui.stageImage.naturalWidth } / ${ ui.stageImage.naturalHeight }`;
+ }
+ };
+ ui.stageImage.src = imageSource;
+ ui.previewImage.src = imageSource;
+ ui.previewBackground.src = imageSource;
+
+ if ( ui.stageImage.complete ) {
+ ui.stageImage.onload();
+ }
+
+ renderWorkingPoint();
+ ui.modal.hidden = false;
+ document.body.classList.add( 'directorist-image-focus-modal-open' );
+ ui.closeButton.focus();
+ }
+
+ function decorateFeaturedImage() {
+ const featuredTag = mediaUploader.container[ 0 ].querySelector(
+ '.ezmu__featured_tag'
+ );
+
+ if ( ! featuredTag ) {
+ updateToolbar( null );
+ return;
+ }
+
+ const item = featuredTag.closest( '.ezmu__thumbnail-list-item' );
+ const fileMeta = item
+ ? getMetaById( mediaUploader, item.dataset.id )
+ : null;
+
+ if ( ! fileMeta ) {
+ updateToolbar( null );
+ return;
+ }
+
+ updateToolbar( fileMeta );
+ }
+
+ toolbarUi.editButton.addEventListener( 'click', () => {
+ if ( previewMeta ) {
+ openModal( previewMeta, toolbarUi.editButton );
+ }
+ } );
+
+ toolbarUi.resetButton.addEventListener( 'click', () => {
+ if ( ! previewMeta ) {
+ return;
+ }
+
+ setFocalPoint( previewMeta, DEFAULT_FOCAL_POINT );
+ updateToolbar( previewMeta );
+ } );
+
+ ui.stage.addEventListener( 'pointerdown', ( event ) => {
+ isDragging = true;
+ ui.stage.setPointerCapture?.( event.pointerId );
+ updatePointFromPointer( event );
+ } );
+
+ ui.stage.addEventListener( 'pointermove', ( event ) => {
+ if ( isDragging ) {
+ updatePointFromPointer( event );
+ }
+ } );
+
+ [ 'pointerup', 'pointercancel' ].forEach( ( eventName ) => {
+ ui.stage.addEventListener( eventName, () => {
+ isDragging = false;
+ } );
+ } );
+
+ ui.stage.addEventListener( 'keydown', ( event ) => {
+ const step = event.shiftKey ? 5 : 1;
+ const keyChanges = {
+ ArrowDown: { x: 0, y: step },
+ ArrowLeft: { x: -step, y: 0 },
+ ArrowRight: { x: step, y: 0 },
+ ArrowUp: { x: 0, y: -step },
+ };
+
+ if ( ! keyChanges[ event.key ] ) {
+ return;
+ }
+
+ event.preventDefault();
+ workingPoint.x += keyChanges[ event.key ].x;
+ workingPoint.y += keyChanges[ event.key ].y;
+ renderWorkingPoint();
+ } );
+
+ ui.xRange.addEventListener( 'input', () => {
+ workingPoint.x = ui.xRange.value;
+ renderWorkingPoint();
+ } );
+
+ ui.yRange.addEventListener( 'input', () => {
+ workingPoint.y = ui.yRange.value;
+ renderWorkingPoint();
+ } );
+
+ ui.presets.addEventListener( 'click', ( event ) => {
+ const button = event.target.closest( 'button[data-x][data-y]' );
+
+ if ( ! button ) {
+ return;
+ }
+
+ workingPoint = { x: button.dataset.x, y: button.dataset.y };
+ renderWorkingPoint();
+ } );
+
+ ui.resetButton.addEventListener( 'click', () => {
+ workingPoint = { ...DEFAULT_FOCAL_POINT };
+ renderWorkingPoint();
+ } );
+
+ ui.doneButton.addEventListener( 'click', () => closeModal( true ) );
+ ui.cancelButton.addEventListener( 'click', () => closeModal() );
+ ui.closeButton.addEventListener( 'click', () => closeModal() );
+ ui.backdrop.addEventListener( 'click', () => closeModal() );
+
+ ui.modal.addEventListener( 'keydown', ( event ) => {
+ if ( event.key === 'Escape' ) {
+ event.preventDefault();
+ closeModal();
+ return;
+ }
+
+ if ( event.key !== 'Tab' ) {
+ return;
+ }
+
+ const focusable = Array.from(
+ ui.modal.querySelectorAll(
+ 'button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])'
+ )
+ ).filter( ( element ) => ! element.hidden );
+
+ if ( ! focusable.length ) {
+ return;
+ }
+
+ const first = focusable[ 0 ];
+ const last = focusable[ focusable.length - 1 ];
+
+ if (
+ event.shiftKey &&
+ ui.modal.ownerDocument.activeElement === first
+ ) {
+ event.preventDefault();
+ last.focus();
+ } else if (
+ ! event.shiftKey &&
+ ui.modal.ownerDocument.activeElement === last
+ ) {
+ event.preventDefault();
+ first.focus();
+ }
+ } );
+
+ const observer = new window.MutationObserver( decorateFeaturedImage );
+ observer.observe( mediaUploader.thumbnailArea, {
+ childList: true,
+ subtree: true,
+ } );
+
+ decorateFeaturedImage();
+}
+
+/**
+ * Focus the image controls when a listing owner follows the dashboard shortcut.
+ *
+ * @return {void}
+ */
+export function initImageFocalPointDeepLink() {
+ const params = new window.URLSearchParams( window.location.search );
+
+ if ( params.get( 'directorist_edit_focus' ) !== '1' ) {
+ return;
+ }
+
+ const imageField = document.querySelector(
+ '#directorist-add-listing-form .directorist-form-image-upload-field'
+ );
+
+ if ( ! imageField ) {
+ return;
+ }
+
+ window.setTimeout( () => {
+ imageField.scrollIntoView( { behavior: 'smooth', block: 'center' } );
+ imageField.classList.add( 'directorist-image-focus-target' );
+
+ const editButton = imageField.querySelector(
+ '.directorist-image-focus-toolbar__edit:not([hidden])'
+ );
+
+ editButton?.focus( { preventScroll: true } );
+
+ window.setTimeout( () => {
+ imageField.classList.remove( 'directorist-image-focus-target' );
+ }, 1800 );
+ }, 350 );
+}
+
+/**
+ * Append the selected preview image and its focal point to listing form data.
+ *
+ * @param {FormData} formData Listing form data.
+ * @param {Object} uploaderEntry Directorist uploader entry.
+ * @param {Array} uploadedImages Newly uploaded temporary images.
+ * @return {void}
+ */
+export function appendImageFocalPointData(
+ formData,
+ uploaderEntry,
+ uploadedImages = []
+) {
+ const mediaUploader = uploaderEntry?.media_uploader;
+
+ if (
+ uploaderEntry?.uploaders_data?.meta_name !== 'listing_img' ||
+ ! mediaUploader?.directoristImageFocusEnabled ||
+ ! mediaUploader.filesMeta?.length
+ ) {
+ return;
+ }
+
+ const previewMeta = mediaUploader.filesMeta[ 0 ];
+ const focalPoint = normalizeFocalPoint(
+ previewMeta.directoristFocalPoint || DEFAULT_FOCAL_POINT
+ );
+ let previewSource = '';
+
+ if ( previewMeta.attachmentID ) {
+ previewSource = `attachment:${ previewMeta.attachmentID }`;
+ } else if ( previewMeta.file ) {
+ const uploadedPreview = uploadedImages.find(
+ ( image ) => image.file === previewMeta.file
+ );
+
+ if ( uploadedPreview?.uploadedFile ) {
+ previewSource = `temp:${ uploadedPreview.uploadedFile }`;
+ }
+ }
+
+ formData.append( 'listing_image_focal_x', focalPoint.x );
+ formData.append( 'listing_image_focal_y', focalPoint.y );
+ formData.append(
+ 'listing_image_focal_is_custom',
+ isDefaultFocalPoint( focalPoint ) ? '0' : '1'
+ );
+
+ if ( previewSource ) {
+ formData.append( 'listing_image_preview_source', previewSource );
+ }
+}
diff --git a/assets/src/scss/component/_add-listing.scss b/assets/src/scss/component/_add-listing.scss
index fb970ee486..b5835baee2 100644
--- a/assets/src/scss/component/_add-listing.scss
+++ b/assets/src/scss/component/_add-listing.scss
@@ -940,6 +940,439 @@
}
}
+.directorist-image-focus-toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 18px;
+ margin-top: 16px;
+ padding: 16px;
+ border: 1px solid var(--directorist-color-border-gray);
+ border-radius: 10px;
+ background: var(--directorist-color-white);
+ transition:
+ border-color 0.2s ease,
+ box-shadow 0.2s ease;
+
+ &[hidden] {
+ display: none;
+ }
+
+ @include e(summary) {
+ min-width: 0;
+ }
+
+ @include e(heading) {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ }
+
+ @include e(label) {
+ color: var(--directorist-color-dark);
+ font-size: 15px;
+ line-height: 1.4;
+ }
+
+ @include e(status) {
+ display: inline-flex;
+ align-items: center;
+ min-height: 24px;
+ padding: 3px 9px;
+ border-radius: 999px;
+ background: var(--directorist-color-bg-gray);
+ color: var(--directorist-color-body);
+ font-size: 12px;
+ font-weight: 600;
+ line-height: 1.2;
+
+ @include m(custom) {
+ background: rgba(var(--directorist-color-primary-rgb), 0.1);
+ color: var(--directorist-color-primary);
+ }
+ }
+
+ @include e(description) {
+ margin: 5px 0 0;
+ color: var(--directorist-color-body);
+ font-size: 13px;
+ line-height: 1.5;
+ }
+
+ @include e(actions) {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-shrink: 0;
+
+ .directorist-btn {
+ white-space: nowrap;
+
+ &:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+ }
+ }
+}
+
+.directorist-form-image-upload-field.directorist-image-focus-target {
+ .directorist-image-focus-toolbar {
+ border-color: var(--directorist-color-primary);
+ box-shadow: 0 0 0 3px rgba(var(--directorist-color-primary-rgb), 0.12);
+ }
+}
+
+body.directorist-image-focus-modal-open {
+ overflow: hidden;
+}
+
+.directorist-image-focus-modal {
+ position: fixed;
+ inset: 0;
+ z-index: 999999;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 24px;
+
+ &[hidden] {
+ display: none;
+ }
+
+ @include e(backdrop) {
+ position: absolute;
+ inset: 0;
+ background: rgba(var(--directorist-color-dark-rgb), 0.62);
+ backdrop-filter: blur(2px);
+ }
+
+ @include e(dialog) {
+ position: relative;
+ z-index: 1;
+ width: min(920px, 100%);
+ max-height: calc(100vh - 48px);
+ overflow: auto;
+ border-radius: 14px;
+ background: var(--directorist-color-white);
+ box-shadow: 0 24px 70px rgba(var(--directorist-color-dark-rgb), 0.28);
+ color: var(--directorist-color-dark);
+ }
+
+ @include e(header) {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 24px;
+ padding: 24px 28px 18px;
+ border-bottom: 1px solid var(--directorist-color-border-gray);
+ }
+
+ @include e(title) {
+ margin: 0;
+ color: var(--directorist-color-dark);
+ font-size: 22px;
+ font-weight: 600;
+ line-height: 1.3;
+ }
+
+ @include e(description) {
+ max-width: 650px;
+ margin: 6px 0 0;
+ color: var(--directorist-color-body);
+ font-size: 14px;
+ line-height: 1.5;
+ }
+
+ @include e(close) {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex: 0 0 36px;
+ width: 36px;
+ height: 36px;
+ padding: 0;
+ border: 1px solid var(--directorist-color-border-gray);
+ border-radius: 50%;
+ background: transparent;
+ color: var(--directorist-color-dark);
+ font-size: 25px;
+ font-weight: 300;
+ line-height: 1;
+ cursor: pointer;
+
+ &:hover,
+ &:focus-visible {
+ border-color: var(--directorist-color-primary);
+ color: var(--directorist-color-primary);
+ outline: none;
+ }
+ }
+
+ @include e(body) {
+ display: grid;
+ grid-template-columns: minmax(0, 1.45fr) minmax(230px, 0.55fr);
+ gap: 28px;
+ padding: 28px;
+ }
+
+ @include e(preview-column) {
+ min-width: 0;
+ }
+
+ @include e(preview-title) {
+ margin: 0 0 12px;
+ color: var(--directorist-color-dark);
+ font-size: 15px;
+ font-weight: 600;
+ }
+
+ @include e(reset) {
+ width: 100%;
+ padding: 8px 10px;
+ border: 0;
+ background: transparent;
+ color: var(--directorist-color-primary);
+ font-size: 13px;
+ font-weight: 500;
+ cursor: pointer;
+
+ &:hover,
+ &:focus-visible {
+ text-decoration: underline;
+ outline: none;
+ }
+ }
+
+ @include e(footer) {
+ display: flex;
+ justify-content: flex-end;
+ gap: 10px;
+ padding: 18px 28px 24px;
+ border-top: 1px solid var(--directorist-color-border-gray);
+ }
+}
+
+.directorist-image-focus-stage {
+ position: relative;
+ display: block;
+ width: 100%;
+ max-height: 55vh;
+ padding: 0;
+ overflow: hidden;
+ border: 0;
+ border-radius: 10px;
+ background-color: var(--directorist-color-bg-gray);
+ line-height: 0;
+ cursor: crosshair;
+ touch-action: none;
+
+ &:focus-visible {
+ outline: 3px solid rgba(var(--directorist-color-primary-rgb), 0.28);
+ outline-offset: 3px;
+ }
+
+ @include e(image) {
+ display: block;
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+ pointer-events: none;
+ user-select: none;
+ }
+
+ @include e(marker) {
+ position: absolute;
+ width: 30px;
+ height: 30px;
+ border: 3px solid var(--directorist-color-white);
+ border-radius: 50%;
+ background: var(--directorist-color-primary);
+ box-shadow: 0 2px 9px rgba(var(--directorist-color-dark-rgb), 0.4);
+ pointer-events: none;
+ transform: translate(-50%, -50%);
+
+ &::before,
+ &::after {
+ position: absolute;
+ content: "";
+ background: var(--directorist-color-white);
+ }
+
+ &::before {
+ top: 50%;
+ left: 5px;
+ right: 5px;
+ height: 2px;
+ transform: translateY(-50%);
+ }
+
+ &::after {
+ top: 5px;
+ bottom: 5px;
+ left: 50%;
+ width: 2px;
+ transform: translateX(-50%);
+ }
+ }
+}
+
+.directorist-image-focus-controls {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 18px;
+ margin-top: 18px;
+
+ @include e(label) {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ align-items: center;
+ gap: 6px 10px;
+ margin: 0;
+ color: var(--directorist-color-dark);
+ font-size: 13px;
+ font-weight: 500;
+
+ output {
+ color: var(--directorist-color-body);
+ font-variant-numeric: tabular-nums;
+ }
+ }
+
+ @include e(range) {
+ grid-column: 1 / -1;
+ width: 100%;
+ accent-color: var(--directorist-color-primary);
+ }
+}
+
+.directorist-image-focus-preview {
+ position: relative;
+ width: 100%;
+ overflow: hidden;
+ border-radius: 10px;
+ background: var(--directorist-color-bg-gray);
+ box-shadow: inset 0 0 0 1px var(--directorist-color-border-gray);
+
+ @include m(contain) {
+ .directorist-image-focus-preview__image {
+ object-fit: contain;
+ }
+ }
+
+ @include m(full) {
+ background: transparent;
+
+ .directorist-image-focus-preview__image {
+ width: auto;
+ max-width: 100%;
+ height: auto;
+ max-height: 420px;
+ margin: 0 auto;
+ object-fit: contain;
+ }
+ }
+
+ @include e(background) {
+ position: absolute;
+ inset: -12px;
+ display: block;
+ width: calc(100% + 24px);
+ height: calc(100% + 24px);
+ object-fit: cover;
+ filter: blur(8px);
+ pointer-events: none;
+ user-select: none;
+ }
+
+ @include e(image) {
+ position: relative;
+ z-index: 1;
+ display: block;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ }
+}
+
+.directorist-image-focus-presets {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 6px;
+ margin-top: 12px;
+
+ @include e(button) {
+ padding: 7px 6px;
+ border: 1px solid var(--directorist-color-border-gray);
+ border-radius: 6px;
+ background: var(--directorist-color-white);
+ color: var(--directorist-color-dark);
+ font-size: 12px;
+ font-weight: 500;
+ cursor: pointer;
+
+ &:hover,
+ &:focus-visible {
+ border-color: var(--directorist-color-primary);
+ color: var(--directorist-color-primary);
+ outline: none;
+ }
+ }
+}
+
+@media only screen and (max-width: 767px) {
+
+ .directorist-image-focus-modal {
+ align-items: flex-start;
+ padding: 12px;
+
+ @include e(dialog) {
+ max-height: calc(100vh - 24px);
+ }
+
+ @include e(header) {
+ padding: 18px;
+ }
+
+ @include e(title) {
+ font-size: 19px;
+ }
+
+ @include e(body) {
+ grid-template-columns: 1fr;
+ gap: 22px;
+ padding: 20px 18px;
+ }
+
+ @include e(footer) {
+ padding: 16px 18px 20px;
+
+ .directorist-btn {
+ flex: 1;
+ }
+ }
+ }
+
+ .directorist-image-focus-controls {
+ grid-template-columns: 1fr;
+ }
+
+ .directorist-image-focus-toolbar {
+ align-items: stretch;
+ flex-direction: column;
+ gap: 14px;
+
+ @include e(actions) {
+ align-items: stretch;
+ flex-direction: column;
+
+ .directorist-btn {
+ width: 100%;
+ }
+ }
+ }
+}
+
//add listing image upload
.directorist-add-listing-wrapper {
max-width: 1000px !important;
diff --git a/assets/src/scss/component/listings/_listing-card.scss b/assets/src/scss/component/listings/_listing-card.scss
index b9813d2dad..898f624d27 100644
--- a/assets/src/scss/component/listings/_listing-card.scss
+++ b/assets/src/scss/component/listings/_listing-card.scss
@@ -551,6 +551,9 @@
width: 100%;
height: 100%;
object-fit: cover;
+ object-position:
+ var(--directorist-image-focus-x, 50%)
+ var(--directorist-image-focus-y, 50%);
margin: 0;
}
@@ -1215,6 +1218,12 @@
}
}
+.directorist-image-focal-point {
+ object-position:
+ var(--directorist-image-focus-x, 50%)
+ var(--directorist-image-focus-y, 50%);
+}
+
// Rating Meta
.directorist-rating-meta {
padding: 0;
diff --git a/includes/asset-loader/localized_data.php b/includes/asset-loader/localized_data.php
index 148c115715..e5af42ef7c 100644
--- a/includes/asset-loader/localized_data.php
+++ b/includes/asset-loader/localized_data.php
@@ -187,7 +187,29 @@ public static function get_add_listings_data() {
'max_location_creation' => esc_attr( $max_loc_creation ),
'max_location_msg' => sprintf( __( 'You can only use %s', 'directorist' ), $max_loc_creation ),
'submission_wait_msg' => esc_html__( 'Please wait, your submission is being processed.', 'directorist' ),
- 'image_uploading_msg' => esc_html__( 'Please wait, your selected images being uploaded.', 'directorist' )
+ 'image_uploading_msg' => esc_html__( 'Please wait, your selected images being uploaded.', 'directorist' ),
+ 'set_image_focus' => esc_html__( 'Set image focus', 'directorist' ),
+ 'edit_image_focus' => esc_html__( 'Edit focus', 'directorist' ),
+ 'image_focus_status_label' => esc_html__( 'Image focus', 'directorist' ),
+ 'image_focus_default_status' => esc_html__( 'Center (default)', 'directorist' ),
+ 'image_focus_custom_status' => esc_html__( 'Custom', 'directorist' ),
+ 'image_focus_help' => esc_html__( 'Controls which part of the Preview image stays visible in cropped listing cards.', 'directorist' ),
+ 'image_focus_help_cover' => esc_html__( 'Focus controls which part of the Preview image remains visible when the card crops it.', 'directorist' ),
+ 'image_focus_help_contain' => esc_html__( 'The complete image remains visible. Focus controls its alignment and is also used on cropped thumbnail surfaces.', 'directorist' ),
+ 'image_focus_help_full' => esc_html__( 'The Original archive image is uncropped. Focus is saved for maps, dashboard thumbnails, and other cropped surfaces.', 'directorist' ),
+ 'image_focus_title' => esc_html__( 'Image focal point', 'directorist' ),
+ 'image_focus_description' => esc_html__( 'Choose the part of the image that should remain visible when the image is cropped.', 'directorist' ),
+ 'image_focus_preview' => esc_html__( 'Listing card preview', 'directorist' ),
+ 'image_focus_horizontal' => esc_html__( 'Horizontal position', 'directorist' ),
+ 'image_focus_vertical' => esc_html__( 'Vertical position', 'directorist' ),
+ 'image_focus_top' => esc_html__( 'Top', 'directorist' ),
+ 'image_focus_center' => esc_html__( 'Center', 'directorist' ),
+ 'image_focus_bottom' => esc_html__( 'Bottom', 'directorist' ),
+ 'image_focus_reset' => esc_html__( 'Reset to center', 'directorist' ),
+ 'image_focus_cancel' => esc_html__( 'Cancel', 'directorist' ),
+ 'image_focus_done' => esc_html__( 'Done', 'directorist' ),
+ 'image_focus_close' => esc_html__( 'Close focal point editor', 'directorist' ),
+ 'image_focus_move' => esc_html__( 'Move the focal point. Use the arrow keys for precise adjustment.', 'directorist' ),
];
//get listing is if the screen in edit listing
diff --git a/includes/classes/class-add-listing.php b/includes/classes/class-add-listing.php
index 2c392c2f83..42244b7c8c 100644
--- a/includes/classes/class-add-listing.php
+++ b/includes/classes/class-add-listing.php
@@ -570,6 +570,7 @@ public static function upload_images( $listing_id, $posted_data ) {
delete_post_thumbnail( $listing_id );
delete_post_meta( $listing_id, '_listing_img' );
delete_post_meta( $listing_id, '_listing_prv_img' );
+ delete_post_meta( $listing_id, '_directorist_image_focal_points' );
return;
}
@@ -589,13 +590,15 @@ public static function upload_images( $listing_id, $posted_data ) {
$target_dir = trailingslashit( $upload_dir['path'] );
$uploaded_images = $old_images;
$background_processable_images = [];
+ $new_attachment_ids = [];
foreach ( $new_images as $image ) {
if ( empty( $image ) ) {
continue;
}
- $image = sanitize_file_name( $image );
+ $submitted_image = sanitize_file_name( $image );
+ $image = $submitted_image;
$filepath = $temp_dir . $image;
if ( is_dir( $filepath ) || ! file_exists( $filepath ) ) {
@@ -628,18 +631,35 @@ public static function upload_images( $listing_id, $posted_data ) {
}
$background_processable_images[ $attachment_id ] = $target_dir . $image;
+ $new_attachment_ids[ $submitted_image ] = $attachment_id;
$uploaded_images[] = $attachment_id;
}
if ( ! empty( $uploaded_images ) ) {
- update_post_meta( $listing_id, '_listing_prv_img', $uploaded_images[0] );
- set_post_thumbnail( $listing_id, $uploaded_images[0] );
+ $uploaded_images = array_values( array_unique( wp_parse_id_list( $uploaded_images ) ) );
+ $preview_source = isset( $posted_data['listing_image_preview_source'] ) ? $posted_data['listing_image_preview_source'] : '';
+ $preview_image = directorist_resolve_listing_preview_image_source( $preview_source, $uploaded_images, $new_attachment_ids );
- unset( $uploaded_images[0] );
+ if ( $preview_image ) {
+ $uploaded_images = array_values( array_diff( $uploaded_images, [ $preview_image ] ) );
+ array_unshift( $uploaded_images, $preview_image );
+ }
+
+ $preview_image = reset( $uploaded_images );
+
+ update_post_meta( $listing_id, '_listing_prv_img', $preview_image );
+ set_post_thumbnail( $listing_id, $preview_image );
- if ( count( $uploaded_images ) ) {
- update_post_meta( $listing_id, '_listing_img', $uploaded_images );
+ directorist_clean_listing_image_focal_points( $listing_id, $uploaded_images );
+ directorist_save_listing_image_focal_point( $listing_id, $preview_image, $posted_data );
+
+ $gallery_images = array_values( array_slice( $uploaded_images, 1 ) );
+
+ if ( count( $gallery_images ) ) {
+ update_post_meta( $listing_id, '_listing_img', $gallery_images );
+ } else {
+ delete_post_meta( $listing_id, '_listing_img' );
}
directorist_background_image_process( $background_processable_images );
diff --git a/includes/classes/class-submission-controller.php b/includes/classes/class-submission-controller.php
index e06ffff2ee..5eedcda0b2 100644
--- a/includes/classes/class-submission-controller.php
+++ b/includes/classes/class-submission-controller.php
@@ -897,6 +897,7 @@ public static function upload_images( $listing_id, $posted_data ) {
delete_post_thumbnail( $listing_id );
delete_post_meta( $listing_id, '_listing_img' );
delete_post_meta( $listing_id, '_listing_prv_img' );
+ delete_post_meta( $listing_id, '_directorist_image_focal_points' );
return;
}
@@ -916,18 +917,20 @@ public static function upload_images( $listing_id, $posted_data ) {
$target_dir = trailingslashit( $upload_dir['path'] );
$uploaded_images = $old_images;
$background_processable_images = array();
+ $new_attachment_ids = array();
foreach ( $new_images as $image ) {
if ( empty( $image ) ) {
continue;
}
- $image = self::get_safe_submitted_filename( $image );
- if ( ! $image ) {
+ $submitted_image = self::get_safe_submitted_filename( $image );
+ if ( ! $submitted_image ) {
continue;
}
- $filepath = $temp_dir . $image;
+ $image = $submitted_image;
+ $filepath = $temp_dir . $image;
if ( is_dir( $filepath ) || ! file_exists( $filepath ) ) {
continue;
@@ -967,18 +970,35 @@ public static function upload_images( $listing_id, $posted_data ) {
}
$background_processable_images[ $attachment_id ] = $target_dir . $image;
+ $new_attachment_ids[ $submitted_image ] = $attachment_id;
$uploaded_images[] = $attachment_id;
}
if ( ! empty( $uploaded_images ) ) {
- update_post_meta( $listing_id, '_listing_prv_img', $uploaded_images[0] );
- set_post_thumbnail( $listing_id, $uploaded_images[0] );
+ $uploaded_images = array_values( array_unique( wp_parse_id_list( $uploaded_images ) ) );
+ $preview_source = isset( $posted_data['listing_image_preview_source'] ) ? $posted_data['listing_image_preview_source'] : '';
+ $preview_image = directorist_resolve_listing_preview_image_source( $preview_source, $uploaded_images, $new_attachment_ids );
- unset( $uploaded_images[0] );
+ if ( $preview_image ) {
+ $uploaded_images = array_values( array_diff( $uploaded_images, [ $preview_image ] ) );
+ array_unshift( $uploaded_images, $preview_image );
+ }
+
+ $preview_image = reset( $uploaded_images );
+
+ update_post_meta( $listing_id, '_listing_prv_img', $preview_image );
+ set_post_thumbnail( $listing_id, $preview_image );
- if ( count( $uploaded_images ) ) {
- update_post_meta( $listing_id, '_listing_img', $uploaded_images );
+ directorist_clean_listing_image_focal_points( $listing_id, $uploaded_images );
+ directorist_save_listing_image_focal_point( $listing_id, $preview_image, $posted_data );
+
+ $gallery_images = array_values( array_slice( $uploaded_images, 1 ) );
+
+ if ( count( $gallery_images ) ) {
+ update_post_meta( $listing_id, '_listing_img', $gallery_images );
+ } else {
+ delete_post_meta( $listing_id, '_listing_img' );
}
directorist_background_image_process( $background_processable_images );
diff --git a/includes/helper-functions.php b/includes/helper-functions.php
index 57b3da23c1..b629627f9b 100644
--- a/includes/helper-functions.php
+++ b/includes/helper-functions.php
@@ -4863,6 +4863,346 @@ function directorist_get_listing_preview_image( $listing_id = 0 ) {
return $image;
}
+/**
+ * Get the normalized display configuration for listing preview images.
+ *
+ * This keeps the archive renderer, focal-point editor, themes, and extensions
+ * aligned with the existing Preview Image settings without changing how those
+ * settings are stored.
+ *
+ * @since 8.9.3
+ *
+ * @return array
+ */
+function directorist_get_listing_preview_image_display_config() {
+ $config = [
+ 'mode' => get_directorist_option( 'way_to_show_preview', 'cover' ),
+ 'size_by' => get_directorist_option( 'prv_container_size_by', 'px' ),
+ 'width' => get_directorist_option( 'crop_width', 360 ),
+ 'height' => get_directorist_option( 'crop_height', 300 ),
+ 'background_type' => get_directorist_option( 'prv_background_type', 'blur' ),
+ 'background_color' => get_directorist_option( 'prv_background_color', '#fff' ),
+ 'image_quality' => get_directorist_option( 'preview_image_quality', 'directorist_preview' ),
+ ];
+
+ /**
+ * Filters the listing preview image display configuration.
+ *
+ * @since 8.9.3
+ *
+ * @param array $config Listing preview image display configuration.
+ */
+ $config = apply_filters( 'directorist_listing_preview_image_display_config', $config );
+ $config = is_array( $config ) ? $config : [];
+
+ $mode = isset( $config['mode'] ) && in_array( $config['mode'], [ 'cover', 'contain', 'full' ], true ) ? $config['mode'] : 'cover';
+ $size_by = isset( $config['size_by'] ) && in_array( $config['size_by'], [ 'px', 'ratio' ], true ) ? $config['size_by'] : 'px';
+ $background_type = isset( $config['background_type'] ) && in_array( $config['background_type'], [ 'blur', 'color' ], true ) ? $config['background_type'] : 'blur';
+ $width = isset( $config['width'] ) && is_numeric( $config['width'] ) ? absint( $config['width'] ) : 360;
+ $height = isset( $config['height'] ) && is_numeric( $config['height'] ) ? absint( $config['height'] ) : 300;
+ $width = $width ? $width : 360;
+ $height = $height ? $height : 300;
+ $background = isset( $config['background_color'] ) && is_scalar( $config['background_color'] ) ? sanitize_text_field( (string) $config['background_color'] ) : '#fff';
+ $image_quality = isset( $config['image_quality'] ) && is_scalar( $config['image_quality'] ) ? sanitize_key( (string) $config['image_quality'] ) : 'directorist_preview';
+ $style = [];
+
+ if ( 'ratio' === $size_by ) {
+ $style['padding-top'] = ( $height / $width * 100 ) . '%';
+ } elseif ( 'full' !== $mode ) {
+ $style['height'] = $height . 'px';
+ }
+
+ if ( 'full' !== $mode && 'color' === $background_type ) {
+ $style['background-color'] = $background;
+ }
+
+ $container_style = '';
+ foreach ( $style as $property => $value ) {
+ $container_style .= $property . ':' . $value . ';';
+ }
+
+ return [
+ 'mode' => $mode,
+ 'size_by' => $size_by,
+ 'width' => $width,
+ 'height' => $height,
+ 'background_type' => $background_type,
+ 'background_color' => $background,
+ 'image_quality' => $image_quality,
+ 'container_class' => 'directorist-card-' . $mode,
+ 'container_style' => safecss_filter_attr( $container_style ),
+ ];
+}
+
+/**
+ * Sanitize listing image focal point coordinates.
+ *
+ * @since 8.9.3
+ *
+ * @param array $focal_point Image focal point coordinates.
+ * @return array
+ */
+function directorist_sanitize_listing_image_focal_point( $focal_point = [] ) {
+ $focal_point = is_array( $focal_point ) ? $focal_point : [];
+ $x = isset( $focal_point['x'] ) && is_numeric( $focal_point['x'] ) ? (float) $focal_point['x'] : 50;
+ $y = isset( $focal_point['y'] ) && is_numeric( $focal_point['y'] ) ? (float) $focal_point['y'] : 50;
+
+ return [
+ 'x' => min( 100, max( 0, round( $x, 2 ) ) ),
+ 'y' => min( 100, max( 0, round( $y, 2 ) ) ),
+ ];
+}
+
+/**
+ * Get all image focal points saved for a listing.
+ *
+ * Focal points are stored against the listing instead of the attachment so the
+ * same media-library image can be positioned differently in different listings.
+ *
+ * @since 8.9.3
+ *
+ * @param int $listing_id Listing ID.
+ * @return array
+ */
+function directorist_get_listing_image_focal_points( $listing_id = 0 ) {
+ $listing_id = absint( $listing_id );
+ $focal_points = $listing_id ? get_post_meta( $listing_id, '_directorist_image_focal_points', true ) : [];
+ $focal_points = is_array( $focal_points ) ? $focal_points : [];
+ $sanitized = [];
+
+ foreach ( $focal_points as $attachment_id => $focal_point ) {
+ $attachment_id = absint( $attachment_id );
+
+ if ( ! $attachment_id ) {
+ continue;
+ }
+
+ $sanitized[ $attachment_id ] = directorist_sanitize_listing_image_focal_point( $focal_point );
+ }
+
+ return $sanitized;
+}
+
+/**
+ * Get the focal point for a listing image.
+ *
+ * @since 8.9.3
+ *
+ * @param int $listing_id Listing ID.
+ * @param int $attachment_id Attachment ID.
+ * @return array
+ */
+function directorist_get_listing_image_focal_point( $listing_id = 0, $attachment_id = 0 ) {
+ $listing_id = absint( $listing_id );
+ $attachment_id = absint( $attachment_id );
+ $focal_points = directorist_get_listing_image_focal_points( $listing_id );
+ $focal_point = isset( $focal_points[ $attachment_id ] ) ? $focal_points[ $attachment_id ] : [ 'x' => 50, 'y' => 50 ];
+
+ /**
+ * Filters a listing image focal point before it is rendered.
+ *
+ * @since 8.9.3
+ *
+ * @param array $focal_point Focal point coordinates.
+ * @param int $listing_id Listing ID.
+ * @param int $attachment_id Attachment ID.
+ */
+ $focal_point = apply_filters( 'directorist_listing_image_focal_point', $focal_point, $listing_id, $attachment_id );
+
+ return directorist_sanitize_listing_image_focal_point( $focal_point );
+}
+
+/**
+ * Get CSS custom properties for a listing image focal point.
+ *
+ * @since 8.9.3
+ *
+ * @param int $listing_id Listing ID.
+ * @param int $attachment_id Attachment ID.
+ * @return string
+ */
+function directorist_get_listing_image_focal_point_style( $listing_id = 0, $attachment_id = 0 ) {
+ $focal_point = directorist_get_listing_image_focal_point( $listing_id, $attachment_id );
+ $x = rtrim( rtrim( number_format( $focal_point['x'], 2, '.', '' ), '0' ), '.' );
+ $y = rtrim( rtrim( number_format( $focal_point['y'], 2, '.', '' ), '0' ), '.' );
+
+ return sprintf( '--directorist-image-focus-x:%1$s%%;--directorist-image-focus-y:%2$s%%;', $x, $y );
+}
+
+/**
+ * Save a focal point for one image in a listing.
+ *
+ * @since 8.9.3
+ *
+ * @param int $listing_id Listing ID.
+ * @param int $attachment_id Attachment ID.
+ * @param array $focal_point Focal point coordinates.
+ * @return bool
+ */
+function directorist_update_listing_image_focal_point( $listing_id, $attachment_id, $focal_point ) {
+ $listing_id = absint( $listing_id );
+ $attachment_id = absint( $attachment_id );
+
+ if ( ! $listing_id || ! $attachment_id ) {
+ return false;
+ }
+
+ $focal_points = directorist_get_listing_image_focal_points( $listing_id );
+ $focal_points[ $attachment_id ] = directorist_sanitize_listing_image_focal_point( $focal_point );
+
+ return (bool) update_post_meta( $listing_id, '_directorist_image_focal_points', $focal_points );
+}
+
+/**
+ * Delete the focal point for one image in a listing.
+ *
+ * @since 8.9.3
+ *
+ * @param int $listing_id Listing ID.
+ * @param int $attachment_id Attachment ID.
+ * @return bool
+ */
+function directorist_delete_listing_image_focal_point( $listing_id, $attachment_id ) {
+ $listing_id = absint( $listing_id );
+ $attachment_id = absint( $attachment_id );
+
+ if ( ! $listing_id || ! $attachment_id ) {
+ return false;
+ }
+
+ $focal_points = directorist_get_listing_image_focal_points( $listing_id );
+
+ if ( ! isset( $focal_points[ $attachment_id ] ) ) {
+ return true;
+ }
+
+ unset( $focal_points[ $attachment_id ] );
+
+ if ( empty( $focal_points ) ) {
+ delete_post_meta( $listing_id, '_directorist_image_focal_points' );
+ return true;
+ }
+
+ return (bool) update_post_meta( $listing_id, '_directorist_image_focal_points', $focal_points );
+}
+
+/**
+ * Check whether the focal-point control is enabled for a listing's image field.
+ *
+ * @since 8.9.3
+ *
+ * @param int $listing_id Listing ID.
+ * @return bool
+ */
+function directorist_is_listing_image_focal_point_enabled( $listing_id = 0 ) {
+ $directory_id = directorist_get_listing_directory( absint( $listing_id ) );
+ $image_field = $directory_id ? directorist_get_listing_form_field( $directory_id, 'image_upload' ) : [];
+ $admin_only = isset( $image_field['only_for_admin'] ) && ! in_array( $image_field['only_for_admin'], [ false, 0, '0', 'false', '' ], true );
+
+ if ( empty( $image_field ) || $admin_only ) {
+ return false;
+ }
+
+ if ( ! isset( $image_field['enable_image_focus'] ) ) {
+ return true;
+ }
+
+ return ! in_array( $image_field['enable_image_focus'], [ false, 0, '0', 'false' ], true );
+}
+
+/**
+ * Remove focal points for images that are no longer attached to a listing.
+ *
+ * @since 8.9.3
+ *
+ * @param int $listing_id Listing ID.
+ * @param array $attachment_ids Attachment IDs that remain in the listing.
+ * @return void
+ */
+function directorist_clean_listing_image_focal_points( $listing_id, $attachment_ids = [] ) {
+ $listing_id = absint( $listing_id );
+ $attachment_ids = array_filter( wp_parse_id_list( $attachment_ids ) );
+
+ if ( ! $listing_id ) {
+ return;
+ }
+
+ $focal_points = directorist_get_listing_image_focal_points( $listing_id );
+ $focal_points = array_intersect_key( $focal_points, array_flip( $attachment_ids ) );
+
+ if ( empty( $focal_points ) ) {
+ delete_post_meta( $listing_id, '_directorist_image_focal_points' );
+ return;
+ }
+
+ update_post_meta( $listing_id, '_directorist_image_focal_points', $focal_points );
+}
+
+/**
+ * Resolve the preview image selected by the frontend image uploader.
+ *
+ * @since 8.9.3
+ *
+ * @param string $source Preview source token.
+ * @param array $attachment_ids Available attachment IDs.
+ * @param array $new_attachment_ids Temporary file names mapped to attachment IDs.
+ * @return int
+ */
+function directorist_resolve_listing_preview_image_source( $source, $attachment_ids = [], $new_attachment_ids = [] ) {
+ $source = sanitize_text_field( (string) $source );
+ $attachment_ids = array_filter( wp_parse_id_list( $attachment_ids ) );
+
+ if ( ! $source || strpos( $source, ':' ) === false ) {
+ return 0;
+ }
+
+ list( $source_type, $source_value ) = explode( ':', $source, 2 );
+ $preview_image_id = 0;
+
+ if ( 'attachment' === $source_type ) {
+ $preview_image_id = absint( $source_value );
+ } elseif ( 'temp' === $source_type ) {
+ $source_value = sanitize_file_name( $source_value );
+ $preview_image_id = isset( $new_attachment_ids[ $source_value ] ) ? absint( $new_attachment_ids[ $source_value ] ) : 0;
+ }
+
+ return in_array( $preview_image_id, $attachment_ids, true ) ? $preview_image_id : 0;
+}
+
+/**
+ * Save focal point coordinates submitted for the selected preview image.
+ *
+ * @since 8.9.3
+ *
+ * @param int $listing_id Listing ID.
+ * @param int $attachment_id Preview attachment ID.
+ * @param array $posted_data Listing submission data.
+ * @return void
+ */
+function directorist_save_listing_image_focal_point( $listing_id, $attachment_id, $posted_data = [] ) {
+ if ( ! array_key_exists( 'listing_image_focal_x', $posted_data ) || ! array_key_exists( 'listing_image_focal_y', $posted_data ) ) {
+ return;
+ }
+
+ if ( array_key_exists( 'listing_image_focal_is_custom', $posted_data ) ) {
+ $is_custom = ! in_array( $posted_data['listing_image_focal_is_custom'], [ false, 0, '0', 'false', '' ], true );
+
+ if ( ! $is_custom ) {
+ directorist_delete_listing_image_focal_point( $listing_id, $attachment_id );
+ return;
+ }
+ }
+
+ directorist_update_listing_image_focal_point(
+ $listing_id,
+ $attachment_id,
+ [
+ 'x' => $posted_data['listing_image_focal_x'],
+ 'y' => $posted_data['listing_image_focal_y'],
+ ]
+ );
+}
+
/**
* Get listing gallery images.
*
diff --git a/includes/model/ListingDashboard.php b/includes/model/ListingDashboard.php
index 98a915eb35..2400d307bf 100644
--- a/includes/model/ListingDashboard.php
+++ b/includes/model/ListingDashboard.php
@@ -257,9 +257,12 @@ public function get_listing_type() {
public function get_listing_thumbnail() {
$id = get_the_ID();
$type = directorist_get_listing_directory( $id );
+ $thumbnail_id = 0;
+ $thumbnail_img = '';
$default_image_src = Helper::default_preview_image_src( $type );
- $image_quality = get_directorist_option( 'preview_image_quality', 'directorist_preview' );
+ $display_config = directorist_get_listing_preview_image_display_config();
+ $image_quality = $display_config['image_quality'];
$listing_prv_img = directorist_get_listing_preview_image( $id );
$listing_img = directorist_get_listing_gallery_images( $id );
@@ -287,8 +290,9 @@ public function get_listing_thumbnail() {
$image_alt = get_post_meta( $thumbnail_id, '_wp_attachment_image_alt', true );
$image_alt = ( ! empty( $image_alt ) ) ? esc_attr( $image_alt ) : esc_html( get_the_title( $thumbnail_id ) );
$image_alt = ( ! empty( $image_alt ) ) ? $image_alt : esc_html( get_the_title() );
+ $image_style = esc_attr( directorist_get_listing_image_focal_point_style( $id, $thumbnail_id ) );
- return "";
+ return "
";
}
public function fav_listing_items() {
@@ -705,6 +709,21 @@ public function get_action_dropdown_item() {
];
}
+ if ( directorist_get_listing_preview_image( $post_id ) && directorist_is_listing_image_focal_point_enabled( $post_id ) ) {
+ $focus_url = add_query_arg(
+ 'directorist_edit_focus',
+ '1',
+ ATBDP_Permalink::get_edit_listing_page_link( $post_id )
+ );
+ $dropdown_items['image_focus'] = [
+ 'class' => '',
+ 'data_attr' => '',
+ 'link' => $focus_url,
+ 'icon' => directorist_icon( 'las la-crosshairs', false ),
+ 'label' => __( 'Edit image focus', 'directorist' ),
+ ];
+ }
+
// Add unfeature option if listing is featured.
if ( apply_filters( 'directorist_can_unfeature_listing', $this->can_unfeature(), $this ) ) {
$dropdown_items['unfeature'] = [
@@ -726,4 +745,4 @@ public function get_action_dropdown_item() {
return apply_filters( 'directorist_dashboard_listing_action_items_end', $dropdown_items, $post_id, $this );
}
-}
\ No newline at end of file
+}
diff --git a/includes/model/Listings.php b/includes/model/Listings.php
index 17a3ac4636..d65df5db11 100644
--- a/includes/model/Listings.php
+++ b/includes/model/Listings.php
@@ -1723,6 +1723,9 @@ public function openstreet_map_card_data() {
$ls_data['gallery_img'] = atbdp_get_image_source( $ls_data['listing_img'][0], 'medium' );
}
+ $ls_data['image_attachment_id'] = ! empty( $ls_data['listing_prv_img'] ) ? absint( $ls_data['listing_prv_img'] ) : absint( $ls_data['listing_img'][0] ?? 0 );
+ $ls_data['image_focal_point_style'] = directorist_get_listing_image_focal_point_style( $listings_id, $ls_data['image_attachment_id'] );
+
$cat_icon = directorist_icon( $this->loop_map_cat_icon(), false );
$ls_data['cat_icon'] = $cat_icon;
@@ -1806,8 +1809,8 @@ public function load_google_map() {
$cat_icon = directorist_icon( $this->loop_map_cat_icon(), false );
$ls_data['cat_icon'] = wp_json_encode( $cat_icon );
- $listing_type = directorist_get_listing_directory( $listings_id );
- $ls_data['default_img'] = Helper::default_preview_image_src( $listing_type );
+ $listing_type = directorist_get_listing_directory( $listings_id );
+ $ls_data['default_image'] = Helper::default_preview_image_src( $listing_type );
if ( ! empty( $ls_data['listing_prv_img'] ) ) {
$ls_data['prv_image'] = atbdp_get_image_source( $ls_data['listing_prv_img'], 'large' );
@@ -1817,6 +1820,9 @@ public function load_google_map() {
$ls_data['gallery_img'] = atbdp_get_image_source( $ls_data['listing_img'][0], 'medium' );
}
+ $ls_data['image_attachment_id'] = ! empty( $ls_data['listing_prv_img'] ) ? absint( $ls_data['listing_prv_img'] ) : absint( $ls_data['listing_img'][0] ?? 0 );
+ $ls_data['image_focal_point_style'] = directorist_get_listing_image_focal_point_style( $listings_id, $ls_data['image_attachment_id'] );
+
if ( ! empty( $ls_data['manual_lat'] ) && ! empty( $ls_data['manual_lng'] ) ) {
$opt['ls_data'] = $ls_data;
$content = Helper::get_template_contents( 'archive/fields/google-map', $opt );
@@ -1872,8 +1878,9 @@ function loop_get_the_thumbnail( $class = '' ) {
$default_image_src = Helper::default_preview_image_src( $this->current_listing_type );
- $id = get_the_ID();
- $image_quality = get_directorist_option( 'preview_image_quality', 'directorist_preview' );
+ $id = get_the_ID();
+ $display_config = directorist_get_listing_preview_image_display_config();
+ $image_quality = $display_config['image_quality'];
$listing_prv_img = directorist_get_listing_preview_image( $id );
$listing_img = directorist_get_listing_gallery_images( $id );
@@ -1886,7 +1893,8 @@ function loop_get_the_thumbnail( $class = '' ) {
if ( empty( $thumbnail_img_id ) ) {
$thumbnail_img_id = $default_image_src;
$image_alt = esc_html( get_the_title( $id ) );
- $image = "
";
+ $image_style = esc_attr( directorist_get_listing_image_focal_point_style( $id ) );
+ $image = "
";
if ( ! $this->disable_single_listing ) {
$image = $link_start . $image . $link_end;
}
@@ -1902,10 +1910,12 @@ function loop_get_the_thumbnail( $class = '' ) {
$image_count = count( $thumbnail_img_id );
if ( 1 === (int) $image_count ) {
- $image_src = atbdp_get_image_source( reset( $thumbnail_img_id ), $image_quality );
- $image_alt = get_post_meta( reset( $thumbnail_img_id ), '_wp_attachment_image_alt', true );
- $image_alt = ( ! empty( $image_alt ) ) ? esc_attr( $image_alt ) : esc_html( get_the_title( reset( $thumbnail_img_id ) ) );
- $image = "
";
+ $attachment_id = reset( $thumbnail_img_id );
+ $image_src = atbdp_get_image_source( $attachment_id, $image_quality );
+ $image_alt = get_post_meta( $attachment_id, '_wp_attachment_image_alt', true );
+ $image_alt = ( ! empty( $image_alt ) ) ? esc_attr( $image_alt ) : esc_html( get_the_title( $attachment_id ) );
+ $image_style = esc_attr( directorist_get_listing_image_focal_point_style( $id, $attachment_id ) );
+ $image = "
";
if ( ! $this->disable_single_listing ) {
$image = $link_start . $image . $link_end;
}
@@ -1919,7 +1929,8 @@ function loop_get_the_thumbnail( $class = '' ) {
$image_src = atbdp_get_image_source( $img_id, $image_quality );
$image_alt = get_post_meta( $img_id, '_wp_attachment_image_alt', true );
$image_alt = ! empty( $image_alt ) ? esc_attr( $image_alt ) : esc_html( get_the_title( $img_id ) );
- $image = "
";
+ $image_style = esc_attr( directorist_get_listing_image_focal_point_style( $id, $img_id ) );
+ $image = "
";
if ( ! $this->disable_single_listing ) {
$image = $link_start . $image . $link_end;
diff --git a/includes/model/SingleListing.php b/includes/model/SingleListing.php
index 377c707c8e..d6a7112001 100644
--- a/includes/model/SingleListing.php
+++ b/includes/model/SingleListing.php
@@ -1393,16 +1393,16 @@ public function map_data() {
$default_image = Helper::default_preview_image_src( $listing_type );
$listing_title = get_the_title( $id );
$img_url = '';
+ $attachment_id = 0;
if ( ! empty( $listing_prv_img ) ) {
- $cropped_image = atbdp_image_cropping( $listing_prv_img, 150, 150, true, 100 );
- $img_url = ! empty( $cropped_image['url'] ) ? $cropped_image['url'] : atbdp_get_image_source( $listing_prv_img, 'small' );
+ $attachment_id = absint( $listing_prv_img );
+ $img_url = atbdp_get_image_source( $attachment_id, 'medium' );
}
if ( empty( $img_url ) && ! empty( $listing_img[0] ) ) {
- $gallery_image_id = (int) $listing_img[0];
- $cropped_image = atbdp_image_cropping( $gallery_image_id, 150, 150, true, 100 );
- $img_url = ! empty( $cropped_image['url'] ) ? $cropped_image['url'] : atbdp_get_image_source( $gallery_image_id, 'small' );
+ $attachment_id = absint( $listing_img[0] );
+ $img_url = atbdp_get_image_source( $attachment_id, 'medium' );
}
if ( empty( $img_url ) ) {
@@ -1410,9 +1410,10 @@ public function map_data() {
}
$image = sprintf(
- "