Browsed by
Tag: JavaScript

Allow / Disallow Gutenberg Toolbar Options

Allow / Disallow Gutenberg Toolbar Options

When coding a Gutenberg block plugin for WordPress and you want to remove some of the <RichText> toolbar options on that block. You can set “withoutInteractiveFormatting” and then list the allowedFormats.

<RichText
withoutInteractiveFormatting
allowedFormats={ [ 'core/bold', 'core/italic', 'core/textColor','core/strikethrough' ] }
value={ html }
/>

All Allowed Formats

  • core/bold (Bold Text)
  • core/code (Code Formating)
  • core/image (Inline Image)
  • core/italic (Italic Text)
  • core/link (Link Option)
  • core/strikethrough (Strikethrough Text)
  • core/underline (Underline Text)
  • core/textColor (Highlight)
  • core/subscript (sub characters e.g. 2)
  • core/superscript, (super characters e.g. 2nd)
  • core/keyboard, (keyboard)
  • core/unknown, (Clear Unknown Formating)
  • core/language, (Language Options)
Moodle 4 : Focus error fix (atto plugins)

Moodle 4 : Focus error fix (atto plugins)

I was hitting upon an error in one of my atto plugins whenever I clicked on a button in my dialogue form. It also appears when you use the Atto Styles button on the toolbar.

Uncaught RangeError: Maximum call stack size exceeded.

This appears to be a known bug see tracker

Using the information given by Jake Dallimore in the comments I added the following to my _displayDialogue function in my YUI module.

require(['core/local/aria/focuslock'], function(FocusLockManager) {
	// We don't want to trap focus.
	FocusLockManager.untrapFocus();
});

From the comments it seems as though what’s happening is we are ending up with two active focuses when there should just be one. The code seems to work by untrapping the focus when the dialogue is opened so that there is only one again

Reducing Motion on Websites

Reducing Motion on Websites

A few months ago I came across a media query for Reduced Motion in CSS. Some users may find some animated motions on websites can make them feel ill. These users can switch on the Reduced Motion setting in their operating system to try and limit these motions.

As web developers we can help by using CSS (and javaScript) to respect that setting.

CSS Media Query for Reduced Motion

Within the media query you can add settings to prevent transitions or animation from happening.

You can even add a wildcard rules so that any transitions or transforms and animations are prevented as I have in the code below.

/* Tone down the animation to avoid vestibular motion triggers like scaling or panning large objects. */
@media (prefers-reduced-motion: reduce) {
	html {
		scroll-behavior: auto; // override smooth scrolling
	}
	* {
		transition: none !important;
		animation: none !important;
		transform: none !Important;
	}
}

Pitfalls

You may find the wildcard approach may disable some transitions or transforms or animations you want to keep so you may have to add some overrides when using that approach or use the css :not notation to ignore certain classes or elements.

/* Tone down the animation to avoid vestibular motion triggers like scaling or panning large objects. */
@media (prefers-reduced-motion: reduce) {
	html {
		scroll-behavior: auto; // override smooth scrolling
	}
	* {
		transition: none !important;
		animation: none !important; 
	}
        // this ignores the elements and classes specified but targets everything else.
	*:not(path, text, g, polyline, rect, svg, clipPath, image, .svg, .mapholder, use) {
		transform: none !Important;
	}
}

JavaScript Respect Reduced Motion

For JavaScript you can use the following code to check if the user has reduced motion set. Then test the matches – if it returns true then the user has reduced motion set.

var mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');

if(mediaQuery.matches) {
	$('#'+defid).toggle();  // show and hide rather than...
} else {
	$('#'+defid).slideToggle();  // sliding up and down
}

Further Reading

https://www.smashingmagazine.com/2020/09/design-reduced-motion-sensitivities/
https://alistapart.com/article/accessibility-for-vestibular/

Building An Atto Plugin (WIP)

Building An Atto Plugin (WIP)

My notes from working out how to build a plugin for Moodle’s Atto Editor.
(This post is not entirely finished – working out the dialogs)

Plugin Location

Atto Editor plugins can be found in /lib/editor/atto/plugins as defined here and listed here

Note an Atto Plugin is known as a sub-plugin rather than a full plugin although it follows a similar format.

Required Files

  • Version.php
  • /lang/en/atto_pluginname.php
  • /yui/src/button/

Version.php

defined('MOODLE_INTERNAL') || die();

$plugin->version   = 2022030700;        // The current plugin version (Date: YYYYMMDDXX).
$plugin->requires  = 2020061511;        // Requires this Moodle version.
$plugin->component = 'atto_pluginname';  // Full name of the plugin (used for diagnostics). 

Current Moodle version can be found in Moodle’s own version.php file.

The component name is {plugin_type}_{plugin_name}
In this case the plugin type is “atto”

Language String File

This will contain all the translatable language strings for this plugin.
It should follow the frankenstyle naming convention of {plugin_type}_{plugin_name}.php i.e. the component name set in version.php

It should at least contain the name of the plugin in there.

$string['pluginname'] = 'Plugin Name';

YUI src button module

This is currently required but looking at the documentation it looks like Moodle are planning on replacing YUI with something else in the future. I worked through the documentation and experimented with it and have recorded more information below.

YUI Modules

Moodle used to use shifter to compile the YUI modules in the past. According to documentation it currently uses Grunt to compile the JS now but the documentation isn’t fully up to date on how this works in practise. I found much more information on how to use shifter so started off with this and will work on shifting to Grunt.

Basic File Structure

  • /build
    • This is where the compiled files get stored this is what Moodle actually loads.
  • /src
    • /button
      • /js
        • button.js
          (contents / working code for the module)
      • /meta
        • button.json
          (list of dependencies for the module)
      • build.json
        (essentially instructions for shifter or grunt on how to build the final js file)

Example build.json

{
  "name": "moodle-atto_pluginname-button",
  "builds": {
    "moodle-atto_pluginname-button": {
      "jsfiles": [
        "button.js"
      ]
    }
  }
}

Note the name “moodle-atto_pluginname-button” in “Builds” indicates the name of the folder it compiles to. The “Name” indicates the name of the javascript files after compiling.

Example /meta/button.json

{
    "moodle-atto_boostrap4moodle-button": {
        "requires": [
            "moodle-editor_atto-plugin"
        ]
    }
}

Example /js/button.js

Moodle Documentation has a list of examples for this file. Update the examples to use your component and plugin name. e.g. in the atto_strike example you would replace “atto_strike” with “atto_pluginname”

Compiling with Shifter

This is the old way of compiling the YUI module and reading through the threads on it, YUI will eventually be replaced but that may be sometime away yet due to the complexity. I’m going to take a look at compiling with Grunt as well but found more information on shifter so looked at it initially as a temporary way of compiling the YUI module.

To use shifter you need to have node.js installed then install shifter.

npm -g install shifter

Once installed navigate to your YUI /src folder and run the shifter watch command.

 cd ../yui/src/
 shifter --watch

Any change you make in button.js after that will cause the compiler to recompile all the files into the build folder.

Button Icon Locations

Icons need to be stored in /pix/filename within the plugin. SVG is supported 😊
They can then be referenced in button.js when creating a button – example below:

		this.addToolbarMenu({
			icon: 'e/bootstrap', 
			iconComponent: 'atto_pluginname',
			title: 'pluginname',
			globalItemConfig: {
				callback: this._changeStyle
			},
			items: items
		});

The icon name “e/bootstrap” is referring to an image stored at /pix/e/bootstrap.svg inside the plugin.
The iconComponent name is the plugin component name so it know to look in /lib/editor/atto/plugins/atto_pluginname
The title “pluginname” is reference to a language string in /lang/en/atto_pluginname.php

Experimenting with Button.js

