├── types.d.ts ├── phpstan.neon ├── pint.json ├── readme.txt ├── src ├── Position.php ├── FocalPoint.php ├── CLI.php └── FocalPointPicker.php ├── autoload.dist.php ├── focal-point-picker.php ├── focal-point-picker.css └── focal-point-picker.js /types.d.ts: -------------------------------------------------------------------------------- 1 | interface Window { 2 | focalPointPicker?: { 3 | defaultPosition?: { top?: number; left?: number }; 4 | }; 5 | } 6 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: 6 3 | paths: 4 | - focal-point-picker.php 5 | - src/ 6 | scanDirectories: 7 | - vendor/.wordpress 8 | - vendor/wp-cli/wp-cli 9 | ignoreErrors: 10 | - '#Constant WPFP_PLUGIN_URI not found#' 11 | - '#Constant WPFP_PLUGIN_DIR not found#' -------------------------------------------------------------------------------- /pint.json: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "psr12", 3 | "rules": { 4 | "array_syntax": { "syntax": "short" }, 5 | "simplified_null_return": true, 6 | "array_indentation": true, 7 | "native_function_invocation": { 8 | "include": ["@all"], 9 | "scope": "all", 10 | "strict": true 11 | } 12 | }, 13 | "exclude": [] 14 | } 15 | -------------------------------------------------------------------------------- /readme.txt: -------------------------------------------------------------------------------- 1 | === Focal Point Picker === 2 | 3 | Zero-dependency custom [focal point](<[url](https://en.wikipedia.org/wiki/Focus_(optics))>) picker for your WordPress images 🎯 4 | 5 | ![CleanShot 2024-06-24 at 15 18 15@2x](https://github.com/hirasso/focal-point-picker/assets/869813/3717cedb-d1db-4192-b24d-9997e48432c9) 6 | 7 | == Description == 8 | 9 | Zero-dependency custom [focal point](<[url](https://en.wikipedia.org/wiki/Focus_(optics))>) picker for your WordPress images 🎯 10 | 11 | **→ [Browse the docs on GitHub](https://github.com/hirasso/focal-point-picker)** 12 | 13 | == Changelog == 14 | 15 | **→ [View the changelog on GitHub](https://github.com/hirasso/focal-point-picker/blob/main/CHANGELOG.md)** -------------------------------------------------------------------------------- /src/Position.php: -------------------------------------------------------------------------------- 1 | left = self::sanitize($left); 23 | $this->top = self::sanitize($top); 24 | } 25 | 26 | /** 27 | * Sanitize a value between zero to one 28 | */ 29 | private static function sanitize(mixed $value): float 30 | { 31 | if (!\is_numeric($value) && empty($value)) { 32 | return 0.5; 33 | } 34 | 35 | $value = \floatval($value); 36 | 37 | if ($value > 1) { 38 | $value /= 100; 39 | } 40 | 41 | return \round($value, 2); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /autoload.dist.php: -------------------------------------------------------------------------------- 1 | __DIR__ . '/vendor/autoload.php', 36 | default => __DIR__ . '/autoload.dist.php' 37 | }; 38 | 39 | /** 40 | * Initialize the Admin Functionality 41 | */ 42 | FocalPointPicker::init(); 43 | CLI::init(); 44 | 45 | /** 46 | * Helper function to retrieve a focal point for an image 47 | */ 48 | if (!\function_exists('fcp_get_focalpoint')) { 49 | function fcp_get_focalpoint(WP_Post|int $post): FocalPoint 50 | { 51 | return new FocalPoint($post); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/FocalPoint.php: -------------------------------------------------------------------------------- 1 | ID, 'focalpoint', true); 36 | $default = FocalPointPicker::getDefaultPosition(); 37 | 38 | $position = new Position( 39 | left: $raw['left'] ?? $default->left, 40 | top: $raw['top'] ?? $default->top, 41 | ); 42 | 43 | $this->left = $position->left; 44 | $this->top = $position->top; 45 | 46 | $this->leftPercent = $this->left * 100; 47 | $this->topPercent = $this->top * 100; 48 | 49 | $this->x = $this->left; 50 | $this->y = $this->top; 51 | 52 | $this->xPercent = $this->x * 100; 53 | $this->yPercent = $this->y * 100; 54 | } 55 | 56 | /** 57 | * Is the focal point's value equal to the default value 58 | */ 59 | public function isDefaultPosition(): bool 60 | { 61 | $defaultPosition = FocalPointPicker::getDefaultPosition(); 62 | return $this->x === $defaultPosition->top && $this->y === $defaultPosition->left; 63 | } 64 | 65 | 66 | } 67 | -------------------------------------------------------------------------------- /focal-point-picker.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Hide the input element 3 | */ 4 | .compat-field-focalpoint-input { 5 | 6 | } 7 | 8 | /** 9 | * Hidden elements if not initialized 10 | */ 11 | focal-point-picker [data-focalpoint-handle], 12 | focal-point-picker [data-focalpoint-preview] { 13 | display: none; 14 | } 15 | 16 | /** 17 | * The input element 18 | */ 19 | [data-focalpoint-input-wrap] { 20 | display: flex; 21 | gap: 0.5rem; 22 | } 23 | [data-focalpoint-input-wrap] > * { 24 | flex: 1; 25 | } 26 | [data-focalpoint-reset] { 27 | cursor: pointer; 28 | max-width: fit-content; 29 | } 30 | 31 | /** 32 | * The handle 33 | */ 34 | [data-focalpoint-handle] { 35 | box-sizing: border-box; 36 | position: absolute; 37 | z-index: 2; 38 | appearance: none; 39 | padding: 0; 40 | border: none; 41 | display: block; 42 | width: 0; 43 | height: 0; 44 | cursor: move; 45 | user-select: all; 46 | filter: drop-shadow(1px 1px 2px rgb(0 0 0 / 0.3)); 47 | } 48 | [data-focalpoint-handle]:focus { 49 | outline: 0 !important; 50 | } 51 | [data-focalpoint-handle]::before { 52 | content: ""; 53 | display: block; 54 | position: absolute; 55 | width: 4px; 56 | height: 4px; 57 | border-radius: 99999px; 58 | background: white; 59 | transform: translate(-50%, -50%); 60 | } 61 | [data-focalpoint-handle]:after { 62 | content: ""; 63 | display: block; 64 | --size: 1.2rem; 65 | width: var(--size); 66 | height: var(--size); 67 | border-radius: 9999px; 68 | transform: translate(-50%, -50%); 69 | box-shadow: 70 | inset 0 0 0 1px white, 71 | 0 0 0 3px #2271b1, 72 | 0 0 0 4px white; 73 | } 74 | [data-fcp-wrap] { 75 | position: relative; 76 | } 77 | [data-fcp-wrap] img { 78 | cursor: crosshair; 79 | position: relative; 80 | z-index: 2; 81 | } 82 | [data-fcp-dragging] [data-fcp-wrap] img { 83 | outline: 1px solid rgba(0 0 0 / 0.1); 84 | } 85 | [data-fcp-dragging] .uploader-window { 86 | display: none !important; 87 | } 88 | 89 | /** 90 | * The Preview 91 | */ 92 | [data-focalpoint-preview] { 93 | position: fixed; 94 | z-index: 99999999; 95 | --size: 20svw; 96 | width: var(--size); 97 | bottom: 0; 98 | right: 0; 99 | box-sizing: border-box; 100 | padding: 1rem; 101 | display: grid; 102 | gap: 1rem; 103 | pointer-events: none; 104 | transition-property: opacity, transform; 105 | transition-duration: 200ms; 106 | transition-timing-function: ease-out; 107 | } 108 | [data-focalpoint-preview] > * { 109 | box-sizing: border-box; 110 | background-size: cover; 111 | border-radius: 0.3rem; 112 | background-image: var(--image); 113 | background-color: white; 114 | box-shadow: 115 | 0px 0px 0px 3px white, 116 | 2px 2px 10px rgb(0 0 0 / 0.5); 117 | background-position: var(--focal-left, 50%) var(--focal-top, 50%); 118 | } 119 | [data-focalpoint-preview] > [data-landscape] { 120 | aspect-ratio: 3/1; 121 | } 122 | [data-focalpoint-preview] > [data-portrait] { 123 | aspect-ratio: 1/3; 124 | width: 33.333%; 125 | justify-self: end; 126 | } 127 | [data-focalpoint-preview]:not(.is-visible) { 128 | opacity: 0; 129 | } 130 | -------------------------------------------------------------------------------- /src/CLI.php: -------------------------------------------------------------------------------- 1 | ...] 45 | * : One or more attachment IDs to update. Space-separated. 46 | * 47 | * [--all] 48 | * : Apply default position to all image attachments. 49 | * 50 | * [--yes] 51 | * : Skip prompt before applying the default position to all attachments. 52 | * 53 | * ## EXAMPLES 54 | * 55 | * # Apply the default position to specific attachments 56 | * $ wp fcp apply-default-position 123 456 789 57 | * Success: Applied default focal point position to 3 attachments. 58 | * 59 | * # Apply default position to all image attachments 60 | * $ wp fcp apply-default-position --all 61 | * Warning: This will update all image attachments. Are you sure? [y/n] 62 | * Success: Applied default focal point position to 42 attachments. 63 | * 64 | * @param array $args Positional arguments (attachment IDs) 65 | * @param array $assocArgs Associative arguments (flags) 66 | */ 67 | public static function applyDefaultPositionCommand(array $args, array $assocArgs): void 68 | { 69 | $applyAll = $assocArgs['all'] ?? false; 70 | 71 | // Validate that either IDs or --all flag is provided 72 | if (empty($args) && !$applyAll) { 73 | WP_CLI::error('Please provide attachment IDs or use the --all flag.'); 74 | return; 75 | } 76 | 77 | // Prevent using both IDs and --all flag 78 | if (!empty($args) && $applyAll) { 79 | WP_CLI::error('Cannot specify both attachment IDs and --all flag. Choose one.'); 80 | return; 81 | } 82 | 83 | $defaultPosition = FocalPointPicker::getDefaultPosition(); 84 | $imageIDs = []; 85 | 86 | // Handle --all flag 87 | if ($applyAll) { 88 | $imageIDs = self::getAllImageIDs(); 89 | 90 | if (empty($imageIDs)) { 91 | WP_CLI::warning('No image attachments found.'); 92 | return; 93 | } 94 | 95 | WP_CLI::confirm( 96 | \sprintf( 97 | "⚠️ Apply the default focal point position (top: %.2f, left: %.2f) to all your %d image%s?", 98 | $defaultPosition->left, 99 | $defaultPosition->top, 100 | \count($imageIDs), 101 | \count($imageIDs) === 1 ? '' : 's' 102 | ), 103 | $assocArgs 104 | ); 105 | } else { 106 | // Validate provided attachment IDs 107 | $imageIDs = \array_map('intval', $args); 108 | $invalidIDs = self::getInvalidImageIDs($imageIDs); 109 | 110 | if (!empty($invalidIDs)) { 111 | WP_CLI::error( 112 | \sprintf( 113 | 'The following IDs are not valid image attachments: %s', 114 | \implode(', ', $invalidIDs) 115 | ) 116 | ); 117 | } 118 | } 119 | 120 | // Apply default position to attachments 121 | $successCount = 0; 122 | 123 | foreach ($imageIDs as $attachmentId) { 124 | \delete_post_meta($attachmentId, 'focalpoint'); 125 | 126 | $result = \update_post_meta( 127 | $attachmentId, 128 | 'focalpoint', 129 | [ 130 | 'left' => $defaultPosition->left, 131 | 'top' => $defaultPosition->top, 132 | ] 133 | ); 134 | 135 | if ($result !== false) { 136 | $successCount++; 137 | } 138 | } 139 | 140 | WP_CLI::success( 141 | \sprintf( 142 | 'Applied the default focal point position (%.2f, %.2f) to %d image%s.', 143 | $defaultPosition->left, 144 | $defaultPosition->top, 145 | $successCount, 146 | $successCount === 1 ? '' : 's' 147 | ) 148 | ); 149 | } 150 | 151 | /** 152 | * Get all image attachment IDs from the database. 153 | * 154 | * @return array Array of attachment IDs 155 | */ 156 | private static function getAllImageIDs(): array 157 | { 158 | $query = new \WP_Query([ 159 | 'post_type' => 'attachment', 160 | 'post_mime_type' => 'image', 161 | 'post_status' => 'inherit', 162 | 'posts_per_page' => -1, 163 | 'fields' => 'ids', 164 | 'no_found_rows' => true, 165 | 'update_post_meta_cache' => false, 166 | 'update_post_term_cache' => false, 167 | 'ignore_sticky_posts' => true 168 | ]); 169 | 170 | return $query->posts; 171 | } 172 | 173 | /** 174 | * Validate that the provided IDs are valid image attachments. 175 | * 176 | * @param array $imageIDs 177 | * @return array Array of invalid IDs 178 | */ 179 | private static function getInvalidImageIDs(array $imageIDs): array 180 | { 181 | $invalidIDs = []; 182 | 183 | foreach ($imageIDs as $id) { 184 | if (!\wp_attachment_is_image($id)) { 185 | $invalidIDs[] = $id; 186 | } 187 | } 188 | 189 | return $invalidIDs; 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /src/FocalPointPicker.php: -------------------------------------------------------------------------------- 1 | self::getDefaultPosition() 33 | ]; 34 | 35 | \wp_add_inline_script( 36 | 'focal-point-picker', 37 | \sprintf( 38 | "var focalPointPicker = %s;", 39 | \wp_json_encode($jsConfig, JSON_PRETTY_PRINT | JSON_NUMERIC_CHECK), 40 | ), 41 | 'before', 42 | ); 43 | } 44 | 45 | /** 46 | * Get the default value, ONCE 47 | */ 48 | public static function getDefaultPosition(): Position 49 | { 50 | if (isset(self::$defaultPosition)) { 51 | return self::$defaultPosition; 52 | } 53 | 54 | self::$defaultPosition = \apply_filters( 55 | 'hirasso/fcp/default-position', 56 | new Position(0.5, 0.5) 57 | ); 58 | 59 | return self::$defaultPosition; 60 | } 61 | 62 | /** 63 | * Helper function to get versioned asset urls 64 | */ 65 | private static function assetUri(string $path): string 66 | { 67 | $uri = WPFP_PLUGIN_URI . '/' . \ltrim($path, '/'); 68 | $file = WPFP_PLUGIN_DIR . '/' . \ltrim($path, '/'); 69 | 70 | if (\file_exists($file)) { 71 | $version = \filemtime($file); 72 | $uri .= "?v=$version"; 73 | } 74 | return $uri; 75 | } 76 | 77 | /** 78 | * Render the focal point picker field. 79 | * Uses custom elements for simple self-initialization 80 | * 81 | * @param array $fields 82 | * @return array 83 | */ 84 | public static function attachmentFieldsToEdit( 85 | array $fields, 86 | WP_Post $post 87 | ): array { 88 | if (!\wp_attachment_is_image($post)) { 89 | return $fields; 90 | } 91 | $focalPoint = new FocalPoint($post); 92 | 93 | \ob_start() ?> 94 | 95 | 96 |
97 | 98 | 99 |
100 | 101 | 105 | 106 |
107 | 108 | \__('Focal Point'), 112 | 'input' => 'html', 113 | 'html' => $html, 114 | ]; 115 | 116 | return $fields; 117 | } 118 | 119 | /** 120 | * Save the focal point 121 | * 122 | * @param array $post 123 | * @param array $attachmentData 124 | * @return array 125 | */ 126 | public static function attachmentFieldsToSave( 127 | array $post, 128 | array $attachmentData 129 | ): array { 130 | $id = $post['ID'] ?? ''; 131 | \check_ajax_referer('update-post_' . $id, 'nonce'); 132 | 133 | if (!\wp_attachment_is_image($id)) { 134 | return $post; 135 | } 136 | 137 | $focalPoint = \array_map( 138 | 'trim', 139 | \explode(' ', $attachmentData['focalpoint'] ?? '') 140 | ); 141 | 142 | /** Validation: Array of two? */ 143 | if (\count($focalPoint) !== 2) { 144 | return $post; 145 | } 146 | 147 | /** Validation: All numeric? */ 148 | foreach ($focalPoint as $value) { 149 | if (!\is_numeric($value)) { 150 | return $post; 151 | } 152 | } 153 | 154 | [$left, $top] = \array_map('floatval', $focalPoint); 155 | 156 | $post = \array_replace_recursive( 157 | $post, 158 | [ 159 | 'meta_input' => [ 160 | 'focalpoint' => [ 161 | 'left' => $left, 162 | 'top' => $top, 163 | ], 164 | ], 165 | ] 166 | ); 167 | 168 | return $post; 169 | } 170 | 171 | /** 172 | * Add the focal point to the image attributes in WordPress image functions 173 | * 174 | * @param array $atts 175 | * @return array 176 | */ 177 | public static function wp_get_attachment_image_attributes( 178 | array $atts, 179 | WP_Post $attachment 180 | ): array { 181 | if (!\wp_attachment_is_image($attachment)) { 182 | return $atts; 183 | } 184 | $focalPoint = new FocalPoint($attachment); 185 | 186 | $atts['class'] ??= ''; 187 | if (!\str_contains($atts['class'], "focal-point-image")) { 188 | $atts['class'] .= " focal-point-image"; 189 | } 190 | 191 | $atts['style'] ??= ''; 192 | $atts['style'] .= " --focal-point-left: {$focalPoint->left}; --focal-point-top: {$focalPoint->top}; "; 193 | 194 | return $atts; 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /focal-point-picker.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | "use strict"; 4 | 5 | /** 6 | * @typedef {import('jquery')} jQuery 7 | * @typedef {import('jqueryui')} jQueryUI 8 | */ 9 | 10 | (($) => { 11 | /** 12 | * Wait for two animation frames 13 | * @returns {Promise} 14 | */ 15 | function nextTick() { 16 | return new Promise((resolve) => { 17 | requestAnimationFrame(() => { 18 | requestAnimationFrame(() => resolve()); 19 | }); 20 | }); 21 | } 22 | /** 23 | * Test if the current browser supports async/await 24 | * @returns {boolean} 25 | */ 26 | function supportsAsyncAwait() { 27 | try { 28 | new Function("return (async () => {})();"); 29 | return true; 30 | } catch (e) { 31 | return false; 32 | } 33 | } 34 | /** 35 | * Create an element on the fly 36 | * @param {string} html - The HTML string to create the element from. 37 | * @return {HTMLElement} The created element. 38 | */ 39 | function createElement(html) { 40 | const template = document.createElement("template"); 41 | template.innerHTML = html; 42 | return /** @type {HTMLElement} */ (template.content.children[0]); 43 | } 44 | 45 | /** 46 | * Self-iniziating custom element for a native experience 47 | */ 48 | class FocalPointPicker extends HTMLElement { 49 | /** @type {HTMLInputElement} preview */ 50 | input; 51 | /** @type {HTMLElement} preview */ 52 | preview; 53 | /** @type {HTMLButtonElement} handle */ 54 | handle; 55 | /** @type {HTMLButtonElement} resetButton */ 56 | resetButton; 57 | /** @type {boolean} dragging */ 58 | dragging = false; 59 | /** @type [number, number] */ 60 | defaultPosition = [0.5, 0.5]; 61 | 62 | constructor() { 63 | super(); 64 | this.input = /** @type {!HTMLInputElement} */ ( 65 | this.querySelector("input") 66 | ); 67 | this.preview = /** @type {!HTMLInputElement} */ ( 68 | this.querySelector("[data-focalpoint-preview]") 69 | ); 70 | this.handle = /** @type {!HTMLButtonElement} */ ( 71 | this.querySelector("[data-focalpoint-handle]") 72 | ); 73 | this.resetButton = /** @type {!HTMLButtonElement} */ ( 74 | this.querySelector("[data-focalpoint-reset]") 75 | ); 76 | } 77 | 78 | /** 79 | * Called when the element is added to the DOM 80 | * @return {void} 81 | */ 82 | connectedCallback() { 83 | if (!supportsAsyncAwait()) { 84 | console.error("The current browser doesn't support async / await."); 85 | return; 86 | } 87 | this.init(); 88 | } 89 | 90 | /** 91 | * Initialize everyhing when connected to the DOM 92 | * @return {Promise} 93 | */ 94 | async init() { 95 | await nextTick(); 96 | 97 | if (!document.contains(this)) { 98 | return; 99 | } 100 | 101 | this.defaultPosition = [ 102 | window.focalPointPicker?.defaultPosition?.left ?? 0.5, 103 | window.focalPointPicker?.defaultPosition?.top ?? 0.5, 104 | ]; 105 | 106 | const mediaModalRoot = this.closest(".media-frame-content"); 107 | const classicRoot = this.closest("#post-body-content"); 108 | 109 | const imageWrap = mediaModalRoot 110 | ? mediaModalRoot.querySelector(".thumbnail-image") 111 | : classicRoot 112 | ? classicRoot.querySelector(".wp_attachment_image p") 113 | : undefined; 114 | 115 | if (!imageWrap) { 116 | console.error("No imageWrap found", this); 117 | return; 118 | } 119 | 120 | if (imageWrap.hasAttribute("data-fcp-wrap")) { 121 | console.log("already initialized", this); 122 | return; 123 | } 124 | 125 | imageWrap.setAttribute("data-fcp-wrap", ""); 126 | 127 | this.imageWrap = imageWrap; 128 | this.img = this.imageWrap.querySelector("img"); 129 | if (!this.img) { 130 | console.error("no image found in imageWrap", this.imageWrap); 131 | return; 132 | } 133 | 134 | if (this.img.complete) { 135 | this.initializeUI(); 136 | } else { 137 | this.img.addEventListener("load", this.initializeUI, { once: true }); 138 | } 139 | } 140 | 141 | /** 142 | * Clean up after us the element is removed from the DOM 143 | * @return {void} 144 | */ 145 | disconnectedCallback() { 146 | const { handle, preview, img, imageWrap, resetButton } = this; 147 | 148 | if (preview) { 149 | this.appendChild(preview); 150 | } 151 | if (handle) { 152 | this.appendChild(handle); 153 | } 154 | if (img) { 155 | img.removeEventListener("click", this.onImageClick); 156 | } 157 | if (imageWrap) { 158 | imageWrap.removeAttribute("data-fcp-wrap"); 159 | } 160 | if (resetButton) { 161 | resetButton.removeEventListener("click", this.reset); 162 | } 163 | window.removeEventListener("resize", this.updateUIFromValue); 164 | } 165 | 166 | /** 167 | * Initialize the user interface 168 | * @return {void} 169 | */ 170 | initializeUI = () => { 171 | const { imageWrap, img, handle, preview, resetButton } = this; 172 | 173 | if (!imageWrap || !img) { 174 | console.error("Some elements are missing", { imageWrap, img }); 175 | return; 176 | } 177 | 178 | imageWrap.appendChild(handle); 179 | document.body.appendChild(preview); 180 | 181 | preview.style.setProperty("--image", `url(${img.src}`); 182 | 183 | window.addEventListener("resize", this.updateUIFromValue); 184 | this.updateUIFromValue(); 185 | 186 | img.addEventListener("click", this.onImageClick); 187 | resetButton.addEventListener("click", this.reset); 188 | 189 | $(handle).on("dblclick", this.reset); 190 | 191 | $(handle).draggable({ 192 | cancel: "none", 193 | scroll: false, 194 | containment: img, 195 | start: () => { 196 | this.dragging = true; 197 | this.togglePreview(true); 198 | document.body.setAttribute("data-fcp-dragging", ""); 199 | }, 200 | stop: () => { 201 | this.dragging = false; 202 | this.togglePreview(false); 203 | document.body.removeAttribute("data-fcp-dragging"); 204 | $(this.input).trigger("change"); 205 | }, 206 | drag: this.applyFocalPointFromHandle, 207 | }); 208 | }; 209 | 210 | /** 211 | * Handle window resize event 212 | * @return {void} 213 | */ 214 | updateUIFromValue = () => { 215 | const [left, top] = this.getValueFromInput(); 216 | this.setHandlePosition(left, top); 217 | this.updatePreview(left, top); 218 | this.adjustResetButton(left, top); 219 | }; 220 | 221 | /** 222 | * Get the current focal point value from the input 223 | * @return {number[]} The current focal point values [left, top]. 224 | */ 225 | getValueFromInput() { 226 | const { input } = this; 227 | if (!input) { 228 | console.error("no input found", { input }); 229 | return this.defaultPosition; 230 | } 231 | 232 | const inputValue = input.value.trim(); 233 | const values = inputValue.split(" "); 234 | 235 | if (values.length > 2) { 236 | console.error("invalid value:", inputValue); 237 | return this.defaultPosition; 238 | } 239 | 240 | return values.map(function (/** @type {string} */ value) { 241 | let number = parseFloat(value); 242 | if (number > 1) { 243 | number /= 100; 244 | } 245 | return parseFloat(number.toFixed(2)); 246 | }); 247 | } 248 | 249 | /** 250 | * Get the focal point from the handle position 251 | * @return {number[]} The focal point values [left, top]. 252 | */ 253 | getValueFromHandle() { 254 | const { img, handle } = this; 255 | 256 | if (!img) { 257 | console.error("missing image", { img }); 258 | return this.defaultPosition; 259 | } 260 | 261 | const handleRect = handle.getBoundingClientRect(); 262 | const imgRect = img.getBoundingClientRect(); 263 | 264 | const point = [ 265 | (handleRect.left - imgRect.left) / imgRect.width, 266 | (handleRect.top - imgRect.top) / imgRect.height, 267 | ]; 268 | 269 | return point.map((number) => parseFloat(number.toFixed(2))); 270 | } 271 | 272 | /** 273 | * Handle image click event 274 | * @param {MouseEvent} e - The mouse event. 275 | * @return {void} 276 | */ 277 | onImageClick = (e) => { 278 | const { imageWrap, handle } = this; 279 | if (!imageWrap) { 280 | return; 281 | } 282 | 283 | const rect = imageWrap.getBoundingClientRect(); 284 | 285 | this.animateHandle(e.x - rect.x, e.y - rect.y).then(() => { 286 | this.applyFocalPointFromHandle(); 287 | $(this.input).trigger("change"); 288 | }); 289 | }; 290 | 291 | /** 292 | * Animate the handle to a position and apply the new point 293 | * after the animation 294 | * @param {number} left 295 | * @param {number} top 296 | */ 297 | animateHandle(left, top) { 298 | return /** @type {Promise} */ ( 299 | new Promise((resolve, reject) => { 300 | $(this.handle).animate( 301 | { left, top }, 302 | { 303 | duration: 200, 304 | complete: resolve, 305 | }, 306 | ); 307 | }) 308 | ); 309 | } 310 | 311 | /** 312 | * Resets the focal point 313 | */ 314 | reset = () => { 315 | if (!this.img || !this.imageWrap) { 316 | console.error("Something went wrong while getting the image rect"); 317 | return; 318 | } 319 | const rect = this.img.getBoundingClientRect(); 320 | 321 | this.setHandlePosition(...this.defaultPosition); 322 | this.applyFocalPointFromHandle(); 323 | $(this.input).trigger("change"); 324 | }; 325 | 326 | /** 327 | * Set the handle position, based on the image 328 | * @param {number} left - The left position as a number between 0-1. 329 | * @param {number} top - The top position as a number between 0-1. 330 | * @return {void} 331 | */ 332 | setHandlePosition(left, top) { 333 | const { img, handle } = this; 334 | 335 | if (!img) { 336 | return; 337 | } 338 | 339 | const point = { 340 | left: img.offsetLeft + img.offsetWidth * left, 341 | top: img.offsetTop + img.offsetHeight * top, 342 | }; 343 | 344 | handle.style.setProperty("left", `${point.left}px`); 345 | handle.style.setProperty("top", `${point.top}px`); 346 | } 347 | 348 | /** 349 | * Apply the focal point values based on the handle position 350 | * @return {void} 351 | */ 352 | applyFocalPointFromHandle = () => { 353 | const [left, top] = this.getValueFromHandle(); 354 | this.updateInput(left, top); 355 | this.updatePreview(left, top); 356 | this.adjustResetButton(left, top); 357 | }; 358 | 359 | /** 360 | * Update the input 361 | * @param {number} left 362 | * @param {number} top 363 | */ 364 | updateInput(left, top) { 365 | this.input.value = `${left} ${top}`; 366 | } 367 | 368 | /** 369 | * Check if a value is equal to the default value 370 | * @param {number} left 371 | * @param {number} top 372 | * @return {void} 373 | */ 374 | adjustResetButton(left, top) { 375 | if (this.resetButton) { 376 | this.resetButton.disabled = this.isDefaultPosition(left, top); 377 | } 378 | } 379 | 380 | /** 381 | * Check if a value is equal to the default value 382 | * @param {number} left 383 | * @param {number} top 384 | * @return {boolean} 385 | */ 386 | isDefaultPosition(left, top) { 387 | return ( 388 | left === this.defaultPosition[0] && top === this.defaultPosition[1] 389 | ); 390 | } 391 | 392 | /** 393 | * Toggles the visibility of the preview pane 394 | * @param {boolean} visible 395 | * @return {void} 396 | */ 397 | togglePreview(visible) { 398 | if (typeof visible !== "boolean") { 399 | throw new Error("togglePreview expects a boolean value"); 400 | } 401 | if (!this.preview) { 402 | return; 403 | } 404 | this.preview.classList.toggle("is-visible", visible); 405 | } 406 | 407 | /** 408 | * Set the preview position 409 | * @param {number} left 410 | * @param {number} top 411 | * @return {void} 412 | */ 413 | updatePreview(left, top) { 414 | if (!this.preview) { 415 | return; 416 | } 417 | if (typeof left !== "number") { 418 | console.error("'left' must be a number:", left); 419 | return; 420 | } 421 | if (typeof top !== "number") { 422 | console.error("'top' must be a number:", top); 423 | return; 424 | } 425 | this.preview.style.setProperty("--focal-left", `${left * 100}%`); 426 | this.preview.style.setProperty("--focal-top", `${top * 100}%`); 427 | } 428 | } 429 | 430 | customElements.define("focal-point-picker", FocalPointPicker); 431 | })(/** @type {jQuery} */ jQuery); 432 | --------------------------------------------------------------------------------