* @license http://www.opensource.org/licenses/mit-license.html MIT License * @link https://github.com/OleVik/grav-plugin-presentation */ namespace Grav\Plugin; use Grav\Common\Grav; use Grav\Common\Plugin; use Grav\Common\Utils; use Grav\Common\Uri; use Grav\Common\Inflector; use Grav\Common\Page\Page; use Grav\Common\Page\Pages; use Grav\Common\Page\Media; use Grav\Common\Page\Collection; use RocketTheme\Toolbox\Event\Event; use Grav\Plugin\PresentationPlugin\API\Content; use Grav\Plugin\PresentationPlugin\API\Parser; use Grav\Plugin\PresentationPlugin\API\Transport; use Grav\Plugin\PresentationPlugin\API\Poll; use Grav\Plugin\PresentationPlugin\Utilities; /** * Creates slides using Reveal.js * * Class PresentationPlugin * * @category Extensions * @package Grav\Plugin * @author Ole Vik * @license http://www.opensource.org/licenses/mit-license.html MIT License * @link https://github.com/OleVik/grav-plugin-presentation */ class PresentationPlugin extends Plugin { /** * Protected variables * * @var bool $cache Grav cache setting * @var Transport $transport Transport API * @var Parser $parser Parser API * @var Content $content Content API */ protected $cache; protected $cacheTwig; protected $transport; protected $parser; protected $content; /** * Register intial event and libraries * * @return array */ public static function getSubscribedEvents() { include __DIR__ . '/vendor/autoload.php'; return [ 'onPluginsInitialized' => ['onPluginsInitialized', 0], ]; } /** * Initialize the plugin and events * * @return void */ public function onPluginsInitialized() { if ($this->config->get('plugins.presentation.enabled') != true) { return; } if ($this->config->get('system.debugger.enabled')) { $this->grav['debugger']->startTimer('presentation', 'Presentation'); } if ($this->isAdmin() && $this->config->get('plugins.admin')) { $this->enable( [ 'onPagesInitialized' => ['handleAPI', 0], 'onGetPageTemplates' => ['onGetPageTemplates', 0], 'onTwigSiteVariables' => ['twigBaseUrl', 0], 'onAssetsInitialized' => ['onAdminPagesAssetsInitialized', 0] ] ); } $this->cache = $this->grav['config']->get('system.cache.enabled'); $this->cacheTwig = $this->grav['config']->get('system.pages.never_cache_twig'); $this->enable( [ 'onShortcodeHandlers' => ['onShortcodeHandlers', 0], 'onPageInitialized' => ['pagePreCache', 0], 'onPageContentProcessed' => ['pageIteration', -10], 'onTwigExtensions' => ['onTwigExtensions', 0], 'onTwigTemplatePaths' => ['templates', 0], 'onTwigSiteVariables' => ['twigBaseUrl', 0], 'onPagesInitialized' => ['handleAPI', 0], 'onAssetsInitialized' => ['onAssetsInitialized', 0], 'onShutdown' => ['onShutdown', 0] ] ); if ($this->config->get('system.debugger.enabled')) { $this->grav['debugger']->stopTimer('presentation'); } } /** * Declare config from plugin-config * * @return array Plugin configuration */ public function config() { if ($this->config->get('plugins.presentation')) { return (array) $this->config->get('plugins.presentation'); } return false; } /** * Disable caching for related templates * * @return void */ public function pagePreCache() { if ($this->grav['page']->template() == 'presentation' || $this->grav['page']->template() == 'slide') { $this->grav['config']->set('system.cache.enabled', false); $this->grav['config']->set('system.pages.never_cache_twig', true); } } /** * Construct the page * * @return void */ public function pageIteration() { $grav = $this->grav; $config = $this->config(); if ($grav['page']->template() == 'presentation' || $grav['page']->template() == 'slide') { if (!isset($this->grav['twig']->twig_vars['reveal_init'])) { $baseUrl = $this->grav['uri']->rootUrl(true); $header = (array) $grav['page']->header(); if (isset($header['presentation'])) { $config = Utils::arrayMergeRecursiveUnique( $config, $header['presentation'] ); } $this->transport = $this->getAPIInstance( $config['transport'] ); $this->parser = $this->getAPIInstance( $config['parser'], $config, $this->transport ); $this->content = $this->getAPIInstance( $config['content'], $this->grav, $config, $this->parser, $this->transport ); $styles = $this->config->get('plugins.presentation.style') ?? $this->config->get('plugins.presentation.styles') ?? []; if (!empty($styles) && is_array($styles) && Utils::arrayIsAssociative($styles)) { $this->parser->processor( $styles, 'presentation', (array) $grav['page'] ); } $tree = $this->content->buildTree($grav['page']->route()); $slides = $this->content->buildContent($tree); $grav['page']->setRawContent($slides); $menu = $this->content->buildMenu($tree); $menu = Utilities::flattenArray($menu, 1); $options = Utilities::parseAmbiguousArrayValues( $this->config->get('plugins.presentation.options') ); $options = json_encode($options, JSON_PRETTY_PRINT); $breakpoints = json_encode( $this->config->get('plugins.presentation.breakpoints') ); $this->grav['twig']->twig_vars['reveal_init'] = $options; $grav['assets']->addInlineJs('const reveal_init = ' . $options . ';', null, 'presentation'); $this->grav['twig']->twig_vars['presentation_menu'] = $menu; $this->grav['twig']->twig_vars['presentation_breakpoints'] = $breakpoints; if ($grav['page']->template() == 'presentation') { $grav['assets']->addInlineCss($this->transport->getStyles(), null, 'presentation'); } } } } /** * Handle API * * @return void */ public function handleAPI() { $uri = $this->grav['uri']; $page = $this->grav['page']; $plugins = $this->config->get('plugins'); if ($uri->path() == '/' . $this->config->get('plugins.presentation.api_route')) { if ($_GET['action'] == 'poll') { $this->handlePollAPI( $uri, $page, (array) $this->config->get('plugins.presentation') ); } } if (isset($plugins['admin']) && $plugins['admin']['enabled'] == true) { $adminRoute = $this->config->get('plugins')['admin']['route']; if ($uri->path() == $adminRoute . '/' . $this->config->get('plugins.presentation.api_route')) { if ($_GET['action'] == 'save') { $this->handleSaveAPI(); } } } } /** * Handle Save API * * @return void */ public function handleSaveAPI() { if (!$this->isAdmin() || empty($_POST)) { return; } header('Content-Type: application/json'); header("allow-control-access-origin: * "); header('HTTP/1.1 200 OK'); try { $post = file_get_contents('php://input') ?? $_POST; $post = json_decode($post, true); $pages = Grav::instance()['pages']; $page = $pages->find('/' . $post['route']); $page->rawMarkdown(base64_decode($post['content'])); $page->save(); echo '200 OK'; } catch (\Exception $e) { echo $e; } exit(); } /** * Handle Poll API * * @param array $config Plugin configuration * * @return void */ public function handlePollAPI($config) { if (isset($config['sync']) && $config['sync'] == 'poll') { set_time_limit(0); header('Cache-Control: no-cache, no-store, max-age=0, must-revalidate'); header('Pragma: no-cache'); if (!isset($_GET['mode'])) { header('HTTP/1.1 400 Bad Request'); exit('400 Bad Request'); } $res = Grav::instance()['locator']; $target = $res->findResource('cache://') . '/presentation'; include_once __DIR__ . '/API/PollInterface.php'; include_once __DIR__ . '/API/Poll.php'; $poll = new Poll($target, 'Poll.json'); gc_enable(); if (!isset($config['token']) || empty($config['token'])) { return; } if ($_GET['mode'] == 'set' && isset($_GET['data'])) { Utilities::authorize($config['token']); $poll->remove(); header('Content-Type:text/plain'); header('HTTP/1.1 202 Accepted'); $poll->set(urldecode($_GET['data'])); } elseif ($_GET['mode'] == 'get') { header('Content-Type: application/json'); header('HTTP/1.1 200 OK'); $poll->get(); } elseif ($_GET['mode'] == 'remove') { Utilities::authorize($config['token']); header('Content-Type:text/plain'); header('HTTP/1.1 200 OK'); $poll->remove(); } $poll = null; unset($poll); gc_collect_cycles(); gc_disable(); exit(); } } /** * Add templates-directory to Twig paths * * @return void */ public function templates() { $this->grav['twig']->twig_paths[] = __DIR__ . '/templates'; } /** * Add root URL to Twig vars * * @return void */ public function twigBaseUrl() { $uri = $this->grav['uri']->rootUrl(true); $this->grav['twig']->twig_vars['presentation_base_url'] = $uri; } /** * Reset cache on shutdown * * @return void */ public function onShutdown() { $this->grav['config']->set('system.cache.enabled', $this->cache); $this->grav['config']->set('system.pages.never_cache_twig', $this->cacheTwig); } /** * Add Twig Extensions * * @return void */ public function onTwigExtensions() { include_once __DIR__ . '/twig/CallStaticExtension.php'; $this->grav['twig']->twig->addExtension(new PresentationPlugin\CallStaticTwigExtension()); include_once __DIR__ . '/twig/FileFinderExtension.php'; $this->grav['twig']->twig->addExtension(new PresentationPlugin\FileFinderTwigExtension()); } /** * Register Page templates * * @param Event $event RocketTheme\Toolbox\Event\Event * * @return void */ public function onGetPageTemplates(Event $event) { $types = $event->types; $locator = Grav::instance()['locator']; $locations = [ 'plugin://' . $this->name . '/blueprints/', 'theme://blueprints/', 'user://blueprints/' ]; foreach ($locations as $location) { $types->register( 'presentation', $locator->findResource($location . 'presentation.yaml') ); $types->register( 'slide', $locator->findResource($location . 'slide.yaml') ); } } /** * Get API Instance * * @param string $class Class name * @param mixed ...$args Class arguments * * @return mixed Class Instance */ public function getAPIInstance(string $class, ...$args) { $caller = '\Grav\Plugin\PresentationPlugin\API\\' . $class; return new $caller(...$args); } /** * Get list of modular scales * * @return array List of modular scales */ public static function getModularScale() { return array( ['name' => 'unison', 'ratio' => '1:1', 'numerical' => 1], ['name' => 'minor second', 'ratio' => '15:16', 'numerical' => 1.067], ['name' => 'major second', 'ratio' => '8:9', 'numerical' => 1.125], ['name' => 'minor third', 'ratio' => '5:6', 'numerical' => 1.2], ['name' => 'major third', 'ratio' => '4:5', 'numerical' => 1.25], ['name' => 'perfect fourth', 'ratio' => '3:4', 'numerical' => 1.333], ['name' => 'aug. fourth / dim. fifth', 'ratio' => '1:√2', 'numerical' => 1.414], ['name' => 'perfect fifth', 'ratio' => '2:3', 'numerical' => 1.5], ['name' => 'minor sixth', 'ratio' => '5:8', 'numerical' => 1.6], ['name' => 'golden section', 'ratio' => '1:1.618', 'numerical' => 1.618], ['name' => 'major sixth', 'ratio' => '3:5', 'numerical' => 1.667], ['name' => 'minor seventh', 'ratio' => '9:16', 'numerical' => 1.778], ['name' => 'major seventh', 'ratio' => '8:15', 'numerical' => 1.875], ['name' => 'octave', 'ratio' => '1:2', 'numerical' => 2], ['name' => 'major tenth', 'ratio' => '2:5', 'numerical' => 2.5], ['name' => 'major eleventh', 'ratio' => '3:8', 'numerical' => 2.667], ['name' => 'major twelfth', 'ratio' => '1:3', 'numerical' => 3], ['name' => 'double octave', 'ratio' => '1:4', 'numerical' => 4] ); } /** * Parse modular scales for blueprints * * @return array Blueprint-friendly list of modular scales */ public static function getModularScaleBlueprintOptions() { $options = ['' => 'None']; foreach (self::getModularScale() as $scale) { $options[(string) $scale['numerical']] = $scale['numerical'] . ' (' . ucwords($scale['name']) . ', ' . $scale['ratio'] . ')'; } return $options; } /** * Get class names for blueprints * * @param string $key Needle to search for * * @return array Blueprint-friendly list of class names */ public static function getClassNamesBlueprintOptions(string $key) { $regex = '/Grav\\\\Plugin\\\\PresentationPlugin\\\\API\\\\(?.*)/i'; $classes = preg_grep($regex, get_declared_classes()); $matches = preg_grep('/' . $key . '/i', $classes); $options = [ '' => 'None', $key => $key ]; foreach ($matches as $match) { $match = str_replace('Grav\Plugin\PresentationPlugin\API\\', '', $match); $options[$match] = $match; } return $options; } /** * Get reveal.js themes * * @return array Associative array of styles */ public static function getRevealThemes() { include __DIR__ . '/vendor/autoload.php'; $inflector = new Inflector(); $options = array('none' => 'None'); $path = 'user://plugins/presentation/node_modules/reveal.js/css/theme'; $location = Grav::instance()['locator']->findResource($path, true); if (!$location) { return $options; } $files = Utilities::filesFinder($location, ['css']); if (empty($files)) { return $options; } foreach ($files as $file) { $key = $file->getBasename('.' . $file->getExtension()); $options[$key] = $inflector->titleize($key); } return $options; } /** * Initialize shortcodes * * @return void */ public function onShortcodeHandlers() { $this->grav['shortcode']->registerAllShortcodes(__DIR__ . '/shortcodes'); } /** * Add admin assets * * @return void */ public function onAdminPagesAssetsInitialized() { $uri = $this->grav['uri']; $res = Grav::instance()['locator']; $path = $res->findResource('plugin://' . $this->name, false); $adminRoute = $this->config->get('plugins.admin.route'); if (!Utils::contains($uri->path(), $adminRoute . '/pages')) { return; } if ($this->config->get('plugins.presentation.admin_async_save') !== true) { return; } $adminRoute = $uri->rootUrl(true) . $adminRoute; $inlineJsConstants = array( 'presentationAPIRoute = "' . $adminRoute . '/' . $this->config->get('plugins.presentation.api_route') . '"', 'presentationAPITimeout = ' . ($this->config->get('plugins.presentation.poll_timeout') ?: 2000) * 2.5, 'presentationAPIRetryLimit = ' . ($this->config->get('plugins.presentation.poll_retry_limit') ?: 10), 'presentationAdminAsyncSave = ' . ($this->config->get('plugins.presentation.admin_async_save') ?: 0), 'presentationAdminAsyncSaveTyping = ' . ($this->config->get('plugins.presentation.admin_async_save_typing') ?: 0) ); $inlineJs = ''; foreach ($inlineJsConstants as $constant) { $inlineJs .= 'const ' . $constant . ';' . "\n"; } $this->grav['assets']->addInlineJs($inlineJs); $this->grav['assets']->addJs( $path . '/js/save.js' ); $this->grav['assets']->addJs( $path . '/node_modules/axios/dist/axios.min.js' ); $this->grav['assets']->addJs( $path . '/node_modules/js-base64/base64.min.js' ); $this->grav['assets']->addJs( $path . '/node_modules/codemirror/lib/codemirror.js' ); } /** * Add general assets * * @return void */ public function onAssetsInitialized() { if ($this->config->get('plugins.presentation.textsizing') && $this->config->get('plugins.presentation.breakpoints') && !empty($this->config->get('plugins.presentation.breakpoints')) ) { $css = ''; $element = '.reveal .slides section section, .reveal.center .slides section section'; $breakpoints = array_keys( $this->config->get('plugins.presentation.breakpoints') ); $sizes = array_values( $this->config->get('plugins.presentation.breakpoints') ); for ($i = 0; $i < count($breakpoints); $i++) { $css .= '@media screen and '; if ($i == 0) { $css .= '(min-width: 0px) and '; $css .= '(max-width:' . (intval($breakpoints[$i + 1]) - 1) . 'px) '; } else { $css .= '(min-width:' . $breakpoints[$i] . 'px) '; } $css .= '{' . $element . '{font-size:' . $sizes[$i] . 'px !important;}}'; $css .= "\n"; } $this->grav['assets']->addInlineCss($css, null, 'critical'); } $iframe = '.presentation-iframe { width: 100%; width: -moz-available; width: -webkit-fill-available; width: fill-available; height: 100%; height: -moz-available; height: -webkit-fill-available; height: fill-available; }'; $this->grav['assets']->addInlineCss($iframe); } }