I’ve added a few examples of what you can do in the YUI module. I learned a lot of this looking at the code for Core Moodle Atto plugins. A good one to check out is the atto_table plugin.

Using Language Strings in Button.js

You can reference language strings in your YUI module with the following reference:

M.util.get_string("stringReference", component)

Replace “stringReference” with the name of your string located in /lang/en/atto_pluginname.php

The variable component should be declared somewhere in the code and be the component name of your plugin e.g. atto_pluginname

The string should also be listed in the lib.php file for the plugin in the function atto_pluginname_strings_for_js (replace atto_pluginname with your plugin component name).

function atto_pluginname_strings_for_js() {
    global $PAGE;

    $PAGE->requires->strings_for_js(array('stringReference'),
                                    'atto_pluginname');
}

Reference : https://docs.moodle.org/dev/Atto#Atto_subplugin_Php_API

If using mustache templates you can also reference these strings using the following reference and passing the variable via javaScript

{{get_string "stringReference" component}}
var component = 'atto_pluginname',
EXAMPLETEMPLATE = '<div>{{get_string "stringReference" component}}</div>';

You can then use the below code to convert the mustache strings in the example template to the Language strings stored in your plugin language file ( /lang/en/atto_pluginname.php ). Remember to pass the component name (usually stored in a variable) and to reference the string in the plugin’s lib.php file otherwise it won’t work!

/* This example function can pass form content to a dialogue box in Atto */
_getDialogueContent: function() {
		
		// this will be the form template.
		var finaltemplate = Y.Handlebars.compile(EXAMPLETEMPLATE);
		
		// insert variables into the template
		var content = Y.Node.create(finaltemplate({
			component: component
		}));
		
		return content;
	},

Inserting a Template into Content Example

	 /**
	 * Change the title to the specified style.
	 *
	 * @method _insertTemplate
	 * @param {EventFacade} e
	 * @param {string} template
	 * @private
	 */
	_insertTemplate: function(e, template) {
		this._currentSelection = this.get('host').getSelection();
		var host = this.get('host');
		
		// Focus on the last point.
		host.setSelection(this._currentSelection);
		
		// And add the template. e.g. <p>test</p>
		host.insertContentAtFocusPoint(template);
		
		// Mark as updated
		this.markUpdated();
	}

A callback that can be added to globalItemConfig: { } to insert a HTML template into the main content editor. This can be extended to fill in information from a dialog form.

Note: I think there is probably a way to compile the template passed in like there is for the form dialogue but I ended up writing a custom one for inserting into Editor Content because if I use the same method as form dialogue it goes wrong – I’m suspecting I don’t have a setting right somewhere but I need to look deeper into this!

/**
	 * Replace Mustache Placeholders with content
	 *
	 * @method _replaceMustache
	 * @param {string} template (html)
	 * @param {object} variables
	 * @private
	 */
	_replaceMustache: function(template, variables) {
		
		// holder for the final output.
		var finalHtml = template;
		
		// this next bit is only if we have variables to play with.
		if(typeof variables == "object" && Object.keys(variables).length !== 0) {
			
			// Loop through the object
			for (const [key, value] of Object.entries(variables)) {
				
				// find each mustache key and replace with the value.
				var regex = new RegExp(`{{${key}}}`,`g`);
				finalHtml = finalHtml.replace(regex, value);
				
			}
			
		}
		
		return finalHtml;
	},

Troubleshooting

A few things that tripped me up while working out how to get this to work!

Button not appearing

Check it has been set in Site Administration > Plugins > Text Editors > Atto HTML Editor -> Atto Toolbar Settings ( ../admin/settings.php?section=editorsettingsatto )

Add “pluginname = pluginname” to the bottom of Toolbar Config to make it show at the end of the toolbar. You can also insert it inside a group of icons if needed.
Note you should change “pluginname” to match your component name

Question for later : is it possible to add it by default or is that a bad idea and if so why?

Missing Strings?

If a string is called in the YUI js files it must be specified in the language strings file in order to show. Otherwise you will get errors!

It also may need to be specified in lib.php in the atto_pluginname_strings_for_js() function.

function atto_pluginname_strings_for_js() {
    global $PAGE;

    $PAGE->requires->strings_for_js(array('accordion',
										  'pluginname',
										  'h3',
                                          'h4',
                                          'h5',
                                          'pre',
                                          'p'),
                                    'atto_pluginname');
}

Missing YUI component?

This seems very obvious in hindsight but although the documentation only mentions requiring yui/src/button.js you actually need javascript files in /yui/build/moodle-atto_pluginname-button/ to exist for example : moodle-atto_pluginname-button.js as well for this to work. Otherwise it just won’t load and you’ll get a missing module message in dev tools console. These files get automatically created when you compile with shifter or (presumably) Grunt.

YUI module not compiling

One of those silly obvious ones but make sure any custom functions you add are separated by a comma!

Useful Documentation

https://docs.moodle.org/dev/Atto
https://docs.moodle.org/dev/Frankenstyle
https://docs.moodle.org/dev/YUI/Modules
https://docs.moodle.org/dev/YUI/Shifter
https://docs.moodle.org/dev/Grunt

YouTube OpenDay Live Feed

YouTube OpenDay Live Feed

Due to the unprecedented circumstances this year the College had to host their Open Day virtually.

We wanted to have a live feed of heads of departments answering questions which we got during the feed and beforehand. Because we wanted this front and centre we had to look into a way of embedding the feed into our dedicated Open Day webpage.

After looking around online I came across this interesting discussion on embedding Live YouTube Feeds and used it as a basis to create my own class.

https://stackoverflow.com/questions/44354421/how-to-embed-youtube-live-chat-with-url-permanent

<?php 
/*****************************************************************
	Class : LIVE EVENT SCHEDULER 
	
	This class handles the live event schedule and loads the 
	youtube live feed if an event is due.
	
	FUNCTIONS
	---------------------------------------------------------
	1. showAll()
		show all scheduled events as cards below the main feed
	2. getNextEvent()
		get an array of the next event's details
	3. showAllUpcoming()
		display a mini feed of all the upcoming events.
	4. showAllPast()
	5. getLiveChatURL()
	6. getLiveVideoID($channelId)
	7. fetchVideoFeeds()
	
*****************************************************************/
class Schedule {
	
	public $schedule = array(
	    array(
			'datetime'	=> '202006121100',
			'show'		=> false, // show in main schedule
			'subject'	=> 'Welcome Message',
			'speaker'	=> 'Principal & Vice Principal ',
			'img'		=> '',
			'vid'		=> '',
			'desc'		=> 'Welcome message from the Principal.',
			'link'		=> array( 
						array(	'link' => '', 						'text' => 'Find out more' )),
		),

	);
	
	// Properties
	public $date;
	public $time;
	public $speaker;
	public $subject;
	
	function __construct() {
		
	}
	
	/****************************************
	show all upcoming scheduled items. 
	These appear as cards below the main feed.
	
	@RETURNS
	$html (string) 	: Compiled HTML
	
	****************************************/
	public function showAll() {
		
                // Display code goes here.....
		
		return $html;
	}
	
	/*********************************************
		
		Get the next event
		This was used to help calculate when the next event was.
		
		@RETURNS
		$return (array)	: The event array that is next up.
		
	*********************************************/
	public function getNextEvent() {
		$now 	= date_create(); // get current date and time. 
		$compare 	= date_format($now,"YmdHis"); // formatted current date and time to use as a comparison
		$return 	= $this->schedule[0]; // The event array that is next up. (default as first)
		
		// loop through events and compare with formatted date. 
		foreach($this->schedule as $event) {
			
			if($compare < $event['datetime'].'00') {
				$return = $event;
				break;
			}
		}
		
		return $return; 
	} 
	
