import React, { useEffect, useRef, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import { __ } from '@wordpress/i18n'; import { toast } from 'sonner'; import { Switch } from '../ui/switch'; import { Checkbox } from '../ui/checkbox'; import ProAlert from '../ui/pro-alert'; import { getWatermarkSampleImage } from '../../api'; import { renderWatermarkPreview, type WatermarkPosition } from '../../lib/watermark-preview'; const GRID: WatermarkPosition[] = [ 'tl', 'tc', 'tr', 'ml', 'c', 'mr', 'bl', 'bc', 'br' ]; const DEFAULTS = { enabled: false, apply_on_upload: true, type: 'text' as 'text' | 'image', text: window.THUMBPRESS?.site_name || '', font: 'sans-bold' as 'sans' | 'sans-bold', color: '#ffffff', shadow: true, position: 'br' as WatermarkPosition, size: 20, opacity: 60, margin: 20, rotation: 0, sizes: [ 'full', 'large', 'medium_large' ], mimes: [ 'image/jpeg', 'image/png', 'image/webp' ], min_width: 400, min_height: 300, }; type Values = typeof DEFAULTS; const fieldClass = '!w-24 text-center flex !rounded-lg !border !border-thumbpress-border bg-white !px-3 !py-2 text-sm focus:!outline-none focus:!shadow-none'; function loadImage( url: string ): Promise { return new Promise( ( resolve ) => { if ( ! url ) { resolve( null ); return; } const img = new Image(); img.crossOrigin = 'anonymous'; img.onload = () => resolve( img ); img.onerror = () => resolve( null ); img.src = url; } ); } /** * Free Watermark settings. * * Every option can be changed and the preview redraws as you go, so the feature can be judged before * buying. Nothing is saved and nothing is applied: Save, Reset-to-saved and the Watermark page belong * to Pro and open the upsell. Pro replaces this tab's content (same `watermark` slug) once its * licence is active. */ export default function Watermark() { const [ alertOpen, setAlertOpen ] = useState( false ); const openAlert = () => setAlertOpen( true ); // Pro replaces this tab by the `watermark` slug, so normally it is gone by the time Pro is // active. A Pro build older than the watermark replaces nothing, and then this component is // what a paying customer sees: the upsell below is correctly hidden from them, which left the // Save button opening a dialog that never rendered and doing nothing at all (#575). Telling // them to buy Pro would be wrong; they need a newer one. const handleSave = () => { if ( isProActive ) { toast.error( __( 'This version of ThumbPress Pro cannot save a watermark yet. Update ThumbPress Pro to the latest version.', 'image-sizes' ) ); return; } openAlert(); }; const [ v, setV ] = useState( DEFAULTS ); const [ logoUrl, setLogoUrl ] = useState( '' ); const [ sample, setSample ] = useState( null ); const [ sampleReady, setSampleReady ] = useState( false ); const [ params, setParams ] = useSearchParams(); const previewBox = useRef( null ); const scrollToPreview = useRef( false ); const [ logo, setLogo ] = useState( null ); const [ preview, setPreview ] = useState( '' ); const [ previewing, setPreviewing ] = useState( false ); const isProActive = window.THUMBPRESS?.pro_active; const set = ( key: K, value: Values[ K ] ) => setV( ( cur ) => ( { ...cur, [ key ]: value } ) ); const toggle = ( key: 'sizes' | 'mimes', value: string ) => setV( ( cur ) => ( { ...cur, [ key ]: cur[ key ].includes( value ) ? cur[ key ].filter( ( x ) => x !== value ) : [ ...cur[ key ], value ], } ) ); useEffect( () => { getWatermarkSampleImage().then( loadImage ).then( ( img ) => { setSample( img ); setSampleReady( true ); } ); }, [] ); useEffect( () => { loadImage( logoUrl ).then( setLogo ); }, [ logoUrl ] ); const showPreview = () => { setPreviewing( true ); try { const canvas = document.createElement( 'canvas' ); const drawn = renderWatermarkPreview( canvas, sample, logo, v ); if ( ! drawn ) { toast.error( 'image' === v.type ? __( 'Choose a logo to see it on the image.', 'image-sizes' ) : __( 'Type some text to see it on the image.', 'image-sizes' ) ); return; } setPreview( canvas.toDataURL( 'image/jpeg', 0.85 ) ); } catch { toast.error( __( 'The preview could not be created.', 'image-sizes' ) ); } finally { setPreviewing( false ); } }; // "Preview your watermark" on the Watermark page lands here with ?preview=1: draw it once the sample is // ready, bring it into view (it sits below the long form), and drop the flag so a reload does not redo it. useEffect( () => { if ( '1' !== params.get( 'preview' ) || ! sampleReady ) { return; } scrollToPreview.current = true; showPreview(); const next = new URLSearchParams( params ); next.delete( 'preview' ); setParams( next, { replace: true } ); }, [ params, sampleReady ] ); useEffect( () => { if ( preview && scrollToPreview.current ) { scrollToPreview.current = false; previewBox.current?.scrollIntoView( { behavior: 'smooth', block: 'center' } ); } }, [ preview ] ); // The logo is chosen from the Media Library, like Pro's. It only feeds the in-browser preview here. const pickLogo = () => { const wpGlobal = ( window as any ).wp; if ( ! wpGlobal?.media ) { return; } const frame = wpGlobal.media( { title: __( 'Choose watermark logo', 'image-sizes' ), library: { type: [ 'image/png', 'image/jpeg', 'image/webp' ] }, multiple: false, } ); frame.on( 'select', () => { const picked = frame.state().get( 'selection' ).first().toJSON(); setLogoUrl( picked.sizes?.medium?.url || picked.url ); } ); frame.open(); }; const proBadge = ! isProActive && ( ); const row = ( title: string, desc: string | undefined, control: React.ReactNode, last = false, badge = false ) => (

{ title }

{ badge && proBadge }
{ desc &&

{ desc }

}
{ control }
); const slider = ( key: 'size' | 'opacity' | 'margin' | 'rotation', min: number, max: number, unit: string ) => (
set( key, Number( e.target.value ) ) } className="w-36 accent-[#40189D]" /> { v[ key ] }{ unit }
); const sizeLabels: [ string, string ][] = [ [ 'full', __( 'Full size (or scaled)', 'image-sizes' ) ], [ 'large', __( 'Large', 'image-sizes' ) ], [ 'medium_large', __( 'Medium Large', 'image-sizes' ) ], [ 'medium', __( 'Medium', 'image-sizes' ) ], [ 'thumbnail', __( 'Thumbnail', 'image-sizes' ) ], ]; const mimeLabels: [ string, string ][] = [ [ 'image/jpeg', 'JPEG' ], [ 'image/png', 'PNG' ], [ 'image/webp', 'WebP' ] ]; return (
{ row( __( 'Enable watermark', 'image-sizes' ), __( 'Stamp your name or logo on your images so they stay yours when they are copied.', 'image-sizes' ), set( 'enabled', c ) } />, false, true, ) } { row( __( 'Watermark new uploads', 'image-sizes' ), __( 'Mark every image as it is uploaded. The untouched original is kept so you can undo it.', 'image-sizes' ), set( 'apply_on_upload', c ) } />, ) } { row( __( 'Type', 'image-sizes' ), __( 'Write text over your images, or place a logo.', 'image-sizes' ),
{ ( [ [ 'text', __( 'Text', 'image-sizes' ) ], [ 'image', __( 'Logo image', 'image-sizes' ) ] ] as [ 'text' | 'image', string ][] ).map( ( [ value, label ] ) => ( ) ) }
, ) } { 'text' === v.type ? ( <> { row( __( 'Text', 'image-sizes' ), __( 'Latin, Greek and Cyrillic letters are supported.', 'image-sizes' ), set( 'text', e.target.value ) } className="!w-64 !rounded-lg !border !border-thumbpress-border bg-white !px-3 !py-2 text-sm focus:!outline-none focus:!shadow-none" />, ) } { row( __( 'Font', 'image-sizes' ), __( 'Bold reads best over busy photos.', 'image-sizes' ), , ) } { row( __( 'Colour', 'image-sizes' ), __( 'White suits most photos; choose a darker shade for light images.', 'image-sizes' ),
set( 'color', e.target.value ) } className="h-9 w-12 cursor-pointer rounded border border-thumbpress-border bg-white p-1" /> set( 'color', e.target.value ) } className={ fieldClass } />
, ) } { row( __( 'Shadow', 'image-sizes' ), __( 'A soft outline so the text stays readable on light and dark photos.', 'image-sizes' ), set( 'shadow', c ) } />, ) } ) : row( __( 'Logo', 'image-sizes' ), __( 'PNG with a transparent background works best.', 'image-sizes' ),
{ logoUrl && } { logoUrl && ( ) }
, ) } { row( __( 'Position', 'image-sizes' ), __( 'Where the mark sits, or repeat it across the whole image.', 'image-sizes' ),
{ GRID.map( ( pos ) => (
, ) } { row( __( 'Size', 'image-sizes' ), __( 'Width of the mark as a share of the image width.', 'image-sizes' ), slider( 'size', 3, 100, '%' ) ) } { row( __( 'Opacity', 'image-sizes' ), __( 'How see-through the mark is. Lower values are subtler.', 'image-sizes' ), slider( 'opacity', 5, 100, '%' ) ) } { row( __( 'Margin', 'image-sizes' ), __( 'Distance from the edge, or the gap between tiles.', 'image-sizes' ), slider( 'margin', 0, 200, 'px' ) ) } { row( __( 'Rotation', 'image-sizes' ), __( 'Tilt the mark from -90° to 90°; positive values turn it counter-clockwise. An angled mark is harder to crop out.', 'image-sizes' ), slider( 'rotation', -90, 90, '°' ) ) } { row( __( 'Image sizes to mark', 'image-sizes' ), __( 'Small thumbnails are usually better left clean.', 'image-sizes' ),
{ sizeLabels.map( ( [ name, label ] ) => ( ) ) }
, ) } { row( __( 'Image types', 'image-sizes' ), __( 'GIF and AVIF are never marked.', 'image-sizes' ),
{ mimeLabels.map( ( [ mime, label ] ) => ( ) ) }
, ) } { row( __( 'Skip small images', 'image-sizes' ), __( 'Images narrower or shorter than this are left alone.', 'image-sizes' ),
set( 'min_width', Number( e.target.value ) || 0 ) } className={ fieldClass } /> × set( 'min_height', Number( e.target.value ) || 0 ) } className={ fieldClass } /> px
, true, ) }
{ preview && (

{ sample ? __( 'Preview on your most recent image. Nothing has been changed. Approximate: Pro draws the real watermark on your server.', 'image-sizes' ) : __( 'Preview on a sample image. Nothing has been changed. Approximate: Pro draws the real watermark on your server.', 'image-sizes' ) }

) } { ! isProActive && ( setAlertOpen( false ) } /> ) }
); }