View source
<?php
namespace Drupal\collapse_text\Plugin\Filter;
use Drupal\filter\FilterProcessResult;
use Drupal\filter\Plugin\FilterBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Component\Utility\Html;
define('COLLAPSE_TEXT_DEFAULT_TITLE', t('Click here to expand or collapse this section'));
class CollapseText extends FilterBase {
public function settingsForm(array $form, FormStateInterface $form_state) {
$form['default_title'] = [
'#type' => 'textfield',
'#title' => t('Default title'),
'#description' => t('If no title is supplied for a section, use this as the default. This may not be empty. The original default title is "@default_title".', [
'@default_title' => COLLAPSE_TEXT_DEFAULT_TITLE,
]),
'#default_value' => isset($this->settings['default_title']) ? $this->settings['default_title'] : COLLAPSE_TEXT_DEFAULT_TITLE,
'#required' => TRUE,
];
$form['form'] = [
'#type' => 'checkbox',
'#title' => t('Surround text with an empty form tag'),
'#description' => t('Collapse text works by generating <details> tags. To validate as proper HTML, these need to be within a <form> tag. This option allows you to prevent the generation of the surrounding form tag. You probably do not want to change this.'),
'#default_value' => isset($this->settings['form']) ? $this->settings['form'] : 1,
];
return $form;
}
public function prepare($text, $langcode) {
$text = preg_replace('/(?<!\\\\) # not preceded by a backslash
< # an open bracket
( # start capture
\\/? # optional backslash
collapse # the string collapse
[^>]* # everything up to the closing angle bracket; note that you cannot use one inside the tag!
) # stop capture
> # close bracket
/ix', '[$1]', $text);
$text = preg_replace_callback('/(?<!\\\\) # not preceded by a backslash
\\[ # open bracket
collapse # the string collapse
[^\\]]* # everything up to a closing straight bracket; note that you cannot use one inside a tag!
\\] # closing bracket
/ix', [
$this,
'filterPrepareRegexCallback',
], $text);
return $text;
}
public function process($text, $langcode) {
$options = $this->settings;
list($text, $options) = $this
->checkOptions($text, $options);
$tags = $this
->findTags($text, $options);
$levels = $this
->findLevels($tags, $options);
if (count($levels)) {
$tree = $this
->processRecurseLevels($text, 0, strlen($text), $levels, $options);
static $render_number = 1;
$holder = [];
if ($options['form']) {
$holder = [
'#type' => 'form',
'#theme' => 'collapse_text_form',
'#form_id' => 'collapse-text-dynamic-form-number-' . $render_number,
'#id' => 'collapse-text-dynamic-form-number-' . $render_number++,
];
}
else {
$holder = [
'#type' => 'markup',
'#prefix' => '<div id="' . 'collapse-text-dynamic-div-number-' . $render_number++ . '">',
'#suffix' => '</div>',
];
}
$holder['collapse_text_internal_text'] = $this
->processRecurseTree($tree, $options);
$text = \Drupal::service('renderer')
->render($holder);
}
return new FilterProcessResult($text);
}
public function tips($long = FALSE) {
if ($long) {
return t('<p>You may surround a section of text with "[collapse]" and "[/collapse]" to it into a collapsible section.</p><p>You may use "[collapse]" tags within other "[collapse]" tags for nested collapsing sections.</p><p>If you start with "[collapsed]" or "[collapse collapsed]", the section will default to a collapsed state.</p><p>You may specify a title for the section in two ways. You may add a "title=" parameter to the opening tag, such as "[collapse title=<your title here>]". In this case, you should surround the title with double-quotes. If you need to include double-quotes in the title, use the html entity "&quot;". For example: \'[collapse title="&quot;Once upon a time&quot;"]\'. If a title is not specified in the "[collapse]" tag, the title will be taken from the first heading found inside the section. A heading is specified using the "<hX>" html tag, where X is a number from 1-6. The heading will be removed from the section in order to prevent duplication. If a title is not found using these two methods, a default title will be supplied.</p><p>For advanced uses, you may also add a "class=" option to specify CSS classes to be added to the section. The CSS classes should be surrounded by double-quotes, and separated by spaces; e.g. \'[collapse class="class1 class2"]\'.</p><p>You may combine these options in (almost) any order. The "collapsed" option should always come first; things will break if it comes after "title=" or "class=". If you need to have it come after the other options, you must specify it as \'collapsed="collapsed"\'; e.g. \'[collapse title="foo" collapsed="collapsed"]\'.</p><p>If you wish to put the string "[collapse" into the document, you will need to prefix it with a backslash ("\\"). The first backslash before any instance of "[collapse" or "[/collapse" will be removed, all others will remain. Thus, if you want to display "[collapse stuff here", you should enter "\\[collapse stuff here". If you wish to display "\\[collapse other stuff", you will need to put in "\\\\[collapse other stuff". If you prefix three backslashes, two will be displayed, etc.</p><p>If you prefer, you can use angle brackets ("<>") instead of straight brackets ("[]"). This module will find any instance of "<collapse" and change it to "[collapse" (also fixing the end of the tags and the closing tags).</p><p>You may override the settings of the filter on an individual basis using a "[collapse options ...]" tag. The possible options now are \'form="form"\' or \'form="noform"\', and \'default_title="..."\'. For example, \'[collapse options form="noform" default_title="Click me!"]\'. Only the first options tag will be looked at, and the settings apply for the entire text area, not just the "[collapse]" tags following the options tag. Note that surrounding <p> and <br> tags will be removed.</p><p>This module supports some historical variants of the tag as well. The following are <strong>not</strong> recommended for any new text, but are left in place so that old uses still work. The "class=" option used to called "style=", and "style=" will be changed into "class=". If you don\'t put a double-quote immediately after "class=", everything up to the end of the tag or the string "title=" will be interpreted as the class string. Similarly, if you don\'t have a double-quote immediately following "title=", everything up to the end of the tag will be used as the title. Note that in this format, "style=" <em>must</em> precede "title=".</p>');
}
else {
return t('Use [collapse] and [/collapse] to create collapsible text blocks. [collapse collapsed] or [collapsed] will start with the block closed.');
}
}
public function filterPrepareRegexCallback($matches) {
$tag = $matches[0];
$tag = preg_replace('/^ # start of tag
\\[ # open bracket
( # start capture
collapsed # the string collapsed
(?: |\\]) # either a space or a close bracket
) # end capture
/ix', '[collapse $1', $tag);
$tag = preg_replace('/^\\[collapse collapsed( |\\])/i', '[collapse collapsed="collapsed"$1', $tag);
$tag = preg_replace('/ style=([^"].*?)(?= collapsed=| title=|\\])/i', ' class="$1"', $tag);
$tag = preg_replace('/ style="/i', ' class="', $tag);
$tag = preg_replace('/ title=([^"].*?)(?= collapsed=| class=|\\])/i', ' title="$1"', $tag);
return $tag;
}
public function processRecurseTree($tree, $options) {
$parts = [];
$weight = 0;
foreach ($tree as $item) {
$part = NULL;
if ($item['type'] == 'text') {
$part = $this
->processTextItem($item['value'], $options);
}
elseif ($item['type'] = 'child') {
$part = $this
->processChildItem($item, $options);
}
if (isset($part)) {
$part['#weight'] = $weight++;
$parts[] = $part;
}
}
return $parts;
}
public function processTextItem($item, $options) {
$item = preg_replace('/(?<!\\\\)\\[\\/?collapse[^\\]]*\\]/', '', $item);
$item = str_replace([
'\\[collapse',
'\\[/collapse',
], [
'[collapse',
'[/collapse',
], $item);
$item = preg_replace('/^<\\/p>/', '', $item);
$item = preg_replace('/<p(?:\\s[^>]*)?>$/', '', $item);
$item = preg_replace('/^<br ?\\/?>/', '', $item);
$item = preg_replace('/<br ?\\/?>$/', '', $item);
if (preg_match('/\\S/', $item)) {
return [
'#type' => 'markup',
'#markup' => $item,
'#prefix' => '<div class="collapse-text-text">',
'#suffix' => '</div>',
];
}
else {
return NULL;
}
}
public function processChildItem($item, $options) {
$tag = preg_replace([
'/^\\[/',
'/\\]$/',
'/&/',
], [
'<',
'/>',
'&',
], $item['tag']);
$tag = $this
->htmlToXmlEntities($tag);
$xmltag = simplexml_load_string($tag);
$collapsed = $xmltag['collapsed'] == 'collapsed';
$class = trim($xmltag['class']);
$title = htmlspecialchars(trim($xmltag['title']), ENT_QUOTES, 'UTF-8');
$classes = [];
$classes[] = Html::cleanCssIdentifier('collapse-text-details');
$classes[] = 'collapsible';
if ($collapsed) {
$classes[] = 'collapsed';
}
foreach (explode(' ', $class) as $c) {
if (!empty($c)) {
$classes[] = Html::cleanCssIdentifier($c);
}
}
if (empty($title)) {
if ($item['value'][0]['type'] == 'text') {
$h_matches = [];
if (preg_match('/(<h\\d[^>]*>(.+?)<\\/h\\d>)/smi', $item['value'][0]['value'], $h_matches)) {
$title = strip_tags($h_matches[2]);
}
if (!empty($title)) {
$occ = 1;
$item['value'][0]['value'] = str_replace($h_matches[0], '', $item['value'][0]['value'], $occ);
}
}
}
if (empty($title)) {
$title = $options['default_title'];
$classes[] = Html::cleanCssIdentifier('collapse-text-default-title');
}
$details = [
'#type' => 'details',
'#theme' => 'collapse_text_details',
'#title' => htmlspecialchars_decode($title),
'#open' => !$collapsed,
'#attributes' => [
'class' => $classes,
],
'collapse_text_contents' => $this
->processRecurseTree($item['value'], $options),
];
return $details;
}
public function processRecurseLevels($string, $string_start, $string_end, $elements, $options) {
$text_start = $string_start;
$text_length = $string_end - $string_start;
$child_start = $string_start;
$child_end = $string_end;
$slice_start = -1;
$elt_start_found = FALSE;
$elt_start = 0;
while (!$elt_start_found and $elt_start < count($elements)) {
if ($elements[$elt_start]['type'] == 'start') {
$elt_start_found = TRUE;
}
else {
$elt_start++;
}
}
if ($elt_start_found) {
$text_length = $elements[$elt_start]['start'] - $string_start;
$child_start = $elements[$elt_start]['end'];
$slice_start = $elt_start + 1;
}
else {
return [
[
'type' => 'text',
'value' => substr($string, $text_start, $text_length),
],
];
}
$elt_end_found = FALSE;
$elt_end = $elt_start;
while (!$elt_end_found and $elt_end < count($elements)) {
if ($elements[$elt_end]['type'] == 'end' and $elements[$elt_end]['level'] == $elements[$elt_start]['level']) {
$elt_end_found = TRUE;
}
else {
$elt_end++;
}
}
if ($elt_end_found) {
$child_end = $elements[$elt_end]['start'];
$slice_length = $elt_end - $slice_start;
}
else {
if ($elt_start + 1 < count($elements)) {
return $this
->processRecurseLevels($string, $string_start, $string_end, array_slice($elements, $elt_start + 1), $options);
}
else {
$text_length = $string_end - $text_start;
return [
[
'type' => 'text',
'value' => substr($string, $text_start, $text_length),
],
];
}
}
$parts = [];
$parts[] = [
'type' => 'text',
'value' => substr($string, $text_start, $text_length),
];
$parts[] = [
'type' => 'child',
'tag' => $elements[$elt_start]['tag'],
'value' => $this
->processRecurseLevels($string, $child_start, $child_end, array_slice($elements, $slice_start, $slice_length), $options),
];
$parts = array_merge($parts, $this
->processRecurseLevels($string, $elements[$elt_end]['end'], $string_end, array_slice($elements, $elt_end), $options));
return $parts;
}
public function findLevels($tags, $options) {
$levels = [];
$curr_level = 0;
foreach ($tags as $item) {
$type = 'unknown';
if (substr($item[0], 0, 9) == '[collapse') {
$type = 'start';
}
elseif (substr($item[0], 0, 10) == '[/collapse') {
$type = 'end';
}
if ($type == 'start') {
$curr_level++;
}
$levels[] = [
'type' => $type,
'tag' => $item[0],
'start' => $item[1],
'end' => $item[1] + strlen($item[0]),
'level' => $curr_level,
];
if ($type == 'end') {
$curr_level--;
}
}
return $levels;
}
public function findTags($text, $options) {
$matches = [];
$regex = '/
(?<!\\\\) # not proceeded by a backslash
\\[ # opening bracket
\\/? # a closing tag?
collapse # the word collapse
[^\\]]* # everything until the closing bracket
\\] # a closing bracket
/smx';
preg_match_all($regex, $text, $matches, PREG_OFFSET_CAPTURE);
return $matches[0];
}
public function checkOptions($text, $options) {
$matches = [];
$regex_text = '
(?<!\\\\) # not proceeded by a backslash
\\[ # opening bracket
collapse # the word collapse
\\s+ # white space
options # the word options
[^\\]]* # everything until the closing bracket
\\] # a closing bracket
';
if (preg_match('/' . $regex_text . '/smx', $text, $matches)) {
$opt_tag = $matches[0];
$opt_tag = preg_replace('/^\\[collapse /', '[', $opt_tag);
$opt_tag = preg_replace([
'/^\\[/',
'/\\]$/',
], [
'<',
'/>',
], $opt_tag);
$opt_tag = $this
->htmlToXmlEntities($opt_tag);
$opt_tag = simplexml_load_string($opt_tag);
if ($opt_tag['form'] == 'form') {
$options['form'] = 1;
}
elseif ($opt_tag['form'] == 'noform') {
$options['form'] = 0;
}
if ($opt_tag['default_title']) {
$options['default_title'] = htmlspecialchars(trim($opt_tag['default_title']), ENT_QUOTES, 'UTF-8');
}
$text = preg_replace('/(?:<\\/?p>|<br\\s*\\/?>)*' . $regex_text . '(?:<\\/?p>|<br\\s*\\/?>)*/smx', '', $text);
}
return [
$text,
$options,
];
}
public function htmlToXmlEntities($text) {
static $replace = [
' ' => ' ',
'¡' => '¡',
'¢' => '¢',
'£' => '£',
'¤' => '¤',
'¥' => '¥',
'¦' => '¦',
'§' => '§',
'¨' => '¨',
'©' => '©',
'ª' => 'ª',
'«' => '«',
'¬' => '¬',
'­' => '­',
'®' => '®',
'¯' => '¯',
'°' => '°',
'±' => '±',
'²' => '²',
'³' => '³',
'´' => '´',
'µ' => 'µ',
'¶' => '¶',
'·' => '·',
'¸' => '¸',
'¹' => '¹',
'º' => 'º',
'»' => '»',
'¼' => '¼',
'½' => '½',
'¾' => '¾',
'¿' => '¿',
'À' => 'À',
'Á' => 'Á',
'Â' => 'Â',
'Ã' => 'Ã',
'Ä' => 'Ä',
'Å' => 'Å',
'Æ' => 'Æ',
'Ç' => 'Ç',
'È' => 'È',
'É' => 'É',
'Ê' => 'Ê',
'Ë' => 'Ë',
'Ì' => 'Ì',
'Í' => 'Í',
'Î' => 'Î',
'Ï' => 'Ï',
'Ð' => 'Ð',
'Ñ' => 'Ñ',
'Ò' => 'Ò',
'Ó' => 'Ó',
'Ô' => 'Ô',
'Õ' => 'Õ',
'Ö' => 'Ö',
'×' => '×',
'Ø' => 'Ø',
'Ù' => 'Ù',
'Ú' => 'Ú',
'Û' => 'Û',
'Ü' => 'Ü',
'Ý' => 'Ý',
'Þ' => 'Þ',
'ß' => 'ß',
'à' => 'à',
'á' => 'á',
'â' => 'â',
'ã' => 'ã',
'ä' => 'ä',
'å' => 'å',
'æ' => 'æ',
'ç' => 'ç',
'è' => 'è',
'é' => 'é',
'ê' => 'ê',
'ë' => 'ë',
'ì' => 'ì',
'í' => 'í',
'î' => 'î',
'ï' => 'ï',
'ð' => 'ð',
'ñ' => 'ñ',
'ò' => 'ò',
'ó' => 'ó',
'ô' => 'ô',
'õ' => 'õ',
'ö' => 'ö',
'÷' => '÷',
'ø' => 'ø',
'ù' => 'ù',
'ú' => 'ú',
'û' => 'û',
'ü' => 'ü',
'ý' => 'ý',
'þ' => 'þ',
'ÿ' => 'ÿ',
''' => ''',
'Œ' => 'Œ',
'œ' => 'œ',
'Š' => 'Š',
'š' => 'š',
'Ÿ' => 'Ÿ',
'ˆ' => 'ˆ',
'˜' => '˜',
' ' => ' ',
' ' => ' ',
' ' => ' ',
'‌' => '‌',
'‍' => '‍',
'‎' => '‎',
'‏' => '‏',
'–' => '–',
'—' => '—',
'‘' => '‘',
'’' => '’',
'‚' => '‚',
'“' => '“',
'”' => '”',
'„' => '„',
'†' => '†',
'‡' => '‡',
'‰' => '‰',
'‹' => '‹',
'›' => '›',
'€' => '€',
'ƒ' => 'ƒ',
'Α' => 'Α',
'Β' => 'Β',
'Γ' => 'Γ',
'Δ' => 'Δ',
'Ε' => 'Ε',
'Ζ' => 'Ζ',
'Η' => 'Η',
'Θ' => 'Θ',
'Ι' => 'Ι',
'Κ' => 'Κ',
'Λ' => 'Λ',
'Μ' => 'Μ',
'Ν' => 'Ν',
'Ξ' => 'Ξ',
'Ο' => 'Ο',
'Π' => 'Π',
'Ρ' => 'Ρ',
'Σ' => 'Σ',
'Τ' => 'Τ',
'Υ' => 'Υ',
'Φ' => 'Φ',
'Χ' => 'Χ',
'Ψ' => 'Ψ',
'Ω' => 'Ω',
'α' => 'α',
'β' => 'β',
'γ' => 'γ',
'δ' => 'δ',
'ε' => 'ε',
'ζ' => 'ζ',
'η' => 'η',
'θ' => 'θ',
'ι' => 'ι',
'κ' => 'κ',
'λ' => 'λ',
'μ' => 'μ',
'ν' => 'ν',
'ξ' => 'ξ',
'ο' => 'ο',
'π' => 'π',
'ρ' => 'ρ',
'ς' => 'ς',
'σ' => 'σ',
'τ' => 'τ',
'υ' => 'υ',
'φ' => 'φ',
'χ' => 'χ',
'ψ' => 'ψ',
'ω' => 'ω',
'ϒ' => 'ϒ',
'ϖ' => 'ϖ',
'•' => '•',
'…' => '…',
'′' => '′',
'″' => '″',
'‾' => '‾',
'⁄' => '⁄',
'℘' => '℘',
'ℑ' => 'ℑ',
'ℜ' => 'ℜ',
'™' => '™',
'ℵ' => 'ℵ',
'←' => '←',
'↑' => '↑',
'→' => '→',
'↓' => '↓',
'↔' => '↔',
'↵' => '↵',
'⇐' => '⇐',
'⇑' => '⇑',
'⇒' => '⇒',
'⇓' => '⇓',
'⇔' => '⇔',
'∀' => '∀',
'∂' => '∂',
'∃' => '∃',
'∅' => '∅',
'∇' => '∇',
'∈' => '∈',
'∉' => '∉',
'∋' => '∋',
'∏' => '∏',
'∑' => '∑',
'−' => '−',
'∗' => '∗',
'√' => '√',
'∝' => '∝',
'∞' => '∞',
'∠' => '∠',
'∧' => '∧',
'∨' => '∨',
'∩' => '∩',
'∪' => '∪',
'∫' => '∫',
'∴' => '∴',
'∼' => '∼',
'≅' => '≅',
'≈' => '≈',
'≠' => '≠',
'≡' => '≡',
'≤' => '≤',
'≥' => '≥',
'⊂' => '⊂',
'⊃' => '⊃',
'⊄' => '⊄',
'⊆' => '⊆',
'⊇' => '⊇',
'⊕' => '⊕',
'⊗' => '⊗',
'⊥' => '⊥',
'⋅' => '⋅',
'⌈' => '⌈',
'⌉' => '⌉',
'⌊' => '⌊',
'⌋' => '⌋',
'⟨' => '〈',
'⟩' => '〉',
'◊' => '◊',
'♠' => '♠',
'♣' => '♣',
'♥' => '♥',
'♦' => '♦',
];
if (strpos($text, '&') !== FALSE) {
$text = strtr($text, $replace);
}
return $text;
}
}