	/***************************************************
		Show all upcoming events as a mini feed
		
		@RETURNS
		$html (string) : Container for the compiled HTML
	***************************************************/
	public function showAllUpcoming() {

		// Display code goes here ....
		
		return $html;
	}
	
	/***********************************************
		Show all past events
		added so that past videos could be viewed.
		
		@RETURNS
		$html (string) : Compiled HTML of past events. 
		
	***********************************************/
	public function showAllPast() {
		// Display Code goes here....
		
		return $html;
	}
	
	/********************************************
		Fetch Live Chat URL from YouTube Channel
		This requires a YouTube Channel ID.
		
		It will try to fetch the current live video feed ID 
		present on that Youtube Channel. 
		If it can't find any Live Feeds will return an error. 
		If it can the live chat feed url is returned. 
	*********************************************/
	public function getLiveChatURL() {
			
		try {
		    $livevideoId = $this->getLiveVideoID('<< REPLACE WITH CHANNEL ID >>');
		
		    // Output the Chat URL
		    //echo "The Chat URL is https://www.youtube.com/live_chat?v=".$livevideoId;
		    return array( true, "https://www.youtube.com/live_chat?v=".$livevideoId);
		} catch(Exception $e) {
			
			
		    // Echo the generated error
		    return array( false, "1. ERROR: ".$e->getMessage());
		}
	}
	
	// The method which finds the video ID
	public function getLiveVideoID($channelId)
	{
	    $livevideoId = null;
	
	    // Fetch the livestream page
	    if($data = file_get_contents('https://www.youtube.com/embed/live_stream?channel='.$channelId))
	    {
	        // Find the video ID in there
	        if(preg_match('/\'VIDEO_ID\': \"(.*?)\"/', $data, $matches))
	            $videoId = $matches[1];
	        else
	            throw new Exception('2. Couldn\'t find video ID');
	    }
	    else
	        throw new Exception('3. Couldn\'t fetch data');
	
	    return $videoId;
	}
	
	// AJax function.
	public function fetchVideoFeeds() {
		$html = '';
		$result = $this->getLiveChatURL();
		
		$html .= '<div class="col-xs-12 col-lg-8">';
					//$html .= '<pre>Dev Note : We are testing the live stream right now</pre><iframe style="width: 100%; height: 411px;" src="https://www.youtube.com/embed/live_stream?channel=CHANNEL_ID_HERE&autoplay=1&rel=0&showinfo=0&cc_load_policy=1" frameborder="0" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" allowfullscreen autoplay></iframe>';

					if($result[0] == true) {
						$html .= '<iframe title="VIDEO FEED TITLE" aria-label="VIDEO ARIA LABEL" style="width: 100%; height: 411px;" src="https://www.youtube.com/embed/live_stream?channel=CHANNEL_ID_HERE&autoplay=1&rel=0&showinfo=0&cc_load_policy=1" frameborder="0" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" allowfullscreen autoplay></iframe>';
					} else {
						$html .= '<iframe title="VIDEO TITLE" aria-label="VIDEO ARIA LABEL" style="width: 100%; height: 411px;" src="https://www.youtube-nocookie.com/embed/ZvzgjA5hhdo?rel=0&autoplay=0&rel=0&showinfo=0&cc_load_policy=1" frameborder="0" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>';
					} 
				$html .= '</div>
				<div class="col-xs-12 col-lg-4">';
				
					if($result[0] == true) {
						$html .= '<iframe src="'.$result[1].'&embed_domain=www.YOURDOMAIN.ac.uk" style="width: 100%; height: 411px" frameborder="0"></iframe>';
					} else {
						$html .= '<div class="nofeed">';
							$html .= '<h2 class="arrow">Upcoming Events </h2>';
							$nextEvent = $this->getNextEvent();
							$datetime = date_create($nextEvent['datetime']);
							$today = date_create();
							$dateDiff = date_diff($today,$datetime);
							
							$html .= '<div class="content">';
								$html .= '<div class="countdown">';
									$html .= '<div style="display: none;">'.($dateDiff->d > 0 ? $dateDiff->d.'d ' : '').(($dateDiff->d > 0 || $dateDiff->h) > 0 ? $dateDiff->h.'h ' : ''). (($dateDiff->h || $dateDiff->i > 0) ? $dateDiff->i.'m ' : '').$dateDiff->s.'s</div>';
									$html .= '<h6>The next event will be in <time class="counter" datetime="'.date_format($datetime,'Y-m-d\TH:i:s\Z').'" data-now="'.date_format($today, 'Y-m-d\TH:i:s\Z').'">'.($dateDiff->d > 0 ? $dateDiff->d.'d ' : '').(($dateDiff->d > 0 || $dateDiff->h) > 0 ? $dateDiff->h.'h ' : ''). (($dateDiff->h || $dateDiff->i > 0) ? $dateDiff->i.'m ' : '').$dateDiff->s.'s</time></h6>';
									$html .= $this->showAllUpcoming(); 
																		
								$html .= '</div>'; 
								
							$html .= '</div>';
						$html .= '</div>';
					} 
				$html .= '</div>';
				
		return $html;
	}
}

// init
$schedule = new Schedule(); 
?>

(I’ve taken out some of the irrelevant display bits).

The Schedule

For this example I have the schedule as an array but really it would be better to use a database and add some fetch from database functions to the class.

showAll(), showAllUpcoming(),showAllPast()

These functions just displayed all the upcoming events below the feed and aren’t important for this post.

getNextEvent()

We had a little countdown running on the page to the next event this function was just to help with that countdown and not important for this post.

getLiveChatURL()

This function tries to fetch the Live Chat url if it fails it will return an error message.

$livevideoId = $this->getLiveVideoID('PASTE CHANNEL URL HERE');

To get the Live Chat URL we also need the Live Video ID. This is fetched via the next function getLiveVideoID() . It requires the Channel ID to get this.

Getting the Channel ID

It’s actually fairly straightforward to get the Channel ID. You can either get this from your channel settings or you can simply go to your channel and the ID will display at the end of your URL.

Screenshot of YouTube Channel URL - the ID is the string of characters after /channel/
the ID is the string of characters after /channel/

Note About YouTube Requirements

Please note there are a few requirements regarding embedding Live Video Feeds.

  1. To stream direct to Youtube feeds must be public and allow embedding for the script to work.
  2. You need a certain number of subscribers (1000+) and be eligible for the YouTube Partner Program to be able to embed youtube feeds on your websites directly.
  3. Had we known! Restream.io costs but you can go live on multiple platforms at once (Twitter/Twitch/Facebook/Instagram) and embed on any website you like. ( thanks Steve 🙂 )
return array( true, "https://www.youtube.com/live_chat?v=".$livevideoId);

The Live Chat Feed for any Live Video will follow the above format. Of course this might change in the future with YouTube updates but this works at time of writing.

/********************************************
		Fetch Live Chat URL from YouTube Channel
		This requires a YouTube Channel ID.
		
		It will try to fetch the current live video feed ID 
		present on that Youtube Channel. 
		If it can't find any Live Feeds will return an error. 
		If it can the live chat feed url is returned. 
	*********************************************/
	public function getLiveChatURL() {
			
		try {
		    $livevideoId = $this->getLiveVideoID('PASTE CHANNEL ID HERE');
		
		    // Output the Chat URL
		    //echo "The Chat URL is https://www.youtube.com/live_chat?v=".$livevideoId;
		    return array( true, "https://www.youtube.com/live_chat?v=".$livevideoId);
		} catch(Exception $e) {
			
			
		    // Echo the generated error
		    return array( false, "1. ERROR: ".$e->getMessage());
		}
	}

