From 5b1940f0ba758e619cb593a08137fc04c5eea1c6 Mon Sep 17 00:00:00 2001 From: Tilman Vatteroth Date: Mon, 8 Feb 2021 18:29:02 +0100 Subject: [PATCH] Add quote extra markdown it plugin (#1020) Signed-off-by: Tilman Vatteroth --- cypress/integration/quote-extra.spec.ts | 75 ++++++++++ cypress/support/fill.ts | 3 +- src/components/common/fork-awesome/types.d.ts | 122 +---------------- .../use-replacer-instance-list-creator.ts | 4 +- .../FullMarkdownItConfigurator.tsx | 11 +- .../markdown-it-plugins/parser-debugger.ts | 2 +- .../markdown-it-plugins/quote-extra-color.ts | 39 ++++++ .../markdown-it-plugins/quote-extra.test.ts | 72 ++++++++++ .../markdown-it-plugins/quote-extra.ts | 128 ++++++++++++++---- .../colored-blockquote-replacer.tsx} | 13 +- 10 files changed, 309 insertions(+), 160 deletions(-) create mode 100644 cypress/integration/quote-extra.spec.ts create mode 100644 src/components/markdown-renderer/markdown-it-plugins/quote-extra-color.ts create mode 100644 src/components/markdown-renderer/markdown-it-plugins/quote-extra.test.ts rename src/components/markdown-renderer/replace-components/{quote-options/quote-options-replacer.tsx => colored-blockquote/colored-blockquote-replacer.tsx} (74%) diff --git a/cypress/integration/quote-extra.spec.ts b/cypress/integration/quote-extra.spec.ts new file mode 100644 index 000000000..29b0d25ae --- /dev/null +++ b/cypress/integration/quote-extra.spec.ts @@ -0,0 +1,75 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +describe('Quote extra tags', function () { + beforeEach(() => { + cy.visitTestEditor() + }) + + describe('Name quote tag', () => { + it('renders correctly', () => { + cy.codemirrorFill('[name=testy mctestface]') + + cy.getMarkdownBody() + .find('.quote-extra') + .should('be.visible') + .find('.fa-user') + .should('be.visible') + + cy.getMarkdownBody() + .find('.quote-extra') + .should('be.visible') + .contains('testy mctestface') + }) + }) + + describe('Time quote tag', () => { + it('renders correctly', () => { + cy.codemirrorFill(`[time=always]`) + + cy.getMarkdownBody() + .find('.quote-extra') + .should('be.visible') + .find('.fa-clock-o') + .should('be.visible') + + cy.getMarkdownBody() + .find('.quote-extra') + .should('be.visible') + .contains('always') + }) + }) + + describe('Color quote tag', () => { + it('renders correctly', () => { + cy.codemirrorFill(`[color=#b51f08]`) + + cy.getMarkdownBody() + .find('.quote-extra') + .should('be.visible') + .find('.fa-tag') + .should('be.visible') + + cy.getMarkdownBody() + .find('.quote-extra') + .should('be.visible') + .should('have.css', 'color', 'rgb(181, 31, 8)') + }) + + it('doesn\'t render in a blockquote and dyes the blockquote border', () => { + cy.codemirrorFill(`> [color=#b51f08] HedgeDoc`) + + cy.getMarkdownBody() + .find('.quote-extra') + .should('not.exist') + + cy.getMarkdownBody() + .find('blockquote') + .should('be.visible') + .should('have.css', 'border-left-color', 'rgb(181, 31, 8)') + }) + }) +}) diff --git a/cypress/support/fill.ts b/cypress/support/fill.ts index 0f35de554..62b103f6c 100644 --- a/cypress/support/fill.ts +++ b/cypress/support/fill.ts @@ -34,6 +34,7 @@ Cypress.Commands.add('codemirrorFill', (content: string) => { .fill(content) if (line) { cy.get('.CodeMirror') - .contains('.CodeMirror-line', line) + .find('.CodeMirror-line') + .should('contain.text', line) } }) diff --git a/src/components/common/fork-awesome/types.d.ts b/src/components/common/fork-awesome/types.d.ts index 37d52523e..d2bde0290 100644 --- a/src/components/common/fork-awesome/types.d.ts +++ b/src/components/common/fork-awesome/types.d.ts @@ -4,123 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -export type IconName = '500px' | 'activitypub' | 'address-book-o' | 'address-book' | 'address-card-o' | 'address-card' - | 'adjust' | 'adn' | 'align-center' | 'align-justify' | 'align-left' | 'align-right' | 'amazon' - | 'ambulance' | 'american-sign-language-interpreting' | 'anchor' | 'android' | 'angellist' - | 'angle-double-down' | 'angle-double-left' | 'angle-double-right' | 'angle-double-up' - | 'angle-down' | 'angle-left' | 'angle-right' | 'angle-up' | 'apple' | 'archive-org' | 'archive' - | 'archlinux' | 'area-chart' | 'arrow-circle-down' | 'arrow-circle-left' | 'arrow-circle-o-down' - | 'arrow-circle-o-left' | 'arrow-circle-o-right' | 'arrow-circle-o-up' | 'arrow-circle-right' - | 'arrow-circle-up' | 'arrow-down' | 'arrow-left' | 'arrow-right' | 'arrows-alt' | 'arrows-h' - | 'arrows' | 'arrows-v' | 'arrow-up' | 'artstation' | 'assistive-listening-systems' | 'asterisk' - | 'at' | 'att' | 'audio-description' | 'backward' | 'balance-scale' | 'bandcamp' | 'ban' - | 'bar-chart' | 'barcode' | 'bars' | 'bath' | 'battery-empty' | 'battery-full' | 'battery-half' - | 'battery-quarter' | 'battery-three-quarters' | 'bed' | 'beer' | 'behance-square' | 'behance' - | 'bell-o' | 'bell-rigning-o' | 'bell-ringing' | 'bell-slash-o' | 'bell-slash' | 'bell' - | 'bicycle' | 'binoculars' | 'biometric' | 'birthday-cake' | 'bitbucket-square' | 'bitbucket' - | 'black-tie' | 'blind' | 'bluetooth-b' | 'bluetooth' | 'bold' | 'bolt' | 'bomb' | 'bookmark-o' - | 'bookmark' | 'book' | 'bootstrap' | 'braille' | 'briefcase' | 'btc' | 'bug' | 'building-o' - | 'building' | 'bullhorn' | 'bullseye' | 'bus' | 'buysellads' | 'calculator' | 'calendar-check-o' - | 'calendar-minus-o' | 'calendar-o' | 'calendar-plus-o' | 'calendar' | 'calendar-times-o' - | 'camera-retro' | 'camera' | 'caret-down' | 'caret-left' | 'caret-right' | 'caret-square-o-down' - | 'caret-square-o-left' | 'caret-square-o-right' | 'caret-square-o-up' | 'caret-up' | 'car' - | 'cart-arrow-down' | 'cart-plus' | 'cc-amex' | 'cc-diners-club' | 'cc-discover' | 'cc-jcb' - | 'cc-mastercard' | 'cc-paypal' | 'cc-stripe' | 'cc' | 'cc-visa' | 'certificate' | 'chain-broken' - | 'check-circle-o' | 'check-circle' | 'check-square-o' | 'check-square' | 'check' - | 'chevron-circle-down' | 'chevron-circle-left' | 'chevron-circle-right' | 'chevron-circle-up' - | 'chevron-down' | 'chevron-left' | 'chevron-right' | 'chevron-up' | 'child' | 'chrome' - | 'circle-o-notch' | 'circle-o' | 'circle' | 'circle-thin' | 'classicpress-circle' - | 'classicpress' | 'clipboard' | 'clock-o' | 'clone' | 'cloud-download' | 'cloud' - | 'cloud-upload' | 'code-fork' | 'codepen' | 'code' | 'codiepie' | 'coffee' | 'cogs' | 'cog' - | 'columns' | 'commenting-o' | 'commenting' | 'comment-o' | 'comments-o' | 'comments' | 'comment' - | 'compass' | 'compress' | 'connectdevelop' | 'contao' | 'copyright' | 'creative-commons' - | 'credit-card-alt' | 'credit-card' | 'crop' | 'crosshairs' | 'css3' | 'c' | 'cubes' | 'cube' - | 'cutlery' | 'dashcube' | 'database' | 'deaf' | 'debian' | 'delicious' | 'desktop' - | 'deviantart' | 'dev-to' | 'diamond' | 'diaspora' | 'digg' | 'digitalocean' | 'discord-alt' - | 'discord' | 'dogmazic' | 'dot-circle-o' | 'download' | 'dribbble' | 'dropbox' | 'drupal' - | 'edge' | 'eercast' | 'eject' | 'ellipsis-h' | 'ellipsis-v' | 'emby' | 'empire' - | 'envelope-open-o' | 'envelope-open' | 'envelope-o' | 'envelope-square' | 'envelope' | 'envira' - | 'eraser' | 'ethereum' | 'etsy' | 'eur' | 'exchange' | 'exclamation-circle' | 'exclamation' - | 'exclamation-triangle' | 'expand' | 'expeditedssl' | 'external-link-square' | 'external-link' - | 'eyedropper' | 'eye-slash' | 'eye' | 'facebook-messenger' | 'facebook-official' - | 'facebook-square' | 'facebook' | 'fast-backward' | 'fast-forward' | 'fax' | 'f-droid' - | 'female' | 'ffmpeg' | 'fighter-jet' | 'file-archive-o' | 'file-audio-o' | 'file-code-o' - | 'file-epub' | 'file-excel-o' | 'file-image-o' | 'file-o' | 'file-pdf-o' | 'file-powerpoint-o' - | 'files-o' | 'file' | 'file-text-o' | 'file-text' | 'file-video-o' | 'file-word-o' | 'film' - | 'filter' | 'fire-extinguisher' | 'firefox' | 'fire' | 'first-order' | 'flag-checkered' - | 'flag-o' | 'flag' | 'flask' | 'flickr' | 'floppy-o' | 'folder-open-o' | 'folder-open' - | 'folder-o' | 'folder' | 'font-awesome' | 'fonticons' | 'font' | 'fork-awesome' | 'fort-awesome' - | 'forumbee' | 'forward' | 'foursquare' | 'free-code-camp' | 'freedombox' | 'friendica' - | 'frown-o' | 'funkwhale' | 'futbol-o' | 'gamepad' | 'gavel' | 'gbp' | 'genderless' - | 'get-pocket' | 'gg-circle' | 'gg' | 'gift' | 'gimp' | 'gitea' | 'github-alt' | 'github-square' - | 'github' | 'gitlab' | 'git-square' | 'git' | 'glass' | 'glide-g' | 'glide' | 'globe-e' - | 'globe' | 'globe-w' | 'gnupg' | 'gnu-social' | 'google-plus-official' | 'google-plus-square' - | 'google-plus' | 'google' | 'google-wallet' | 'graduation-cap' | 'gratipay' | 'grav' - | 'hackaday' | 'hacker-news' | 'hackster' | 'hal' | 'hand-lizard-o' | 'hand-o-down' - | 'hand-o-left' | 'hand-o-right' | 'hand-o-up' | 'hand-paper-o' | 'hand-peace-o' - | 'hand-pointer-o' | 'hand-rock-o' | 'hand-scissors-o' | 'handshake-o' | 'hand-spock-o' - | 'hashnode' | 'hashtag' | 'hdd-o' | 'header' | 'headphones' | 'heartbeat' | 'heart-o' | 'heart' - | 'history' | 'home' | 'hospital-o' | 'hourglass-end' | 'hourglass-half' | 'hourglass-o' - | 'hourglass-start' | 'hourglass' | 'houzz' | 'h-square' | 'html5' | 'hubzilla' | 'i-cursor' - | 'id-badge' | 'id-card-o' | 'id-card' | 'ils' | 'imdb' | 'inbox' | 'indent' | 'industry' - | 'info-circle' | 'info' | 'inkscape' | 'inr' | 'instagram' | 'internet-explorer' | 'ioxhost' - | 'italic' | 'jirafeau' | 'joomla' | 'joplin' | 'jpy' | 'jsfiddle' | 'julia' | 'jupyter' - | 'keybase' | 'keyboard-o' | 'key-modern' | 'key' | 'krw' | 'language' | 'laptop' | 'laravel' - | 'lastfm-square' | 'lastfm' | 'leaf' | 'leanpub' | 'lemon-o' | 'level-down' | 'level-up' - | 'liberapay-square' | 'liberapay' | 'life-ring' | 'lightbulb-o' | 'line-chart' - | 'linkedin-square' | 'linkedin' | 'link' | 'linode' | 'linux' | 'list-alt' | 'list-ol' | 'list' - | 'list-ul' | 'location-arrow' | 'lock' | 'long-arrow-down' | 'long-arrow-left' - | 'long-arrow-right' | 'long-arrow-up' | 'low-vision' | 'magic' | 'magnet' | 'male' - | 'map-marker' | 'map-o' | 'map-pin' | 'map-signs' | 'map' | 'mars-double' | 'mars-stroke-h' - | 'mars-stroke' | 'mars-stroke-v' | 'mars' | 'mastodon-alt' | 'mastodon-square' | 'mastodon' - | 'matrix-org' | 'maxcdn' | 'meanpath' | 'medium-square' | 'medium' | 'medkit' | 'meetup' - | 'meh-o' | 'mercury' | 'microchip' | 'microphone-slash' | 'microphone' | 'minus-circle' - | 'minus-square-o' | 'minus-square' | 'minus' | 'mixcloud' | 'mobile' | 'modx' | 'money' - | 'moon-o' | 'moon' | 'motorcycle' | 'mouse-pointer' | 'music' | 'neuter' | 'newspaper-o' - | 'nextcloud-square' | 'nextcloud' | 'nodejs' | 'object-group' | 'object-ungroup' - | 'odnoklassniki-square' | 'odnoklassniki' | 'opencart' | 'open-collective' | 'openid' | 'opera' - | 'optin-monster' | 'orcid' | 'outdent' | 'pagelines' | 'paint-brush' | 'paperclip' - | 'paper-plane-o' | 'paper-plane' | 'paragraph' | 'patreon' | 'pause-circle-o' | 'pause-circle' - | 'pause' | 'paw' | 'paypal' | 'peertube' | 'pencil-square-o' | 'pencil-square' | 'pencil' - | 'percent' | 'phone-square' | 'phone' | 'php' | 'picture-o' | 'pie-chart' | 'pinterest-p' - | 'pinterest-square' | 'pinterest' | 'pixelfed' | 'plane' | 'play-circle-o' | 'play-circle' - | 'play' | 'pleroma' | 'plug' | 'plus-circle' | 'plus-square-o' | 'plus-square' | 'plus' - | 'podcast' | 'power-off' | 'print' | 'product-hunt' | 'puzzle-piece' | 'python' | 'qq' - | 'qrcode' | 'question-circle-o' | 'question-circle' | 'question' | 'quora' | 'quote-left' - | 'quote-right' | 'random' | 'ravelry' | 'react' | 'rebel' | 'recycle' | 'reddit-alien' - | 'reddit-square' | 'reddit' | 'refresh' | 'registered' | 'renren' | 'repeat' | 'reply-all' - | 'reply' | 'researchgate' | 'retweet' | 'road' | 'rocket' | 'rss-square' | 'rss' | 'rub' - | 'safari' | 'scissors' | 'scribd' | 'scuttlebutt' | 'search-minus' | 'search-plus' | 'search' - | 'sellsy' | 'server' | 'shaarli-o' | 'shaarli' | 'share-alt-square' | 'share-alt' - | 'share-square-o' | 'share-square' | 'share' | 'shield' | 'ship' | 'shirtsinbulk' - | 'shopping-bag' | 'shopping-basket' | 'shopping-cart' | 'shower' | 'signalapp' | 'signal' - | 'sign-in' | 'sign-language' | 'sign-out' | 'simplybuilt' | 'sitemap' | 'skyatlas' | 'skype' - | 'slack' | 'sliders' | 'slideshare' | 'smile-o' | 'snapchat-ghost' | 'snapchat-square' - | 'snapchat' | 'snowdrift' | 'snowflake-o' | 'social-home' | 'sort-alpha-asc' | 'sort-alpha-desc' - | 'sort-amount-asc' | 'sort-amount-desc' | 'sort-asc' | 'sort-desc' | 'sort-numeric-asc' - | 'sort-numeric-desc' | 'sort' | 'soundcloud' | 'space-shuttle' | 'spell-check' | 'spinner' - | 'spoon' | 'spotify' | 'square-o' | 'square' | 'stack-exchange' | 'stack-overflow' - | 'star-half-o' | 'star-half' | 'star-o' | 'star' | 'steam-square' | 'steam' | 'step-backward' - | 'step-forward' | 'stethoscope' | 'sticky-note-o' | 'sticky-note' | 'stop-circle-o' - | 'stop-circle' | 'stop' | 'street-view' | 'strikethrough' | 'stumbleupon-circle' | 'stumbleupon' - | 'subscript' | 'subway' | 'suitcase' | 'sun-o' | 'sun' | 'superpowers' | 'superscript' - | 'syncthing' | 'table' | 'tablet' | 'tachometer' | 'tags' | 'tag' | 'tasks' | 'taxi' - | 'telegram' | 'television' | 'tencent-weibo' | 'terminal' | 'text-height' | 'text-width' - | 'themeisle' | 'thermometer-empty' | 'thermometer-full' | 'thermometer-half' - | 'thermometer-quarter' | 'thermometer-three-quarters' | 'th-large' | 'th-list' | 'th' - | 'thumbs-down' | 'thumbs-o-down' | 'thumbs-o-up' | 'thumbs-up' | 'thumb-tack' | 'ticket' - | 'times-circle-o' | 'times-circle' | 'times' | 'tint' | 'tipeee' | 'toggle-off' | 'toggle-on' - | 'tor-onion' | 'trademark' | 'train' | 'transgender-alt' | 'transgender' | 'trash-o' | 'trash' - | 'tree' | 'trello' | 'tripadvisor' | 'trophy' | 'truck' | 'try' | 'tty' | 'tumblr-square' - | 'tumblr' | 'twitch' | 'twitter-square' | 'twitter' | 'umbrella' | 'underline' | 'undo' - | 'universal-access' | 'university' | 'unlock-alt' | 'unlock' | 'unslpash' | 'upload' | 'usb' - | 'usd' | 'user-circle-o' | 'user-circle' | 'user-md' | 'user-o' | 'user-plus' | 'user-secret' - | 'users' | 'user' | 'user-times' | 'venus-double' | 'venus-mars' | 'venus' | 'viacoin' - | 'viadeo-square' | 'viadeo' | 'video-camera' | 'vimeo-square' | 'vimeo' | 'vine' | 'vk' - | 'volume-control-phone' | 'volume-down' | 'volume-mute' | 'volume-off' | 'volume-up' | 'weibo' - | 'weixin' | 'whatsapp' | 'wheelchair-alt' | 'wheelchair' | 'wifi' | 'wikidata' | 'wikipedia-w' - | 'window-close-o' | 'window-close' | 'window-maximize' | 'window-minimize' | 'window-restore' - | 'windows' | 'wire' | 'wordpress' | 'wpbeginner' | 'wpexplorer' | 'wpforms' | 'wrench' - | 'xing-square' | 'xing' | 'xmpp' | 'yahoo' | 'y-combinator' | 'yelp' | 'yoast' | 'youtube-play' - | 'youtube-square' | 'youtube' | 'zotero' +import { ForkAwesomeIcons } from '../../editor-page/editor-pane/tool-bar/emoji-picker/icon-names' + +export type IconName = keyof typeof ForkAwesomeIcons export type IconSize = '2x' | '3x' | '4x' | '5x' diff --git a/src/components/markdown-renderer/hooks/use-replacer-instance-list-creator.ts b/src/components/markdown-renderer/hooks/use-replacer-instance-list-creator.ts index 654e83e91..01d049f9e 100644 --- a/src/components/markdown-renderer/hooks/use-replacer-instance-list-creator.ts +++ b/src/components/markdown-renderer/hooks/use-replacer-instance-list-creator.ts @@ -19,7 +19,7 @@ import { LinemarkerReplacer } from '../replace-components/linemarker/linemarker- import { LinkReplacer } from '../replace-components/link-replacer/link-replacer' import { MarkmapReplacer } from '../replace-components/markmap/markmap-replacer' import { MermaidReplacer } from '../replace-components/mermaid/mermaid-replacer' -import { QuoteOptionsReplacer } from '../replace-components/quote-options/quote-options-replacer' +import { ColoredBlockquoteReplacer } from '../replace-components/colored-blockquote/colored-blockquote-replacer' import { SequenceDiagramReplacer } from '../replace-components/sequence-diagram/sequence-diagram-replacer' import { TaskListReplacer } from '../replace-components/task-list/task-list-replacer' import { VegaReplacer } from '../replace-components/vega-lite/vega-replacer' @@ -45,7 +45,7 @@ export const useReplacerInstanceListCreator = (onTaskCheckedChange?: (lineInMark new MarkmapReplacer(), new VegaReplacer(), new HighlightedCodeReplacer(), - new QuoteOptionsReplacer(), + new ColoredBlockquoteReplacer(), new KatexReplacer(), new TaskListReplacer(onTaskCheckedChange) ], [onImageClick, onTaskCheckedChange, baseUrl]) diff --git a/src/components/markdown-renderer/markdown-it-configurator/FullMarkdownItConfigurator.tsx b/src/components/markdown-renderer/markdown-it-configurator/FullMarkdownItConfigurator.tsx index 1e45cc9d9..713bf3bc8 100644 --- a/src/components/markdown-renderer/markdown-it-configurator/FullMarkdownItConfigurator.tsx +++ b/src/components/markdown-renderer/markdown-it-configurator/FullMarkdownItConfigurator.tsx @@ -22,6 +22,7 @@ import { LineMarkers, lineNumberMarker } from '../replace-components/linemarker/ import { VimeoReplacer } from '../replace-components/vimeo/vimeo-replacer' import { YoutubeReplacer } from '../replace-components/youtube/youtube-replacer' import { BasicMarkdownItConfigurator } from './BasicMarkdownItConfigurator' +import { quoteExtraColor } from '../markdown-it-plugins/quote-extra-color' export class FullMarkdownItConfigurator extends BasicMarkdownItConfigurator { constructor( @@ -57,7 +58,15 @@ export class FullMarkdownItConfigurator extends BasicMarkdownItConfigurator { legacySpeakerdeckShortCode, AsciinemaReplacer.markdownItPlugin, highlightedCode, - quoteExtra, + quoteExtraColor, + quoteExtra({ + quoteLabel: 'name', + icon: 'user' + }), + quoteExtra({ + quoteLabel: 'time', + icon: 'clock-o' + }), (markdownIt) => documentToc(markdownIt, this.onToc)) if (this.onLineMarkers) { const callback = this.onLineMarkers diff --git a/src/components/markdown-renderer/markdown-it-plugins/parser-debugger.ts b/src/components/markdown-renderer/markdown-it-plugins/parser-debugger.ts index eaa3fcb1e..e8f06a738 100644 --- a/src/components/markdown-renderer/markdown-it-plugins/parser-debugger.ts +++ b/src/components/markdown-renderer/markdown-it-plugins/parser-debugger.ts @@ -10,7 +10,7 @@ export const MarkdownItParserDebugger: MarkdownIt.PluginSimple = (md: MarkdownIt if (process.env.NODE_ENV !== 'production') { md.core.ruler.push('test', (state) => { console.log(state) - return true + return false }) } } diff --git a/src/components/markdown-renderer/markdown-it-plugins/quote-extra-color.ts b/src/components/markdown-renderer/markdown-it-plugins/quote-extra-color.ts new file mode 100644 index 000000000..d4afd62c8 --- /dev/null +++ b/src/components/markdown-renderer/markdown-it-plugins/quote-extra-color.ts @@ -0,0 +1,39 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import MarkdownIt from 'markdown-it/lib' +import { parseQuoteExtraTag } from './quote-extra' + +const cssColorRegex = /(#(?:[0-9a-f]{2}){2,4}|(?:#[0-9a-f]{3})|black|silver|gray|whitesmoke|maroon|red|purple|fuchsia|green|lime|olivedrab|yellow|navy|blue|teal|aquamarine|orange|aliceblue|antiquewhite|aqua|azure|beige|bisque|blanchedalmond|blueviolet|brown|burlywood|cadetblue|chartreuse|chocolate|coral|cornflowerblue|cornsilk|crimson|currentcolor|darkblue|darkcyan|darkgoldenrod|darkgray|darkgreen|darkgrey|darkkhaki|darkmagenta|darkolivegreen|darkorange|darkorchid|darkred|darksalmon|darkseagreen|darkslateblue|darkslategray|darkslategrey|darkturquoise|darkviolet|deeppink|deepskyblue|dimgray|dimgrey|dodgerblue|firebrick|floralwhite|forestgreen|gainsboro|ghostwhite|goldenrod|gold|greenyellow|grey|honeydew|hotpink|indianred|indigo|ivory|khaki|lavenderblush|lavender|lawngreen|lemonchiffon|lightblue|lightcoral|lightcyan|lightgoldenrodyellow|lightgray|lightgreen|lightgrey|lightpink|lightsalmon|lightseagreen|lightskyblue|lightslategray|lightslategrey|lightsteelblue|lightyellow|limegreen|linen|mediumaquamarine|mediumblue|mediumorchid|mediumpurple|mediumseagreen|mediumslateblue|mediumspringgreen|mediumturquoise|mediumvioletred|midnightblue|mintcream|mistyrose|moccasin|navajowhite|oldlace|olive|orangered|orchid|palegoldenrod|palegreen|paleturquoise|palevioletred|papayawhip|peachpuff|peru|pink|plum|powderblue|rebeccapurple|rosybrown|royalblue|saddlebrown|salmon|sandybrown|seagreen|seashell|sienna|skyblue|slateblue|slategray|slategrey|snow|springgreen|steelblue|tan|thistle|tomato|transparent|turquoise|violet|wheat|white|yellowgreen)/i + +export const quoteExtraColor: MarkdownIt.PluginSimple = (md) => { + md.inline.ruler.push(`extraQuote_color`, (state) => { + const quoteExtraTagValues = parseQuoteExtraTag(state.src, state.pos, state.posMax) + + if (!quoteExtraTagValues || quoteExtraTagValues.label !== 'color') { + return false + } + state.pos = quoteExtraTagValues.valueEndIndex + 1 + + if (!cssColorRegex.exec(quoteExtraTagValues.value)) { + return false + } + + state.pos = quoteExtraTagValues.valueEndIndex + 1 + + const token = state.push('quote-extra-color', '', 0) + token.attrSet('color', quoteExtraTagValues.value) + + return true + }) + + md.renderer.rules['quote-extra-color'] = (tokens, idx) => { + const token = tokens[idx] + const color = token.attrGet('color') ?? '' + + return `` + } +} diff --git a/src/components/markdown-renderer/markdown-it-plugins/quote-extra.test.ts b/src/components/markdown-renderer/markdown-it-plugins/quote-extra.test.ts new file mode 100644 index 000000000..bca7115a6 --- /dev/null +++ b/src/components/markdown-renderer/markdown-it-plugins/quote-extra.test.ts @@ -0,0 +1,72 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { parseQuoteExtraTag, QuoteExtraTagValues } from './quote-extra' + +describe('Quote extra syntax parser', () => { + it('should parse a valid tag', () => { + const expected: QuoteExtraTagValues = { + labelStartIndex: 1, + labelEndIndex: 4, + valueStartIndex: 5, + valueEndIndex: 8, + label: 'abc', + value: 'def' + } + expect(parseQuoteExtraTag('[abc=def]', 0, 1000)) + .toEqual(expected) + }) + + it('shouldn\'t parse a tag with no opener bracket', () => { + expect(parseQuoteExtraTag('abc=def]', 0, 1000)) + .toEqual(undefined) + }) + + it('shouldn\'t parse a tag with no closing bracket', () => { + expect(parseQuoteExtraTag('[abc=def', 0, 1000)) + .toEqual(undefined) + }) + + it('shouldn\'t parse a tag with no separation character', () => { + expect(parseQuoteExtraTag('[abcdef]', 0, 1000)) + .toEqual(undefined) + }) + + it('shouldn\'t parse a tag with an empty label', () => { + expect(parseQuoteExtraTag('[=def]', 0, 1000)) + .toEqual(undefined) + }) + + it('shouldn\'t parse a tag with an empty value', () => { + expect(parseQuoteExtraTag('[abc=]', 0, 1000)) + .toEqual(undefined) + }) + + it('shouldn\'t parse a tag with an empty body', () => { + expect(parseQuoteExtraTag('[]', 0, 1000)) + .toEqual(undefined) + }) + + it('shouldn\'t parse a tag with an empty body', () => { + expect(parseQuoteExtraTag('[]', 0, 1000)) + .toEqual(undefined) + }) + + it('shouldn\'t parse a correct tag if start index isn\'t at the opening bracket', () => { + expect(parseQuoteExtraTag('[abc=def]', 1, 1000)) + .toEqual(undefined) + }) + + it('shouldn\'t parse a correct tag if maxPos ends before tag end', () => { + expect(parseQuoteExtraTag('[abc=def]', 0, 1)) + .toEqual(undefined) + }) + + it('shouldn\'t parse a correct tag if start index is after maxPos', () => { + expect(parseQuoteExtraTag(' [abc=def]', 3, 2)) + .toEqual(undefined) + }) +}) diff --git a/src/components/markdown-renderer/markdown-it-plugins/quote-extra.ts b/src/components/markdown-renderer/markdown-it-plugins/quote-extra.ts index e7ed2f9f2..685e182c7 100644 --- a/src/components/markdown-renderer/markdown-it-plugins/quote-extra.ts +++ b/src/components/markdown-renderer/markdown-it-plugins/quote-extra.ts @@ -4,44 +4,112 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import MarkdownIt from 'markdown-it' -import markdownItRegex from 'markdown-it-regex' -import { RegexOptions } from '../../../external-types/markdown-it-regex/interface' +import MarkdownIt from 'markdown-it/lib' +import Token from 'markdown-it/lib/token' +import { IconName } from '../../common/fork-awesome/types' -export const quoteExtra: MarkdownIt.PluginSimple = (markdownIt) => { - markdownItRegex(markdownIt, replaceQuoteExtraAuthor) - markdownItRegex(markdownIt, replaceQuoteExtraColor) - markdownItRegex(markdownIt, replaceQuoteExtraTime) +export interface QuoteExtraOptions { + quoteLabel: string + icon: IconName } -const replaceQuoteExtraTime: RegexOptions = { - name: 'quote-extra-time', - regex: /\[time=([^\]]+)]/, - replace: (match) => { - // ESLint wants to collapse this tag, but then the tag won't be valid html anymore. - // noinspection CheckTagEmptyBody - return ` ${ match }` +export const quoteExtra: (pluginOptions: QuoteExtraOptions) => MarkdownIt.PluginSimple = + (pluginOptions) => (md) => { + md.inline.ruler.push(`extraQuote_${ pluginOptions.quoteLabel }`, (state) => { + const quoteExtraTagValues = parseQuoteExtraTag(state.src, state.pos, state.posMax) + + if (!quoteExtraTagValues || quoteExtraTagValues.label !== pluginOptions.quoteLabel) { + return false + } + state.pos = quoteExtraTagValues.valueEndIndex + 1 + + const tokens: Token[] = [] + state.md.inline.parse( + quoteExtraTagValues.value, + state.md, + state.env, + tokens + ) + + const token = state.push('quote-extra', '', 0) + token.attrSet('icon', pluginOptions.icon) + token.children = tokens + + return true + }) + + if (md.renderer.rules['quote-extra']) { + return + } + + md.renderer.rules['quote-extra'] = (tokens, idx, options: MarkdownIt.Options, env: unknown) => { + const token = tokens[idx] + const innerTokens = token.children + + if (!innerTokens) { + return '' + } + + const innerHtml = md.renderer.renderInline(innerTokens, options, env) + return `${ innerHtml }` + } + } + +export interface QuoteExtraTagValues { + labelStartIndex: number, + labelEndIndex: number, + valueStartIndex: number, + valueEndIndex: number, + label: string, + value: string +} + +export const parseQuoteExtraTag = (line: string, start: number, maxPos: number): QuoteExtraTagValues | undefined => { + if (line[start] !== '[') { + return + } + + const labelStartIndex = start + 1 + const labelEndIndex = parseLabel(line, labelStartIndex, maxPos) + if (!labelEndIndex || labelStartIndex === labelEndIndex) { + return + } + + const valueStartIndex = labelEndIndex + 1 + const valueEndIndex = parseValue(line, valueStartIndex, maxPos) + if (!valueEndIndex || valueStartIndex === valueEndIndex) { + return + } + + return { + labelStartIndex, + labelEndIndex, + valueStartIndex, + valueEndIndex, + label: line.substr(labelStartIndex, labelEndIndex - labelStartIndex), + value: line.substr(valueStartIndex, valueEndIndex - valueStartIndex) } } -const cssColorRegex = /\[color=(#(?:[0-9a-f]{2}){2,4}|(?:#[0-9a-f]{3})|black|silver|gray|whitesmoke|maroon|red|purple|fuchsia|green|lime|olivedrab|yellow|navy|blue|teal|aquamarine|orange|aliceblue|antiquewhite|aqua|azure|beige|bisque|blanchedalmond|blueviolet|brown|burlywood|cadetblue|chartreuse|chocolate|coral|cornflowerblue|cornsilk|crimson|currentcolor|darkblue|darkcyan|darkgoldenrod|darkgray|darkgreen|darkgrey|darkkhaki|darkmagenta|darkolivegreen|darkorange|darkorchid|darkred|darksalmon|darkseagreen|darkslateblue|darkslategray|darkslategrey|darkturquoise|darkviolet|deeppink|deepskyblue|dimgray|dimgrey|dodgerblue|firebrick|floralwhite|forestgreen|gainsboro|ghostwhite|goldenrod|gold|greenyellow|grey|honeydew|hotpink|indianred|indigo|ivory|khaki|lavenderblush|lavender|lawngreen|lemonchiffon|lightblue|lightcoral|lightcyan|lightgoldenrodyellow|lightgray|lightgreen|lightgrey|lightpink|lightsalmon|lightseagreen|lightskyblue|lightslategray|lightslategrey|lightsteelblue|lightyellow|limegreen|linen|mediumaquamarine|mediumblue|mediumorchid|mediumpurple|mediumseagreen|mediumslateblue|mediumspringgreen|mediumturquoise|mediumvioletred|midnightblue|mintcream|mistyrose|moccasin|navajowhite|oldlace|olive|orangered|orchid|palegoldenrod|palegreen|paleturquoise|palevioletred|papayawhip|peachpuff|peru|pink|plum|powderblue|rebeccapurple|rosybrown|royalblue|saddlebrown|salmon|sandybrown|seagreen|seashell|sienna|skyblue|slateblue|slategray|slategrey|snow|springgreen|steelblue|tan|thistle|tomato|transparent|turquoise|violet|wheat|white|yellowgreen)]/i - -const replaceQuoteExtraColor: RegexOptions = { - name: 'quote-extra-color', - regex: cssColorRegex, - replace: (match) => { - // ESLint wants to collapse this tag, but then the tag won't be valid html anymore. - // noinspection CheckTagEmptyBody - return `` +const parseValue = (line: string, start: number, maxPos: number): number | undefined => { + let level = 1 + for (let pos = start; pos <= maxPos; pos += 1) { + const currentCharacter = line[pos] + if (currentCharacter === ']') { + level -= 1 + if (level === 0) { + return pos + } + } else if (currentCharacter === '[') { + level += 1 + } } } -const replaceQuoteExtraAuthor: RegexOptions = { - name: 'quote-extra-name', - regex: /\[name=([^\]]+)]/, - replace: (match) => { - // ESLint wants to collapse this tag, but then the tag won't be valid html anymore. - // noinspection CheckTagEmptyBody - return ` ${ match }` +const parseLabel = (line: string, start: number, maxPos: number): number | undefined => { + for (let pos = start; pos <= maxPos; pos += 1) { + if (line[pos] === '=') { + return pos + } } } diff --git a/src/components/markdown-renderer/replace-components/quote-options/quote-options-replacer.tsx b/src/components/markdown-renderer/replace-components/colored-blockquote/colored-blockquote-replacer.tsx similarity index 74% rename from src/components/markdown-renderer/replace-components/quote-options/quote-options-replacer.tsx rename to src/components/markdown-renderer/replace-components/colored-blockquote/colored-blockquote-replacer.tsx index c9cfe4cab..83eb62e70 100644 --- a/src/components/markdown-renderer/replace-components/quote-options/quote-options-replacer.tsx +++ b/src/components/markdown-renderer/replace-components/colored-blockquote/colored-blockquote-replacer.tsx @@ -1,12 +1,12 @@ /* - SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) - - SPDX-License-Identifier: AGPL-3.0-only + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only */ import { DomElement } from 'domhandler' import { ReactElement } from 'react' -import { ComponentReplacer } from '../ComponentReplacer' +import { ComponentReplacer, NativeRenderer, SubNodeTransform } from '../ComponentReplacer' const isColorExtraElement = (node: DomElement | undefined): boolean => { if (!node || !node.attribs || !node.attribs.class || !node.attribs['data-color']) { @@ -24,8 +24,8 @@ const findQuoteOptionsParent = (nodes: DomElement[]): DomElement | undefined => }) } -export class QuoteOptionsReplacer extends ComponentReplacer { - public getReplacement(node: DomElement): ReactElement | undefined { +export class ColoredBlockquoteReplacer extends ComponentReplacer { + public getReplacement(node: DomElement, subNodeTransform: SubNodeTransform, nativeRenderer: NativeRenderer): ReactElement | undefined { if (node.name !== 'blockquote' || !node.children || node.children.length < 1) { return } @@ -44,5 +44,6 @@ export class QuoteOptionsReplacer extends ComponentReplacer { return } node.attribs = Object.assign(node.attribs || {}, { style: `border-left-color: ${ attributes['data-color'] };` }) + return nativeRenderer() } }