countdown-8.x-1.8/src/Plugin/CountdownLibrary/Tick.php
src/Plugin/CountdownLibrary/Tick.php
<?php
declare(strict_types=1);
namespace Drupal\countdown\Plugin\CountdownLibrary;
use Drupal\Core\Form\FormStateInterface;
use Drupal\countdown\Plugin\CountdownLibraryPluginBase;
/**
* PQINA Tick Counter library plugin implementation.
*
* Tick is a highly customizable counter with multiple views, themes,
* and extensive configuration options. It supports various display
* modes and animations through modular extensions.
*
* @CountdownLibrary(
* id = "tick",
* label = @Translation("PQINA Tick Counter"),
* description = @Translation("Highly customizable counter with multiple views, themes, and animation styles"),
* type = "external",
* homepage = "https://pqina.nl/tick",
* repository = "https://github.com/pqina/tick",
* version = "1.8.3",
* npm_package = "@pqina/tick",
* folder_names = {
* "tick",
* "@pqina-tick",
* "pqina-tick",
* "tick-master",
* "pqina-tick-master"
* },
* files = {
* "css" = {
* "development" = "dist/core/tick.core.css",
* "production" = "dist/core/tick.core.min.css"
* },
* "js" = {
* "development" = "dist/core/tick.core.global.js",
* "production" = "dist/core/tick.core.global.min.js"
* }
* },
* required_files = {
* "dist/core/tick.core.global.js",
* "dist/core/tick.core.css"
* },
* alternative_paths = {
* {
* "dist/tick.js",
* "dist/tick.css"
* },
* {
* "lib/tick.js",
* "lib/tick.css"
* },
* {
* "build/tick.core.js",
* "build/tick.core.css"
* }
* },
* init_function = "Tick",
* author = "PQINA",
* license = "MIT",
* dependencies = {},
* cdn = {
* "jsdelivr" = {
* "css" = "//cdn.jsdelivr.net/npm/@pqina/tick@1.8.3/dist/core/tick.core.min.css",
* "js" = "//cdn.jsdelivr.net/npm/@pqina/tick@1.8.3/dist/core/tick.core.global.min.js"
* },
* "unpkg" = {
* "css" = "//unpkg.com/@pqina/tick@1.8.3/dist/core/tick.core.min.css",
* "js" = "//unpkg.com/@pqina/tick@1.8.3/dist/core/tick.core.global.min.js"
* },
* "cdnjs" = {
* "css" = "//cdnjs.cloudflare.com/ajax/libs/tick/1.8.3/tick.core.min.css",
* "js" = "//cdnjs.cloudflare.com/ajax/libs/tick/1.8.3/tick.core.global.min.js"
* }
* },
* weight = 3,
* experimental = false,
* api_version = "1.0"
* )
*/
final class Tick extends CountdownLibraryPluginBase {
/**
* Available Tick extensions with their configurations.
*
* @var array
*/
protected const EXTENSIONS = [
'font_highres' => [
'label' => 'High Resolution Font',
'description' => 'High-quality font rendering for displays',
'files' => [
'development' => 'dist/font-highres/tick.font.highres.global.js',
'production' => 'dist/font-highres/tick.font.highres.global.min.js',
],
'cdn' => [
'jsdelivr' => '//cdn.jsdelivr.net/npm/@pqina/tick@1.8.3/dist/font-highres/tick.font.highres.global.min.js',
'unpkg' => '//unpkg.com/@pqina/tick@1.8.3/dist/font-highres/tick.font.highres.global.min.js',
],
],
'font_lowres' => [
'label' => 'Low Resolution Font',
'description' => 'Optimized font for smaller displays',
'files' => [
'development' => 'dist/font-lowres/tick.font.lowres.global.js',
'production' => 'dist/font-lowres/tick.font.lowres.global.min.js',
],
'cdn' => [
'jsdelivr' => '//cdn.jsdelivr.net/npm/@pqina/tick@1.8.3/dist/font-lowres/tick.font.lowres.global.min.js',
'unpkg' => '//unpkg.com/@pqina/tick@1.8.3/dist/font-lowres/tick.font.lowres.global.min.js',
],
],
'view_boom' => [
'label' => 'Boom View',
'description' => 'Explosive animation effect with sound',
'css' => [
'development' => 'dist/view-boom/tick.view.boom.css',
'production' => 'dist/view-boom/tick.view.boom.min.css',
],
'js' => [
'development' => 'dist/view-boom/tick.view.boom.global.js',
'production' => 'dist/view-boom/tick.view.boom.global.min.js',
],
'cdn' => [
'jsdelivr' => [
'css' => '//cdn.jsdelivr.net/npm/@pqina/tick@1.8.3/dist/view-boom/tick.view.boom.min.css',
'js' => '//cdn.jsdelivr.net/npm/@pqina/tick@1.8.3/dist/view-boom/tick.view.boom.global.min.js',
],
],
],
'view_dots' => [
'label' => 'Dots View',
'description' => 'Dot matrix display style',
'css' => [
'development' => 'dist/view-dots/tick.view.dots.css',
'production' => 'dist/view-dots/tick.view.dots.min.css',
],
'js' => [
'development' => 'dist/view-dots/tick.view.dots.global.js',
'production' => 'dist/view-dots/tick.view.dots.global.min.js',
],
'cdn' => [
'jsdelivr' => [
'css' => '//cdn.jsdelivr.net/npm/@pqina/tick@1.8.3/dist/view-dots/tick.view.dots.min.css',
'js' => '//cdn.jsdelivr.net/npm/@pqina/tick@1.8.3/dist/view-dots/tick.view.dots.global.min.js',
],
],
],
'view_line' => [
'label' => 'Line View',
'description' => 'Linear progress indicator',
'css' => [
'development' => 'dist/view-line/tick.view.line.css',
'production' => 'dist/view-line/tick.view.line.min.css',
],
'js' => [
'development' => 'dist/view-line/tick.view.line.global.js',
'production' => 'dist/view-line/tick.view.line.global.min.js',
],
'cdn' => [
'jsdelivr' => [
'css' => '//cdn.jsdelivr.net/npm/@pqina/tick@1.8.3/dist/view-line/tick.view.line.min.css',
'js' => '//cdn.jsdelivr.net/npm/@pqina/tick@1.8.3/dist/view-line/tick.view.line.global.min.js',
],
],
],
'view_swap' => [
'label' => 'Swap View',
'description' => 'Smooth swapping animation',
'css' => [
'development' => 'dist/view-swap/tick.view.swap.css',
'production' => 'dist/view-swap/tick.view.swap.min.css',
],
'js' => [
'development' => 'dist/view-swap/tick.view.swap.global.js',
'production' => 'dist/view-swap/tick.view.swap.global.min.js',
],
'cdn' => [
'jsdelivr' => [
'css' => '//cdn.jsdelivr.net/npm/@pqina/tick@1.8.3/dist/view-swap/tick.view.swap.min.css',
'js' => '//cdn.jsdelivr.net/npm/@pqina/tick@1.8.3/dist/view-swap/tick.view.swap.global.min.js',
],
],
],
];
/**
* Available transforms for Tick library.
*
* @var array
*/
protected const TRANSFORMS = [
'pad' => 'Pad values with zeros or custom character',
'round' => 'Round numeric values',
'ceil' => 'Round up to nearest integer',
'floor' => 'Round down to nearest integer',
'fraction' => 'Convert to fraction between min and max',
'percentage' => 'Convert to percentage',
'multiply' => 'Multiply by value',
'divide' => 'Divide by value',
'add' => 'Add value',
'subtract' => 'Subtract value',
'modulus' => 'Modulo operation',
'abs' => 'Absolute value',
'limit' => 'Limit between min and max',
'upper' => 'Convert to uppercase',
'lower' => 'Convert to lowercase',
'split' => 'Split string by character',
'replace' => 'Replace string patterns',
'format' => 'Format with template',
'plural' => 'Pluralize text',
'chars' => 'Convert to character codes',
'ascii' => 'Convert to ASCII codes',
'tween' => 'Smooth transition animation',
'spring' => 'Spring physics animation',
'step' => 'Step-based animation',
'arrive' => 'Arrival animation',
'delay' => 'Delay animation',
];
/**
* Available transitions for Tick library.
*
* @var array
*/
protected const TRANSITIONS = [
'crossfade' => 'Fade in/out transition',
'swap' => 'Swap animation with direction',
'revolve' => 'Revolve around axis',
'zoom' => 'Zoom in/out effect',
'fade' => 'Simple fade effect',
'move' => 'Move along axis',
'rotate' => 'Rotate around axis',
'scale' => 'Scale transformation',
];
/**
* {@inheritdoc}
*/
protected function detectVersionCustom(string $base_path): ?string {
// Check the main JS file for version information.
$js_files = [
'/dist/core/tick.core.global.js',
'/dist/core/tick.core.global.min.js',
'/dist/tick.js',
'/lib/tick.js',
];
// Try multiple strategies to detect Tick version.
foreach ($js_files as $js_file) {
$file_path = $base_path . $js_file;
if (file_exists($file_path)) {
try {
// Read first 10KB of file.
$handle = fopen($file_path, 'r');
$content = fread($handle, 10240);
fclose($handle);
// Look for version patterns specific to PQINA Tick.
$patterns = [
'/\*\s+@pqina\/tick\s+v?([0-9]+\.[0-9]+(?:\.[0-9]+)?)/',
'/\*\s+Tick\s+v?([0-9]+\.[0-9]+(?:\.[0-9]+)?)/',
'/\*\s+@version\s+([0-9]+\.[0-9]+(?:\.[0-9]+)?)/',
'/Tick\.version\s*=\s*["\']([^"\']+)["\']/',
'/TICK_VERSION\s*=\s*["\']([0-9]+\.[0-9]+(?:\.[0-9]+)?)["\']/',
];
foreach ($patterns as $pattern) {
if (preg_match($pattern, $content, $matches)) {
$this->logger->info('PQINA Tick version detected: @version from @file', [
'@version' => $matches[1],
'@file' => $js_file,
]);
return $this->normalizeVersion($matches[1]);
}
}
}
catch (\Exception $e) {
$this->logger->error('Error reading Tick file @file: @message', [
'@file' => $js_file,
'@message' => $e->getMessage(),
]);
}
}
}
// Check package.json for @pqina/tick.
$package_file = $base_path . '/package.json';
if (file_exists($package_file)) {
try {
$content = file_get_contents($package_file);
$package_data = json_decode($content, TRUE);
if (json_last_error() === JSON_ERROR_NONE) {
// Check if it's the right package.
if (isset($package_data['name']) &&
($package_data['name'] === '@pqina/tick' || $package_data['name'] === 'tick')) {
if (!empty($package_data['version'])) {
return $this->normalizeVersion($package_data['version']);
}
}
}
}
catch (\Exception $e) {
$this->logger->warning('Could not read Tick package.json', [
'@error' => $e->getMessage(),
]);
}
}
return NULL;
}
/**
* {@inheritdoc}
*/
protected function resolveRequiredExtensions(array $library_config, array $config): array {
$extensions = [];
// Get view type from library configuration.
$view_type = $library_config['view_type'] ?? 'text';
// Map view types to required extensions.
switch ($view_type) {
case 'boom':
$extensions[] = 'view_boom';
break;
case 'dots':
$extensions[] = 'view_dots';
// Also need font extension for dots view.
$font = $library_config['font'] ?? 'highres';
$extensions[] = 'font_' . $font;
break;
case 'line':
$extensions[] = 'view_line';
break;
case 'swap':
$extensions[] = 'view_swap';
break;
case 'text':
default:
// Text view needs no extensions.
break;
}
return $extensions;
}
/**
* {@inheritdoc}
*/
protected function buildExtensionLibraryName(string $extension_id, array $config): ?string {
// Check if this is a valid Tick extension.
if (!isset(self::EXTENSIONS[$extension_id])) {
return NULL;
}
// Remove view_ and font_ prefixes to match library names.
$library_suffix = $extension_id;
if (str_starts_with($library_suffix, 'view_')) {
$library_suffix = substr($library_suffix, 5);
}
// Build the library name: tick_<suffix>(_cdn)(.min).
$library_name = 'tick_' . $library_suffix;
if ($config['method'] === 'cdn') {
$library_name .= '_cdn';
}
if ($config['variant']) {
$library_name .= '.min';
}
return $library_name;
}
/**
* {@inheritdoc}
*/
public function buildConfigurationForm(array &$form, FormStateInterface $form_state, array $default_values = []): void {
// First, build the common fields from parent.
parent::buildConfigurationForm($form, $form_state, $default_values);
// View type selection.
$form['library_specific']['view_type'] = [
'#type' => 'select',
'#title' => $this->t('View Type'),
'#description' => $this->t('Select the Tick display style.'),
'#options' => [
'text' => $this->t('Text (simple text display)'),
'swap' => $this->t('Swap (animated text swapping)'),
'dots' => $this->t('Dots (dot matrix display)'),
'line' => $this->t('Line (progress bar)'),
'boom' => $this->t('Boom (with sound effects)'),
],
'#default_value' => $this->getConfigValue($default_values, 'view_type', 'text'),
'#required' => TRUE,
];
// Font selection for dots view.
$form['library_specific']['font'] = [
'#type' => 'select',
'#title' => $this->t('Font Resolution'),
'#description' => $this->t('Select font resolution for dots view.'),
'#options' => [
'highres' => $this->t('High Resolution (5x7 matrix)'),
'lowres' => $this->t('Low Resolution (3x5 matrix)'),
],
'#default_value' => $this->getConfigValue($default_values, 'font', 'highres'),
'#states' => [
'visible' => [
':input[name$="[view_type]"]' => ['value' => 'dots'],
],
],
];
// Transform pipeline configuration.
$form['library_specific']['transforms'] = [
'#type' => 'details',
'#title' => $this->t('Value Transforms'),
'#description' => $this->t('Configure value transformation pipeline.'),
'#open' => FALSE,
];
$form['library_specific']['transforms']['enable_transforms'] = [
'#type' => 'checkbox',
'#title' => $this->t('Enable value transforms'),
'#description' => $this->t('Apply transformations to counter values.'),
'#default_value' => $this->getConfigValue($default_values, 'enable_transforms', FALSE),
];
$form['library_specific']['transforms']['transform_chain'] = [
'#type' => 'textfield',
'#title' => $this->t('Transform Chain'),
'#description' => $this->t('Enter transform chain (e.g., "pad(2) -> upper").'),
'#default_value' => $this->getConfigValue($default_values, 'transform_chain', ''),
'#states' => [
'visible' => [
':input[name$="[enable_transforms]"]' => ['checked' => TRUE],
],
],
];
// Transition configuration.
$form['library_specific']['transitions'] = [
'#type' => 'details',
'#title' => $this->t('Transitions'),
'#description' => $this->t('Configure animation transitions.'),
'#open' => FALSE,
];
$form['library_specific']['transitions']['transition_type'] = [
'#type' => 'select',
'#title' => $this->t('Transition Type'),
'#description' => $this->t('Select the transition animation.'),
'#options' => [
'none' => $this->t('None'),
'crossfade' => $this->t('Crossfade'),
'swap' => $this->t('Swap'),
'revolve' => $this->t('Revolve'),
'zoom' => $this->t('Zoom'),
],
'#default_value' => $this->getConfigValue($default_values, 'transition_type', 'crossfade'),
];
$form['library_specific']['transitions']['transition_duration'] = [
'#type' => 'number',
'#title' => $this->t('Transition Duration'),
'#description' => $this->t('Duration in milliseconds.'),
'#default_value' => $this->getConfigValue($default_values, 'transition_duration', 500),
'#min' => 0,
'#max' => 5000,
'#step' => 100,
'#field_suffix' => $this->t('ms'),
];
// Dots view specific settings.
$form['library_specific']['dots_settings'] = [
'#type' => 'details',
'#title' => $this->t('Dots View Settings'),
'#description' => $this->t('Settings for dot matrix display.'),
'#open' => FALSE,
'#states' => [
'visible' => [
':input[name$="[view_type]"]' => ['value' => 'dots'],
],
],
];
$form['library_specific']['dots_settings']['dot_color'] = [
'#type' => 'textfield',
'#title' => $this->t('Dot Color'),
'#description' => $this->t('CSS color for dots (e.g., #333, rgb(0,0,0)).'),
'#default_value' => $this->getConfigValue($default_values, 'dot_color', 'auto'),
'#size' => 20,
];
$form['library_specific']['dots_settings']['dot_shape'] = [
'#type' => 'select',
'#title' => $this->t('Dot Shape'),
'#description' => $this->t('Shape of the dots.'),
'#options' => [
'auto' => $this->t('Auto (from CSS)'),
'square' => $this->t('Square'),
'circle' => $this->t('Circle'),
],
'#default_value' => $this->getConfigValue($default_values, 'dot_shape', 'auto'),
];
$form['library_specific']['dots_settings']['dot_update_delay'] = [
'#type' => 'number',
'#title' => $this->t('Dot Update Delay'),
'#description' => $this->t('Delay between dot updates in milliseconds.'),
'#default_value' => $this->getConfigValue($default_values, 'dot_update_delay', 10),
'#min' => 0,
'#max' => 100,
'#field_suffix' => $this->t('ms'),
];
// Line view specific settings.
$form['library_specific']['line_settings'] = [
'#type' => 'details',
'#title' => $this->t('Line View Settings'),
'#description' => $this->t('Settings for progress bar display.'),
'#open' => FALSE,
'#states' => [
'visible' => [
':input[name$="[view_type]"]' => ['value' => 'line'],
],
],
];
$form['library_specific']['line_settings']['line_orientation'] = [
'#type' => 'select',
'#title' => $this->t('Line Orientation'),
'#description' => $this->t('Orientation of the progress bar.'),
'#options' => [
'horizontal' => $this->t('Horizontal'),
'vertical' => $this->t('Vertical'),
],
'#default_value' => $this->getConfigValue($default_values, 'line_orientation', 'horizontal'),
];
$form['library_specific']['line_settings']['line_flip'] = [
'#type' => 'checkbox',
'#title' => $this->t('Flip direction'),
'#description' => $this->t('Reverse the fill direction.'),
'#default_value' => $this->getConfigValue($default_values, 'line_flip', FALSE),
];
$form['library_specific']['line_settings']['fill_color'] = [
'#type' => 'textfield',
'#title' => $this->t('Fill Color'),
'#description' => $this->t('Color of the progress fill.'),
'#default_value' => $this->getConfigValue($default_values, 'fill_color', '#333'),
'#size' => 20,
];
$form['library_specific']['line_settings']['rail_color'] = [
'#type' => 'textfield',
'#title' => $this->t('Rail Color'),
'#description' => $this->t('Color of the progress rail/background.'),
'#default_value' => $this->getConfigValue($default_values, 'rail_color', '#eee'),
'#size' => 20,
];
// Boom view specific settings.
$form['library_specific']['boom_settings'] = [
'#type' => 'details',
'#title' => $this->t('Boom View Settings'),
'#description' => $this->t('Settings for sound effects.'),
'#open' => FALSE,
'#states' => [
'visible' => [
':input[name$="[view_type]"]' => ['value' => 'boom'],
],
],
];
$form['library_specific']['boom_settings']['sample_url'] = [
'#type' => 'textfield',
'#title' => $this->t('Sound Sample URL'),
'#description' => $this->t('URL to the sound file (.mp3, .wav).'),
'#default_value' => $this->getConfigValue($default_values, 'sample_url', ''),
'#maxlength' => 255,
];
$form['library_specific']['boom_settings']['volume'] = [
'#type' => 'number',
'#title' => $this->t('Volume'),
'#description' => $this->t('Sound volume level.'),
'#default_value' => $this->getConfigValue($default_values, 'volume', 0.5),
'#min' => 0,
'#max' => 1,
'#step' => 0.1,
];
// Swap view specific settings.
$form['library_specific']['swap_settings'] = [
'#type' => 'details',
'#title' => $this->t('Swap View Settings'),
'#description' => $this->t('Settings for swap animation.'),
'#open' => FALSE,
'#states' => [
'visible' => [
':input[name$="[view_type]"]' => ['value' => 'swap'],
],
],
];
$form['library_specific']['swap_settings']['transition_direction'] = [
'#type' => 'select',
'#title' => $this->t('Transition Direction'),
'#description' => $this->t('Direction of the swap animation.'),
'#options' => [
'forward' => $this->t('Forward'),
'reverse' => $this->t('Reverse'),
'detect' => $this->t('Auto-detect'),
],
'#default_value' => $this->getConfigValue($default_values, 'transition_direction', 'forward'),
];
// Advanced settings.
$form['library_specific']['advanced'] = [
'#type' => 'details',
'#title' => $this->t('Advanced Settings'),
'#description' => $this->t('Advanced Tick configuration options.'),
'#open' => FALSE,
];
$form['library_specific']['advanced']['update_interval'] = [
'#type' => 'number',
'#title' => $this->t('Update Interval'),
'#description' => $this->t('Update interval in milliseconds.'),
'#default_value' => $this->getConfigValue($default_values, 'update_interval', 1000),
'#min' => 100,
'#max' => 10000,
'#step' => 100,
'#field_suffix' => $this->t('ms'),
];
$form['library_specific']['advanced']['cascade'] = [
'#type' => 'checkbox',
'#title' => $this->t('Enable cascade'),
'#description' => $this->t('Cascade time units (e.g., 61 seconds = 1 minute 1 second).'),
'#default_value' => $this->getConfigValue($default_values, 'cascade', TRUE),
];
$form['library_specific']['advanced']['server_sync'] = [
'#type' => 'checkbox',
'#title' => $this->t('Server time sync'),
'#description' => $this->t('Synchronize with server time instead of client time.'),
'#default_value' => $this->getConfigValue($default_values, 'server_sync', FALSE),
];
$form['library_specific']['advanced']['autostart'] = [
'#type' => 'checkbox',
'#title' => $this->t('Autostart'),
'#description' => $this->t('Start counting automatically when loaded.'),
'#default_value' => $this->getConfigValue($default_values, 'autostart', TRUE),
];
$form['library_specific']['advanced']['show_credits'] = [
'#type' => 'checkbox',
'#title' => $this->t('Show credits'),
'#description' => $this->t('Display "Powered by PQINA" credit link.'),
'#default_value' => $this->getConfigValue($default_values, 'show_credits', FALSE),
];
// Custom CSS class.
$form['library_specific']['custom_class'] = [
'#type' => 'textfield',
'#title' => $this->t('Custom CSS Class'),
'#description' => $this->t('Additional CSS class for styling.'),
'#default_value' => $this->getConfigValue($default_values, 'custom_class', ''),
'#maxlength' => 128,
];
// Layout configuration.
$form['library_specific']['layout'] = [
'#type' => 'select',
'#title' => $this->t('Layout'),
'#description' => $this->t('Counter layout alignment.'),
'#options' => [
'left' => $this->t('Left aligned'),
'center' => $this->t('Center aligned'),
'right' => $this->t('Right aligned'),
'fit' => $this->t('Fit to container'),
],
'#default_value' => $this->getConfigValue($default_values, 'layout', 'left'),
];
// Presets for common countdown formats.
$form['library_specific']['preset'] = [
'#type' => 'select',
'#title' => $this->t('Preset Format'),
'#description' => $this->t('Use a preset countdown format.'),
'#options' => [
'custom' => $this->t('Custom (use Display Format)'),
'full' => $this->t('Full (Years, Months, Days, Hours, Minutes, Seconds)'),
'extended' => $this->t('Extended (Days, Hours, Minutes, Seconds)'),
'simple' => $this->t('Simple (Hours, Minutes, Seconds)'),
'minimal' => $this->t('Minimal (Minutes, Seconds)'),
'days_only' => $this->t('Days Only'),
'hours_only' => $this->t('Hours Only'),
],
'#default_value' => $this->getConfigValue($default_values, 'preset', 'custom'),
];
}
/**
* {@inheritdoc}
*/
public function validateConfigurationForm(array &$form, FormStateInterface $form_state): void {
// First validate common fields.
parent::validateConfigurationForm($form, $form_state);
// Get values from form state.
$values = $form_state->getValues();
// Handle nested structure from block forms.
$source = $values;
if (isset($values['library_specific']) && is_array($values['library_specific'])) {
$source = $values['library_specific'];
}
// Get view_type with default value.
$view_type = $source['view_type'] ?? 'text';
// Validate color values based on view type.
if ($view_type === 'dots') {
$dot_color = $source['dots_settings']['dot_color'] ?? $source['dot_color'] ?? 'auto';
if ($dot_color !== 'auto' && !$this->isValidColor($dot_color)) {
$form_state->setErrorByName(
'library_specific][dots_settings][dot_color',
$this->t('Invalid color format for dot color. Use hex, rgb, rgba, or named colors.')
);
}
}
if ($view_type === 'line') {
$fill_color = $source['line_settings']['fill_color'] ?? $source['fill_color'] ?? '#333';
$rail_color = $source['line_settings']['rail_color'] ?? $source['rail_color'] ?? '#eee';
if (!$this->isValidColor($fill_color)) {
$form_state->setErrorByName(
'library_specific][line_settings][fill_color',
$this->t('Invalid color format for fill color. Use hex, rgb, rgba, or named colors.')
);
}
if (!$this->isValidColor($rail_color)) {
$form_state->setErrorByName(
'library_specific][line_settings][rail_color',
$this->t('Invalid color format for rail color. Use hex, rgb, rgba, or named colors.')
);
}
}
// Validate boom view settings.
if ($view_type === 'boom') {
$sample_url = $source['boom_settings']['sample_url'] ?? $source['sample_url'] ?? '';
if (!empty($sample_url) && !$this->isValidUrlOrPath($sample_url)) {
$form_state->setErrorByName(
'library_specific][boom_settings][sample_url',
$this->t('Invalid sound sample URL or file path. Please provide a valid URL or existing file path.')
);
}
$volume = (float) ($source['boom_settings']['volume'] ?? $source['volume'] ?? 0.5);
if ($volume < 0 || $volume > 1) {
$form_state->setErrorByName(
'library_specific][boom_settings][volume',
$this->t('Volume must be between 0 and 1.')
);
}
}
// Validate transform chain if enabled.
$transforms_enabled = (bool) ($source['transforms']['enable_transforms'] ?? $source['enable_transforms'] ?? FALSE);
$transform_chain = $source['transforms']['transform_chain'] ?? $source['transform_chain'] ?? '';
if ($transforms_enabled && !empty($transform_chain)) {
if (!preg_match('/^[a-z0-9()_\->\s,]+$/i', $transform_chain)) {
$form_state->setErrorByName(
'library_specific][transforms][transform_chain',
$this->t('Transform chain contains invalid characters. Only letters, numbers, parentheses, underscores, arrows, commas, and spaces are allowed.')
);
}
}
}
/**
* {@inheritdoc}
*/
public function getDefaultConfiguration(): array {
// Get parent defaults then add Tick-specific defaults.
$defaults = parent::getDefaultConfiguration();
// Add Tick-specific configuration defaults.
$defaults['view_type'] = 'text';
$defaults['font'] = 'highres';
$defaults['enable_transforms'] = FALSE;
$defaults['transform_chain'] = '';
$defaults['transition_type'] = 'crossfade';
$defaults['transition_duration'] = 500;
$defaults['show_credits'] = FALSE;
// Dots view defaults.
$defaults['dot_color'] = 'auto';
$defaults['dot_shape'] = 'auto';
$defaults['dot_update_delay'] = 10;
// Line view defaults.
$defaults['line_orientation'] = 'horizontal';
$defaults['line_flip'] = FALSE;
$defaults['fill_color'] = '#333';
$defaults['rail_color'] = '#eee';
// Boom view defaults.
$defaults['sample_url'] = '';
$defaults['volume'] = 0.5;
// Swap view defaults.
$defaults['transition_direction'] = 'forward';
// Advanced settings defaults.
$defaults['update_interval'] = 1000;
$defaults['cascade'] = TRUE;
$defaults['server_sync'] = FALSE;
$defaults['autostart'] = TRUE;
$defaults['custom_class'] = '';
$defaults['layout'] = 'left';
$defaults['preset'] = 'custom';
$defaults['format'] = ['d', 'h', 'm', 's'];
return $defaults;
}
/**
* {@inheritdoc}
*/
public function getJavaScriptSettings(array $configuration): array {
// Get parent settings then add Tick-specific settings.
$settings = parent::getJavaScriptSettings($configuration);
// Core Tick settings.
$settings['view_type'] = $this->getConfigValue($configuration, 'view_type', 'text');
$settings['interval'] = (int) $this->getConfigValue($configuration, 'update_interval', 1000);
$settings['cascade'] = (bool) $this->getConfigValue($configuration, 'cascade', TRUE);
$settings['server'] = (bool) $this->getConfigValue($configuration, 'server_sync', FALSE);
$settings['autostart'] = (bool) $this->getConfigValue($configuration, 'autostart', TRUE);
$settings['layout'] = $this->getConfigValue($configuration, 'layout', 'left');
// Credits configuration.
$settings['show_credits'] = (bool) $this->getConfigValue($configuration, 'show_credits', FALSE);
// Custom CSS class.
$custom_class = $this->getConfigValue($configuration, 'custom_class', '');
if (!empty($custom_class)) {
$settings['custom_class'] = $custom_class;
}
// Transform configuration.
$settings['enable_transforms'] = (bool) $this->getConfigValue($configuration, 'enable_transforms', FALSE);
if ($settings['enable_transforms']) {
$transform_chain = $this->getConfigValue($configuration, 'transform_chain', '');
if (!empty($transform_chain)) {
$settings['transform_chain'] = $transform_chain;
}
}
// Apply preset format if selected.
$preset = $this->getConfigValue($configuration, 'preset', 'custom');
if ($preset !== 'custom') {
$settings['format'] = $this->getPresetFormat($preset);
}
else {
// Ensure format is always an array.
$format = $this->getConfigValue($configuration, 'format', ['d', 'h', 'm', 's']);
if (is_string($format)) {
$format = array_map('trim', explode(',', $format));
}
$settings['format'] = $format;
}
// Transition configuration.
$transition_type = $this->getConfigValue($configuration, 'transition_type', 'crossfade');
if ($transition_type !== 'none') {
$settings['transition'] = [
'name' => $transition_type,
'duration' => (int) $this->getConfigValue($configuration, 'transition_duration', 500),
];
}
// View-specific settings based on selected view type.
$view_type = $this->getConfigValue($configuration, 'view_type', 'text');
switch ($view_type) {
case 'dots':
$settings['font'] = $this->getConfigValue($configuration, 'font', 'highres');
$settings['dot_color'] = $this->getConfigValue($configuration, 'dot_color', 'auto');
$settings['dot_shape'] = $this->getConfigValue($configuration, 'dot_shape', 'auto');
$settings['dot_update_delay'] = (int) $this->getConfigValue($configuration, 'dot_update_delay', 10);
break;
case 'line':
$settings['line_orientation'] = $this->getConfigValue($configuration, 'line_orientation', 'horizontal');
$settings['line_flip'] = (bool) $this->getConfigValue($configuration, 'line_flip', FALSE);
$settings['fill_color'] = $this->getConfigValue($configuration, 'fill_color', '#333');
$settings['rail_color'] = $this->getConfigValue($configuration, 'rail_color', '#eee');
break;
case 'boom':
$sample_url = $this->getConfigValue($configuration, 'sample_url', '');
if (!empty($sample_url)) {
$settings['sample_url'] = $sample_url;
}
$settings['volume'] = (float) $this->getConfigValue($configuration, 'volume', 0.5);
break;
case 'swap':
$settings['transition_direction'] = $this->getConfigValue($configuration, 'transition_direction', 'forward');
break;
}
return $settings;
}
/**
* Helper method to validate color values.
*
* @param string $color
* The color value to validate.
*
* @return bool
* TRUE if the color is valid, FALSE otherwise.
*/
private function isValidColor(string $color): bool {
$pattern = '/^(#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})|rgb\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*\)|rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*(0|1|0\.\d+)\s*\)|[a-z]+)$/i';
return preg_match($pattern, $color) === 1;
}
/**
* Helper method to validate URL or file path.
*
* @param string $url
* The URL or path to validate.
*
* @return bool
* TRUE if valid URL or existing file path, FALSE otherwise.
*/
private function isValidUrlOrPath(string $url): bool {
if (filter_var($url, FILTER_VALIDATE_URL)) {
return TRUE;
}
// Check if it's a valid file path.
if (file_exists($url)) {
return TRUE;
}
// Check if it's a relative path within the Drupal installation.
$drupal_root = DRUPAL_ROOT;
if (file_exists($drupal_root . '/' . ltrim($url, '/'))) {
return TRUE;
}
return FALSE;
}
/**
* Convert preset to format array with proper validation.
*
* @param string $preset
* The preset identifier.
*
* @return array
* The format array.
*/
protected function getPresetFormat(string $preset): array {
$formats = [
'full' => ['y', 'M', 'd', 'h', 'm', 's'],
'extended' => ['d', 'h', 'm', 's'],
'simple' => ['h', 'm', 's'],
'minimal' => ['m', 's'],
'days_only' => ['d'],
'hours_only' => ['h'],
];
return $formats[$preset] ?? ['d', 'h', 'm', 's'];
}
/**
* {@inheritdoc}
*/
public function hasExtensions(): bool {
return TRUE;
}
/**
* {@inheritdoc}
*/
public function getAvailableExtensions(): array {
return self::EXTENSIONS;
}
}