getLiveVideoID($channelId)

This function gets the ID of the Live Video using the provided Channel ID.

if($data = file_get_contents('https://www.youtube.com/embed/live_stream?channel='.$channelId))

Currently the Stream url will follow the above format. Of course this might change in the future with YouTube updated but this works at time of writing.

if(preg_match('/\'VIDEO_ID\': \"(.*?)\"/', $data, $matches))
	$videoId = $matches[1];
else
	 throw new Exception('2. Couldn\'t find video ID');

The script then searches the returned file contents for the ‘VIDEO_ID’ variable which is printed in the javaScript on that url.

^ There may be another way of doing this via YouTube’s Javascript Api but this does work.

// The method which finds the video ID
	public function getLiveVideoID($channelId)
	{
	    $livevideoId = null;
	
	    // Fetch the livestream page
	    if($data = file_get_contents('https://www.youtube.com/embed/live_stream?channel='.$channelId))
	    {
	        // Find the video ID in there
	        if(preg_match('/\'VIDEO_ID\': \"(.*?)\"/', $data, $matches))
	            $videoId = $matches[1];
	        else
	            throw new Exception('2. Couldn\'t find video ID');
	    }
	    else
	        throw new Exception('3. Couldn\'t fetch data');
	
	    return $videoId;
	}

fetchVideoFeeds()

Finally I added a function to display the final feed or schedule if there was no live feed. This can also be requested via Ajax to update the feed automatically once the countdown has been reached.

$result = $this->getLiveChatURL();

First, this function gets the current Live Chat result from the above function. If there’s no result from this e.g. an error the script instead shows an advert for the college and a schedule of upcoming Live Events.

Screenshot of the alternative video and schedule of live events.
Screenshot of the upcoming Events Schedule.

If there is a result the script replaces the video advert with the live feed…

if($result[0] == true) {
	$html .= '<iframe title="VIDEO TITLE HERE" aria-label="VIDEO ARIA LABEL" style="width: 100%; height: 411px;" src="https://www.youtube.com/embed/live_stream?channel=CHANNEL_ID_HERE&autoplay=1&rel=0&showinfo=0&cc_load_policy=1" frameborder="0" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" allowfullscreen autoplay></iframe>';
}

Note : I should probably update this so that the Live Stream url is inserted by the schedule class.

if($result[0] == true) {
	$html .= '<iframe src="'.$result[1].'&embed_domain=www.dumgal.ac.uk" style="width: 100%; height: 411px" frameborder="0"></iframe>';
}

It also adds in the Live Chat embed. Note the &embed_domain variable. This should be set to your websites url otherwise the video will not show.

$nextEvent = $this->getNextEvent();
							$datetime = date_create($nextEvent['datetime']);
							$today = date_create();
							$dateDiff = date_diff($today,$datetime);
							
							$html .= '<div class="content">';
								$html .= '<div class="countdown">';
									$html .= '<div style="display: none;">'.($dateDiff->d > 0 ? $dateDiff->d.'d ' : '').(($dateDiff->d > 0 || $dateDiff->h) > 0 ? $dateDiff->h.'h ' : ''). (($dateDiff->h || $dateDiff->i > 0) ? $dateDiff->i.'m ' : '').$dateDiff->s.'s</div>';
									$html .= '<h6>The next event will be in <time class="counter" datetime="'.date_format($datetime,'Y-m-d\TH:i:s\Z').'" data-now="'.date_format($today, 'Y-m-d\TH:i:s\Z').'">'.($dateDiff->d > 0 ? $dateDiff->d.'d ' : '').(($dateDiff->d > 0 || $dateDiff->h) > 0 ? $dateDiff->h.'h ' : ''). (($dateDiff->h || $dateDiff->i > 0) ? $dateDiff->i.'m ' : '').$dateDiff->s.'s</time></h6>';
									
									$html .= $this->showAllUpcoming(); 

I also added in a little countdown which is updated by javaScript.

All this html is then returned by the function.

Initialising the Class

// init
$schedule = new Schedule(); 

Final part of the class.php file it creates a new instance of the class.

The Open Day Page

On the open day page where the feed is to be displayed I included the new class file.

<?php 
			
// Include the Event Scheduler Class. (handles the schedules and live video links)
include('classes/class_schedule.php');
	
?>	

And added in an area for the Live Feed to go…

<!-- LIVE VIDEO FEEDS & CHAT -->
<a class="pastevents" href="#eventspast" title="View Past Events" aria-label="View Past Events">Watch Past Events</a>
<div id="videofeed" class="row videofeeds">
	<?php echo $schedule->fetchVideoFeeds(); ?>
</div>
<!-- END OF LIVE VIDEO FEEDS AND CHAT -->

JavaScript Countdown

I also added in a little bit of javaScript that updates the countdown and reloads the feed area once the countdown hits 0.

jQuery(document).ready( function($) {
	
	// Set the date we're counting down to
	var nextEventDate = $('.counter').attr('datetime');
	var today = $('.counter').data('now');
	var countDownDate = new Date(nextEventDate).getTime(); 
	
	// Update the count down every 1 second
	var x = setInterval(function() {
	
	  // Get today's date and time
	  var today = new Date();
	  // DST
	  today.setHours(today.getHours() + 1);
	  
	  var now = today.getTime();
	
	  // Find the distance between now and the count down date
	  var distance = countDownDate - now;
	
	  // Time calculations for days, hours, minutes and seconds
	  var days = Math.floor(distance / (1000 * 60 * 60 * 24));
	  var hours = Math.floor((distance % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
	  var minutes = Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60));
	  var seconds = Math.floor((distance % (1000 * 60)) / 1000);
	
	  // Display the result in the element with id="demo"
	  $('.counter').html((days > 0 ? days + "d " : '') + ((days > 0 || hours > 0) ? hours + "h " : '') + ((hours > 0 || minutes > 0) ? minutes + "m " : '') + seconds + "s ");
	
	  // If the count down is finished, write some text
	  if (distance < 0) {
	    clearInterval(x);
	    $('.counter').html('Now....reloading in <span class="reloadcount">3</span>');
			    
		// fetch the feed...
		$.get("fetch.php", function(data, status){
			$('.videofeeds').html(data); //.animate({'opacity' : '1'}); // fade back in. 
		});  
	  }
	}, 1000);    //testing    
	
});

Notes about Feed Refresh

There is roughly a minute delay between when you start your Live Feed and it appearing on YouTube so it’s a good idea to have a buffer time and start the Live Feed before the event is scheduled. This also mitigates any issues with server times being slightly out of sync.

We had a 15 minute buffer period before each event.

fetch.php

The fetch.php file just includes the schedule class and echos out the feed.

<?php 		
// Include the Event Scheduler Class. (handles the schedules and live video links)
include('classes/class_schedule.php');

echo $schedule->fetchVideoFeeds();

?>
Making a plugin for XD

Making a plugin for XD

Development in Progress

I am currently learning and updating this post as I go along so it may change over the next few weeks 🙂 .

XD has this cool little area for adding in plugins to your install. They can be anything from inserting useful elements like calendars, to testing your content is accessible for those with colourblindness.

Then I suddenly noticed a little tab called “Development” under plugins:

So as a challenge I’ve decided to have a go at making my own plugin for XD and make a blog post about it.

Notes

XD plugins use JSON and JavaScript – so not too much of a leap!

A basic XD plugin consists of a manifest.json file and a main.js file. You can also add images and other files but those two files are all that is really needed.

Screenshot of a simple XD plugin folder and the two files required.
A simple XD Plugin

There is also a Quick Start Tutorial for setting up with React (I don’t see much point in reposting their steps 🙂 )

mkdir xdpluginreact
cd xdpluginreact
npm init -y
npm install webpack webpack-cli --save-dev

I also then split the files so there was a distribution folder as I don’t want to include development files in the final version that gets moved to Adobe XD.

I’ve documented that in this post here. Following those steps you end up with:

Once the plugin is finished it is the example-plugin folder that is copied into the Adobe XD develop folder.

Finally I can update the package.json file.
Note : “private”: true means that it can’t be published by accident 🙂

XD Brand / Design Guidelines can be found here : https://github.com/AdobeXD/plugin-design-assets/

Debugging

There is a console you can use for catching error messages.
Go to Plugins -> Development -> Developer Console

Setting a Task / Brief

I generally learn more when I’m trying to do something. So now I’ve got the Quick Start Tutorial working and gone through some of the introduction tutorials + the Quick Start React Tutorial. I’m going to set myself a simple task to learn more.

I’m going to create a Lorem Ipsum generator (I know these already exist but I thought I would start with something that appeared to be simple then expand on it. Plus once I’ve got it working I can try bringing in some more fun Lorem Ipsum Generators rather than the boring standard one).

Requirements

  • Insert Lorem Ipsum into a rectangle
  • Fetch the Lorem Ipsum from a file
  • Get the last selected font settings as default
  • Allow user to change default font settings.

Challenge Requirements

  • Allow user to choose type of Lorem Ipsum.
  • Allow user to change the settings on how much lorem ipsum they need, paragraphs, lines, words e.t.c.
  • Allow User to add their own Lorem Ipsum.

Setting up the Project

I followed the Quick Start Panel UI tutorial to setup the new plugin.

Insert Lorem Ipsum into a rectangle

I modified the manifest.json to include my own information and to change the entry point information.

    "uiEntryPoints": [
        {
            "type": "panel",
            "label": "Insert Lorem Ipsum",
            "panelId": "insertLoremIpsum"
        }
    ]

I then changed the first line of the main.js to include the text scenegraph element.

const { Rectangle, Text, Color } = require("scenegraph");

I replaced the “enlargeRectangle” function in the create function with my own function called “replaceWithLoremIpsum”

 function replaceWithLoremIpsum() {
    const { editDocument } = require("application"); // [3]
    let coords;

    // Edit the Document...
    editDocument({ editLabel: "Insert Lorem Ipsum." }, function(selection) {
        
        // Currently Selected Rectangle. 
        const selectedRectangle = selection.items[0]; // [7]
      
        // Get the Rectangle's Coordinates.
        coords = selectedRectangle.boundsInParent;
      
        // Create a new Text Node
        const node = new Text();
        
        // Text Settings
        node.text = "Lorem Ipsum will go here...";
        node.fill = new Color("#1f1f1f");
        node.fontSize = 50;
        
        // Match the selected Rectangles area and location.
        node.areaBox = {width: coords.width, height: coords.height};
        node.translation = {x: coords.x, y: coords.y }; 
        
        // add the new text box.       
        selection.insertionParent.addChild(node);
        
        // Hide the selected rectangle (keeping it in case the user still needs it. 
        selectedRectangle.visible = false;
        
    });
  }

This function takes the currently selected rectangle,
records the coordinates and dimensions of the rectangle,
creates a new Text node,
Adds some text to the new text node,
Sets some text settings,
Changes the Text node to a fixed width and height that matches the selected rectangle (otherwise it just sets an auto height & width),
Moves the Text area to the same coordinates as the selected rectangle,
Add the new text node to the artboard,
Hides the selected rectangle from the user view.

I decided to keep the rectangle rather than removing it incase the user still needs it. They can always delete it if they prefer.

I then updated the querySelector to use my new function rather than the enlargeRectangle function.

panel.querySelector("form").addEventListener("submit", replaceWithLoremIpsum); // [11]

I also updated the module.exports to match the manifest.json file.

module.exports = {
  panels: {
    insertLoremIpsum: {
      show,
      update
    }
  }
};

Fetch the Text from a file

I want to store the sample text seperately from the code. This is partly to keep the code a bit neater.

I’m using the file reading tutorial as a guide : https://adobexdplatform.com/plugin-docs/tutorials/how-to-read-a-file/
and the File reference : https://adobexdplatform.com/plugin-docs/reference/uxp/storage-index.html
and the documentation about using require to include other files : https://adobexdplatform.com/plugin-docs/reference/javascript/javascript-support.html#can-i-use-require

I’ve created a text folder in the plugin directory with a file called latin.json that has the Lorem Ipsum reference text in it.

{
    "content" : "Lorem ipsum dolor sit amet, conse",
}

I added a variable near the top to act as a container for the selected lorem ipsum.

let textToAdd = "Lorem Ipsum will go here..."; 		 // Container for the Lorem Ipsum text to add...

I added a new function to the create function to handle the generation of the Lorem Ipsum text. I’ve written it in this way as I’m intending to expand on it later.

/**************************************************************
FUNCTION TO FETCH LOREM IPSUM
**************************************************************/
function fetchLoremIpsum() {
		
	const noScentences = 7;	// Total number of scentences to include in a paragraph
	const noParagraphs = 2;	// Total number of paragraphs to include
		
	// Fetch the lorem ipsum.
	const latin = require("./text/latin.json"); 
		
	// Split text into scentences		
	let scentences = latin.content.split('.');
		
	// Clear any existing text
	textToAdd = '';
		
	// keep adding scentences so long as we have enough scentences per paragraph.
	for(let i = 0; i < (noScentences * noParagraphs); i++) {
		textToAdd += scentences[i]+'.';
			
		// if we've reached the total number of scentences per paragraph 
		if(i > 0 && (i % noScentences) === 0) {
			textToAdd += '\n';	// add a line break.
		}
	}
		
}

Then I change the new text node to use the textToAdd variable rather than a set string.

node.text = textToAdd;

Get the last selected font settings as default

I initially wasn’t sure if this would be possible but I thought there probably would be a way to select existing nodes in the document without the user indicating one.

I had a look through the tutorials and felt that the tutorial on changing the colours of rectangles would probably give me some clues on what to do ->
https://adobexdplatform.com/plugin-docs/tutorials/how-to-work-with-scenenodelist/.

I added a new container variable for the last submitted text node settings at the top of main.js

let lastSubmittedText;  // Container for Last Submitted Text Node

I then created a new function in the create function called findTextSettings.

    /**************************************************************
        FUNCTION TO FIND PREVIOUS TEXT SETTINGS
        Parameters that are passed down are Contextual Arguments
        ( https://adobexdplatform.com/plugin-docs/reference/structure/handlers.html#contextual-arguments )
    **************************************************************/  
    function findTextSettings(selection, documentRoot) {
        
        // for each node in the document....
        documentRoot.children.forEach(node => {
            
            // Check if node is a child of Artboard
            if(node instanceof Artboard) {
                let artboard = node;
                
                // Filter out the child nodes that are Text nodes and set as text
                let text = artboard.children.filter(artboardChild => {
                    return artboardChild instanceof Text;
                });
                
                let lastText = text[(text.length - 1)];
                
                // Set last submitted Text settings. 
                lastSubmittedText = {
                    fill : lastText.fill,
                    fontSize : lastText.fontSize
                };
                
            }
            
        });
        
    }

I modified the example function for selecting rectangles in the artboard to select text nodes instead and to update the container variable with the new data.

Note about Contextual Arguments:
https://adobexdplatform.com/plugin-docs/reference/structure/handlers.html#contextual-arguments

The documentRoot parameter needs to be added to the editDcoument function for this to be passed down.

// Edit the Document...
editDocument({ editLabel: "Insert Lorem Ipsum." }, function(selection,documentRoot) {

   findTextSettings(selection, documentRoot);
   // ....

}

I also added a call to update the last submitted text node.

I then modified the text settings of the new text mode to use the last submitted settings if they exist:

// Text Settings
node.text = textToAdd;
node.fill = new Color((typeof lastSubmittedText !== 'undefined'  ? lastSubmittedText.fill : "#1f1f1f"));
node.fontSize = (typeof lastSubmittedText !== 'undefined' ? lastSubmittedText.fontSize : 16);

There are more settings than this so I am now going through and adding all the rest of the settings.

Allow user to change default font settings.

Although fetching the last selected settings is useful as a fallback, I think the user should be able to change the text settings before they import the Lorem Ipsum.

This means adding in some inputs to the main form.

I would like to add a list of system fonts to choose from as well but after some research it appears this is not currently possible. But it is something they may add in this year so I will revisit once it is possible : https://forums.adobexdplatform.com/t/api-to-list-which-fonts-are-available-detect-if-text-has-missing-font/629/4

This is probably the case with fontStyles as well but I’ll add a couple of common ones to the list to choose from.

Once I had modified the form to include the new options I then modified the find TextSettings() function to fetch the user settings.

const userFontSize = Number(document.querySelector("#setFontSize").value);
const userCharSpacing = Number(document.querySelector("#setCharSpacing").value);
const userLineSpacing = Number(document.querySelector("#setLineSpacing").value);
const userParaSpacing = Number(document.querySelector("#setParaSpacing").value);
	    
const userFontStyle = String(document.querySelector("#setFontStyle").value);
const userTextTransform = String(document.querySelector("#setTextTransform").value);
const userTextAlign = String(document.querySelector("#setTextAlign").value);
	    
const userUnderline = Boolean(document.querySelector("input[name='setUnderline']").value);
const userStrike = Boolean(document.querySelector("input[name='setStrike']").value);

Then it was just a case of checking the values and overriding the text settings where the user had set something:

if(userFontSize != '') {
    lastSubmittedText.fontSize = userFontSize;
}
if(userCharSpacing != '') {
    lastSubmittedText.charSpacing = userCharSpacing;
}
if(userLineSpacing != '') {
    lastSubmittedText.lineSpacing = userLineSpacing;
}
if(userParaSpacing != '') {
    lastSubmittedText.paragraphSpacing = userParaSpacing;
}

if(userFontStyle != '' && userFontStyle != 'Choose Font Style') {
    lastSubmittedText.fontStyle = userFontStyle;
}
if(userTextTransform != '' && userTextTransform != 'Choose Text Transform') {
    lastSubmittedText.textTransform = userTextTransform;
}
if(userTextAlign != '' && userTextAlign != 'Choose Text Alignment') {
    lastSubmittedText.textAlign = userTextAlign;
}

if(typeof userUnderline !== "undefined") {
    lastSubmittedText.underline = userUnderline;
}
if(typeof userStrike !== "undefined") {
    lastSubmittedText.strikethrough = userStrike;
}

TroubleShooting / Errors

“missing a name for object member”

Check there isn’t a comma at the end of the JSON (oops)

“Command “JSX” Not Found”

$ sudo npm install -g jsx

“Plugin TypeError: Assignment to constant variable.”

If you define a variable as const you cant change its value. To resolve it just define it by let and try. (oops).

more to follow….

References

https://adobexdplatform.com/plugin-docs/
https://adobexdplatform.com/plugin-docs/tutorials/quick-start/
https://adobexdplatform.com/plugin-docs/tutorials/
https://github.com/AdobeXD/Plugin-Samples

WP wp-media.js – Bulk Upload

WP wp-media.js – Bulk Upload

Please Note:

This post is a work in progress and is mainly documentation on things I have discovered / learned while working on a project. There are probably better ways of doing some of this.

Some uploader settings

  • dropzone :
    This is the container element where the user will be dropping files. This triggers the drop files here when files are dragged over the element.
  • container :
    To confirm
  • browser :
    To confirm
  • error :
    Error handler e.g. function(e){ }
  • success :
    This fires when a file has been uploaded (it fires for each file that has been added).
  • added :
    This fires as each file is added to the drop zone (it fires for each file added).
  • progress :
    This fires while the file is being uploaded, (it fires for each file that has been added).
  • complete :
    I’ve never seen this fire so far so I’m not sure what triggers it.
  • refresh :
    I think this fires if the instance is refreshed but I haven’t confirmed.
uploader: {
	dropzone:  $('.dragzone'), // The dropzone container 
	container: $('.dragzone'),
	browser:   'browse_button',
	error: function(e) {	// if there is an error during upload this should fire. 

		console.log(e);
		console.log('error');

	},
	success: function(e) {	// once a file is uploaded this should fire. 

		//console.log(e);
		//console.log('success');
							
		fileTemplate(e);
							
		noItemsUp++; 	// update the number of items uploaded. 
		prevProgress = Math.floor(progressBar);
		progressBar = Math.floor(percent * noItemsUp);
							
		animateProgress();
							
		$('#importFiles').prop('disabled', false);
							
	},
	added: function(e) {	// this fires when a file is added. 
							
		noItems++; 	// add 1 to the number of items being uploaded
		percent		= 100 / noItems;	// work out the new percentage per item...
		prevProgress 	= progressBar;	// This is needed so the animated counter can work
		progressBar 	= percent * noItemsUp;		// This is the new progress the bar needs to move to. 
							
		animateProgress();
							
	},
	progress: function(e) {	// this fires while the file is being uploaded. 
		//console.log(e);
		//console.log('progress');
	},
	complete: function(e) { // ?
		//console.log(e);
		//console.log('complete');
	},
	refresh: function(e) { // ? 
		//console.log(e);
		//console.log('refresh');
	}
}
WP wp.media js – Add a File

WP wp.media js – Add a File

A work in progress blog post for information I’ve discovered so far about hooking into the WordPress Media Library model windows.

The javascript api in WordPress allows you to create custom upload fields and drag and drop zones in plugin and admin pages.

I’ve included some of my working code below – it is a work in progress currently.

Table of Contents

Adding a File Upload Field

The task : create a single file upload field on the add / edit page of a custom post type within a WordPress plugin. The file should use the WordPress media library functionality to upload and select the file.

I’ve added some screenshots of the finished field. I’ve added the field into the “Add New Custom Post Type” form using a meta box.

Screenshot of a meta box containing custom fields including a file upload field.

When the “Add the File” button is clicked (or the input field) the WordPress media library modal should appear.

I’ve modified the parameters to change the title of the modal window and the text on the button.

Once a file has been selected and chosen the information in the file preview box is filled out:

The Code

To use the WordPress Media api you need to include the media library scripts so that your custom script can access its functionality. To do this you will need to create a function for “enqueue”-ing scripts to your WordPress admin. Mine is created as part of a class for my plugin:

// Set some constants
defined( 'FMPLUGINURL' ) or define( 'FMPLUGINURL', plugin_dir_url( __FILE__ ) );

class FileManager {

	// Initialise the plugin
	public static function init() {
		add_action('admin_enqueue_scripts',['FileManager','queue_assets']);
	}

	// Add your assets to the admin view.
	public static function queue_assets() {

		// Checks that it's in the admin view
		if(is_admin()) {
			wp_enqueue_media(); // WP Media API
			wp_register_script( 'fm_media_uploader', FMPLUGINURL.'assets/js/fm_media_uploader.js');        // Customised WP media Upload Script for this plugin. 
			wp_localize_script( 'fm_media_uploader', 'FMPLUGINURL', FMPLUGINURL );
			wp_enqueue_script('fm_media_uploader');
		}
	}
}

FileManager::init();

WordPress References

wp_enqueue_media(); // WP Media

The above code adds all the assets necessary for using the media JavaScript API.

The fm_media_uploader file is where I am adding all the custom JavaScript to hook into the media API.

fm_media_uploader.js

First we need to setup an instance of the Media Library Modal which I will be storing in the variable below.

// Set some required variables 
var mediaUploader;

We also will need a trigger so that the Media Library Modal will open when certain elements are clicked.

// Set some required variables 
var mediaUploader;
		
// Set the triggers
$('#mediaUpload, .file-field input, .file-options a').on("click", function(e) {
			
	// This just prevents any default functions from occuring e.g. stops a form from submitting.
	e.preventDefault();

});

The instance of the media uploader will be created once the trigger is clicked.

// Set the media uploader with the following parameters 
mediaUploader = wp.media.frames.file_frame = wp.media({
	title: 'Choose File',		// Title at the top of the Modal Window
	button: {						// Button Parameters
		text: 'Choose File',	// Button Text
	}, 
	multiple: false 			// Allow multiple file uploads?
});

This sets up an instance of the Media Library Modal while modifying the title of the window and the button parameter. There is also an option to upload multiple or single files. There may be more parameters but these are the ones I know about so far. I may add to this post (and my next one) as I learn more.

This is how the modal window will look once triggered.

Finally we will tell the modal window to open:

// open the Media Uploader. 
mediaUploader.open();

So once the Add File button is clicked the WordPress Media Library modal window will appear and allow you to select a file. However you may find two problems:

  1. You can’t open the same modal window a second time.
  2. Nothing happens to the file once it is selected.

Prevent Multiple Instances

The reason you might have problems clicking the button a second time is that it is trying to create multiple instances on the same variable. So we need to add some code so the creation part is skipped if the instance has already been created.

if (mediaUploader) {
				
	// open the media uploader. 
	mediaUploader.open();
	return;
			
}      

If an instance of the Media Library Modal already exists it simply opens to existing instance and skips the part tht creates the instance.

I think this part could be cleaner. It is something I’m planning on revisiting later.

Handling the Selected File

The following piece of code handles the selected file and should be added when the instance of the Media Library Modal is created but before it is opened.

I may rewrite this bit in the future, as I think it could be neater.

// When a file is selected in the modal window....
mediaUploader.on('select', function() {
			
	var attachment = mediaUploader.state().get('selection').first().toJSON(); 	// the selected file is added to the selection list
	var fileDescription = $('textarea[name="FMDescription"]').val(); 		// get current File Description
	var file = attachment.filename.split('.');												// get the filename and split into an array. 
				
	file.reverse();		// reverse the array to make it easier to get the file extension
				
	$('.file-type').text('.'+file[0]);                                    		// File Extension
	$('input[name="FMFile"]').val(attachment.url);          // File URL
	$('.file-header').text(attachment.title);                         	// File Title
				
	if(fileDescription == '') { 
		$('textarea[name="FMDescription"]').val(attachment.description); 
	} // Set File Description if nothing has been filled in yet...
				
	$('.upload-date').text(attachment.dateFormatted);            // File Upload Date Formatted (this will use WordPress's default setting)
	$('.file-size').text(attachment.filesizeHumanReadable);      // File Size 
	$('.file-options .edit').attr('href', attachment.editLink).text('Edit'); 
	$('input[name="FM_attachment_id"]').val(attachment.id);     // Attachment (file) ID 
				
	var date = $('#jquery-datepicker').val();
				
	if(date) {
		$('input[name="FMTitle"]').val(attachment.title+' '+date);
	}
			
});

This may have more code than you need as I’ve taken it from my own project.

First we have an event listener that runs a function when the ‘select’ event is triggered on the Media Library Modal instance.

mediaUploader.on('select', function() { });

Then we need to collect all the details of the file that has been selected. This is returned in JSON format.

var attachment = mediaUploader.state().get('selection').first().toJSON();

Here’s an example of what it returns:

id: 58
title: "Example PDF Test"
filename: "Example-PDF.pdf"
url: "https://www.example.co.uk/wp-content/uploads/2019/12/Example-PDF.pdf"
link: "https://www.example.co.uk/?attachment_id=58"
alt: ""
author: "1"
description: "Example File Description"
caption: ""
name: "example-pdf"
status: "inherit"
uploadedTo: 0
date: Fri Dec 13 2019 16:13:24 GMT+0000 (Greenwich Mean Time) {}
modified: Thu Jan 30 2020 10:52:08 GMT+0000 (Greenwich Mean Time) {}
menuOrder: 0
mime: "application/pdf"
type: "application"
subtype: "pdf"
icon: "https://www.example.co.uk/wp-includes/images/media/document.png"
dateFormatted: "13th December 2019"
editLink: "https://www.example.co.uk/wp-admin/post.php?post=58&action=edit"
meta: false
authorName: "Rebecca Rumble"
filesizeInBytes: 23632
filesizeHumanReadable: "23 KB"
context: ""
compat: {item: "", meta: ""}

The rest of the code is just me telling the script to fill in various fields with the file data to display it like below:

Handling Edit Mode

When the custom post type is reopened in edit mode; it should automatically have the file selected when the file field is clicked. However at present the script won’t let this happen. We need to add some additional code to make this happen.

var fileID = $('input[name="FM_attachment_id"').val();

First we grab the attachment ID
(I use a hidden field to store this in the Meta Box).

Then in both the open existing mediaUploader statement and the mediaUploader.on(‘open’, function()) we need to add the following:

if(fileID) {
	// select the file ID to show it as selected in the Media Library Modal. 
	mediaUploader.uploader.uploader.param( 'post_id', parseInt(fileID) );
	var selection = mediaUploader.state().get('selection');
	selection.add(wp.media.attachment(fileID));
}

It needs to be added twice because you might try and edit after you’ve added a file or when you are opening an existing custom post type. So it needs to check both in the creation of the instance and when an existing instance is opened.

I’m planning on working on this to try and make it neater and not repeat the same code twice but hey it works for now.

The Full Code

This is the current javaScript code in full…

// Set some required variables 
var mediaUploader;
		
// Set the triggers
$('#mediaUpload, .file-field input, .file-options a').on("click", function(e) {
			
	// This just prevents any default functions from occuring e.g. stops a form from submitting.
	e.preventDefault();
			
	// Fetch the value of the currently selected File - if nothing has been selected this will be empty
	var fileID = $('input[name="FM_attachment_id"').val();
			
	// If there is already a Media Library Modal only this block will run as it doesn't need to be initialised again. 
	if (mediaUploader) {
				
		// This should only be run if a file has already been selected
		if(fileID) {
					
			// when the media library modal is opened....
			mediaUploader.on('open', function() {
						
				// if there's a file ID
				if(fileID) {
					// select the file ID to show it as selected in the Media Library Modal. 
					mediaUploader.uploader.uploader.param( 'post_id', parseInt(fileID) );
					var selection = mediaUploader.state().get('selection');
					selection.add(wp.media.attachment(fileID));
				}
			});
					
		}
				
		// open the media uploader. 
		mediaUploader.open();
		return;
			
	}      
			
	// Set the media uploader with the followign parameters 
	mediaUploader = wp.media.frames.file_frame = wp.media({
		title: 'Choose File',		// Title at the top of the Modal Window
		button: {						// Button Parameters
			text: 'Choose File',	// Button Text
		}, 
		multiple: false 			// Allow multiple file uploads?
	});
			
	// When the Media library Modal is opened... 
	mediaUploader.on('open', function() {
		// if there is a file ID...
		if(fileID) {
			// select the file ID to show it as selected in the Media Library Modal. 
			mediaUploader.uploader.uploader.param( 'post_id', parseInt(fileID) );
			var selection = mediaUploader.state().get('selection');
			selection.add(wp.media.attachment(fileID));
		}
	});
		
	// When a file is selected in the modal window....
	mediaUploader.on('select', function() {
			
		var attachment = mediaUploader.state().get('selection').first().toJSON(); 	// the selected file is added to the selection list
		var fileDescription = $('textarea[name="FMDescription"]').val(); 		// get current File Description
		var file = attachment.filename.split('.');												// get the filename and split into an array. 
				
		file.reverse();		// reverse the array to make it easier to get the file extension
				
		$('.file-type').text('.'+file[0]);                                    		// File Extension
		$('input[name="FMFile"]').val(attachment.url);          // File URL
		$('.file-header').text(attachment.title);                         	// File Title
				
		if(fileDescription == '') { 
			$('textarea[name="FMDescription"]').val(attachment.description); 
		} // Set File Description if nothing has been filled in yet...
				
		$('.upload-date').text(attachment.dateFormatted);            // File Upload Date Formatted (this will use WordPress's default setting)
		$('.file-size').text(attachment.filesizeHumanReadable);      // File Size 
		$('.file-options .edit').attr('href', attachment.editLink).text('Edit'); 
		$('input[name="FM_attachment_id"]').val(attachment.id);     // Attachment (file) ID 
				
		var date = $('#jquery-datepicker').val();
				
		if(date) {
			$('input[name="FMTitle"]').val(attachment.title+' '+date);
		}
			
	});
			
	// open the Media Uploader. 
	mediaUploader.open();
			
});

Meta Box HTML

For reference this is the meta box HTML, it is also part of a PHP class.

<?php 
// display the contents of the metabox 
public static function html($post) {
        
	// https://developer.wordpress.org/reference/functions/get_terms/
	$terms      	= get_terms('file_category', array( 'hide_empty' => false ));
	$fileTerms  	= get_the_terms($post->ID,'file_category' );
	$fileCats   	= array();
            
	if(!empty($fileTerms)) {
		foreach($fileTerms as $fileTerm) {
			$fileCats[] = $fileTerm->term_id;
		}
	}
            
	$FMDate			= get_post_meta($post->ID,'FMDate',true);
	$dateObj		= datetime::createfromformat('Ymd',$FMDate);
	$FM_attachment_id	= get_post_meta($post->ID,'FM_attachment_id',true);
	$attachment		= array();
            
	if(!empty($FM_attachment_id)) {
            
		// https://developer.wordpress.org/reference/functions/wp_prepare_attachment_for_js/ 
		$attachment  = wp_prepare_attachment_for_js($FM_attachment_id);
		$fileExt     = explode('.',$attachment['filename']);
		$fileExt     = array_reverse($fileExt);
               
	}
?>
	<div class="adminform">
		<div class="row">
			<div class="m-all t-all d-all">
				<label for="FMTitle">
					<span class="label">Title: </span>
					<input type="text" name="FMTitle" value="<?php echo (!empty($post->post_title) ? $post->post_title : 'This will be automatically created once details have been entered'); ?>" readonly />
				</label>
			</div>
		</div>
		<div class="row">
			<div class="m-all t-all d-all file_field">
				<label for="FMFile">
					<span class="label">File: </span>
					<input type="hidden" name="FM_attachment_id" value="<?php echo (int) $FM_attachment_id; ?>">
					<input type="text" name="FMFile" value="<?php echo (!empty($attachment) ? $attachment['url'] : ''); ?>" placeholder="Click 'Add the File' to select or upload file" required />
				</label>
				<input type="button" id="mediaUpload" class="button-primary" value="Add the File" />
			</div>
		</div>
		<div class="row">
			<div class="m-all t-all d-all file_information">
				<div class="spacer"></div>
				<div class="file_wrapper">
					<div class="file-icon">
						<img src="<?php echo FMPLUGINURL.'/assets/img/046.svg'; ?>" alt="File icon" class="icon-color" />
						<span class="file-type"><?php echo (!empty($attachment) ? '.'.$fileExt[0] : 'none'); ?></span>
					</div>
					<div class="file-content">
						<div class="file-header"><?php echo (!empty($attachment) ? $attachment['title'] : ''); ?></div>
						<div class="upload-date"><?php echo (!empty($attachment) ? $attachment['dateFormatted'] : ''); ?></div>
						<div class="file-size"><?php echo (!empty($attachment) ? $attachment['filesizeHumanReadable'] : ''); ?></div>
						<div class="file-options"><?php echo (!empty($attachment) ? '<a href="'.$attachment['editLink'].'">Edit</a>' : '<a href="" class="edit"></a>'); ?></div>
					</div>
				</div>
			</div>
		</div>
		<div class="row flowwrap">
			<div class="m-all t-1of2 d-1of2">
				<label for="FMCat">
					<span class="label">File Category: </span>
					<select name="FMCat">
						<option>Select a File Category</option>
						<?php 
						if(!empty($terms)) {
							foreach($terms as $term) {
								echo '<option value="'.$term->term_id.'"'.(in_array($term->term_id,$fileCats) ? ' selected' : '').'>'.$term->name.'</option>';
							}
						} ?>
					<select>
                            
				</label>
			</div>
			<div class="m-all t-1of2 d-1of2">
				<label for="FMDate">
					<span class="label">Date: </span>
					<input type="text" id="jquery-datepicker" name="FMDate" value="<?php echo (!empty($dateObj) ? $dateObj->format('d/m/Y') : ''); ?>" placeholder="Click to add date" autocomplete="off" required />
				</label>
			</div>
		</div>
		<div class="row">
			<div class="m-all t-all d-all">
				<label for="FMDescription">
					<span class="label">Description: </span>
					<br />
					<textarea name="FMDescription"><?php echo $post->post_content; ?></textarea>
				</label>
			</div>
		</div>
	</div>
	<?php
            
}
?>

“Sorry. You cannot attach Files to this post”

I came across this odd bug. This only occurs if you try to upload a file before selecting a file. However if you select a file, then go back and try and upload a file it then works?

I spent a lot of time hunting for the solution but in the end I discovered a tutorial on wp.media which gave me a clue. https://code.tutsplus.com/series/getting-started-with-the-wordpress-media-uploader–cms-666

If I added the following to the mediaUploader it worked as it should, however this had the effect of losing the title option. I believe the title and button options only work on a custom frame.

mediaUploader = wp.media.frames.file_frame = wp.media({
    frame:    'post',
    state:    'insert',
    title: 'Choose File',
    button: {
        text: 'Choose File',
    },
    multiple:false,
});

I also had to change the select listener to ‘insert’

// When a file is selected in the modal window....
// mediaUploader.on('select', function() {
mediaUploader.on('insert', function() {

To be continued -> Bulk Upload via wp.media

Useful References

WordPress Codex wp.media

https://github.com/ericandrewlewis/wp-media-javascript-guide

https://wordpress.org/support/article/media-library-screen/

https://code.tutsplus.com/series/getting-started-with-the-wordpress-media-uploader–cms-666 – One of the best tutorials for Wp.media

CSS Pointer Events

CSS Pointer Events

This is maybe only needed in some edge cases, but you can use pointer-events to make psuedo elements (:after and :before) clickable via javascript.

Note: Preferable to use clickable elements if you can though!

My original code when I needed this as a work around.

SCSS Code

.add-chevron {
    // prevents clicking on element from working
    pointer-events: none; 

    // Necessary for allowing any clickable elements within the parent to work. 
    a, a:visited, button {
        pointer-events: all;
    }
    &:after {
        content: 'click me';
        display: block;
        height: 34px;
        width: 34px;
        // allows this pseudo element to be clickable
        pointer-events: all;
    }
}
CSS Tricks : pointer-events

https://caniuse.com/#feat=pointer-events