diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bdaaf4..5979125 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## [Unreleased] +### Added + +- [`::backdrop` pseudo-element](https://marpit.marp.app/inline-svg?id=backdrop-css-selector) matches to the container SVG when enabled inline SVG mode ([#313](https://github.com/marp-team/marpit/issues/313), [#319](https://github.com/marp-team/marpit/pull/319)) +- Allow setting [the option object](https://marpit-api.marp.app/marpit#~InlineSVGOptions) to `inlineSVG` constructor option ([#319](https://github.com/marp-team/marpit/pull/319)) + ### Fixed - Remove recognized image keywords from alt text ([#316](https://github.com/marp-team/marpit/issues/316), [#318](https://github.com/marp-team/marpit/pull/318)) diff --git a/docs/inline-svg.md b/docs/inline-svg.md index 09eb230..f233544 100644 --- a/docs/inline-svg.md +++ b/docs/inline-svg.md @@ -17,9 +17,13 @@ When you set [`inlineSVG: true` in Marpit constructor option](/usage#triangular_ ``` +### Options + +`inlineSVG` constructor option also allows setting the option object. Refer to [Marpit API documentation](https://marpit-api.marp.app/marpit#~InlineSVGOptions) for details. + ## Motivation -It might feel a bit strange, but this approach has certain advantages. +You may feel it a bit strange. Why we have taken this approach? ### Pixel-perfect scaling @@ -57,9 +61,11 @@ Thus, a minimal web-based presentation no longer requires JavaScript. We strongl ### Isolated layer -Marpit's [advanced backgrounds](/image-syntax#advanced-backgrounds) would work within the isolated `` from the content. It means that the original Markdown DOM structure per page are keeping. +Marpit's [advanced backgrounds](/image-syntax#advanced-backgrounds) will work within the isolated `` from the content. It means that the original Markdown DOM structure per page are keeping. + +If advanced backgrounds were injected into the same layer as the Markdown content, inserted elements may break CSS selectors like [`:first-child` pseudo-class](https://developer.mozilla.org/docs/Web/CSS/:first-child) and [adjacent combinator (`+`)](https://developer.mozilla.org/docs/Web/CSS/Adjacent_sibling_combinator). -## Polyfill +## Webkit polyfill We provide a polyfill for WebKit based browsers in [@marp-team/marpit-svg-polyfill](https://github.com/marp-team/marpit-svg-polyfill). @@ -73,3 +79,40 @@ We provide a polyfill for WebKit based browsers in [@marp-team/marpit-svg-polyfi ``` + +## `::backdrop` CSS selector + +If enabled inline SVG mode, Marpit theme CSS and inline styles will redirect [`::backdrop` CSS selector](https://developer.mozilla.org/docs/Web/CSS/::backdrop) to the SVG container. + +A following rule matches to `` element. + +```css +::backdrop { + background-color: #448; +} +``` + +Some of Marpit integrated apps treats the background of SVG container as like as the backdrop of slide. By setting `background` style to the SVG container, you can change the color/image of slide [letterbox]()/[pillarbox](https://en.wikipedia.org/wiki/Pillarbox). + +Try resizing SVG container in below: + +
+ + +
+
+
+
+
+ +!> `::backdrop` pseudo-element does not limit applicable styles. To avoid unexpected effects into slides and apps, we strongly recommend to use this selector _only for changing the backdrop color_. + +### Disable + +If concerned conflict with styles for SVG container provided by app you are creating, you can disable `::backdrop` selector redirection separately by setting `backdropSelector` option as `false`. + +```javascript +const marpit = new Marpit({ + inlineSVG: { backdropSelector: false }, +}) +``` diff --git a/index.d.ts b/index.d.ts index a5b474e..01ac6a7 100644 --- a/index.d.ts +++ b/index.d.ts @@ -13,11 +13,16 @@ declare namespace Marpit { markdown?: any printable?: boolean slideContainer?: false | Element | Element[] - inlineSVG?: boolean + inlineSVG?: boolean | InlineSVGOptions } type HeadingDivider = 1 | 2 | 3 | 4 | 5 | 6 + type InlineSVGOptions = { + enabled?: boolean + backdropSelector?: boolean + } + type RenderResult = { html: T css: string diff --git a/src/markdown/background_image.js b/src/markdown/background_image.js index b8eaac8..0f2884e 100644 --- a/src/markdown/background_image.js +++ b/src/markdown/background_image.js @@ -9,11 +9,11 @@ import parse from './background_image/parse' * * Convert image token to backgrounds when the alternate text includes `bg`. * - * When Marpit `inlineSVG` option is `false`, the image will convert to + * When Marpit inline SVG mode is disabled, the image will convert to * `backgroundImage` and `backgroundSize` spot directive. It supports only * single background and resizing by using CSS. * - * When `inlineSVG` option is true, the plugin enables advanced background mode. + * When inline SVG mode is enabled, the plugin enables advanced background mode. * In addition to the basic background implementation, it supports multiple * background images, filters, and split background. * diff --git a/src/markdown/inline_svg.js b/src/markdown/inline_svg.js index 847327a..0626e00 100644 --- a/src/markdown/inline_svg.js +++ b/src/markdown/inline_svg.js @@ -16,7 +16,11 @@ function inlineSVG(md) { 'marpit_directives_parse', 'marpit_inline_svg', (state) => { - if (!marpit.options.inlineSVG || state.inlineMode) return + if ( + !(marpit.inlineSVGOptions && marpit.inlineSVGOptions.enabled) || + state.inlineMode + ) + return const { themeSet, lastGlobalDirectives } = marpit const w = themeSet.getThemeProp(lastGlobalDirectives.theme, 'widthPixel') diff --git a/src/marpit.js b/src/marpit.js index 31276e2..0062de8 100644 --- a/src/marpit.js +++ b/src/marpit.js @@ -29,12 +29,25 @@ const defaultOptions = { inlineSVG: false, } +const defaultInlineSVGOptions = { + enabled: true, + backdropSelector: true, +} + /** * Parse Marpit Markdown and render to the slide HTML/CSS. */ class Marpit { #markdown = undefined + /** + * @typedef {Object} Marpit~InlineSVGOptions + * @property {boolean} [enabled=true] Whether inline SVG mode is enabled. + * @property {boolean} [backdropSelector=true] Whether `::backdrop` selector + * support is enabled. If enabled, the `::backdrop` CSS selector will + * match to the SVG container element. + */ + /** * Create a Marpit instance. * @@ -54,8 +67,8 @@ class Marpit { * @param {boolean} [opts.printable=true] Make style printable to PDF. * @param {false|Element|Element[]} [opts.slideContainer] Container element(s) * wrapping each slide sections. - * @param {boolean} [opts.inlineSVG=false] Wrap each sections by inline SVG. - * _(Experimental)_ + * @param {boolean|Marpit~InlineSVGOptions} [opts.inlineSVG=false] Wrap each + * slide sections by inline SVG. _(Experimental)_ */ constructor(opts = {}) { /** @@ -225,11 +238,23 @@ class Marpit { ...wrapArray(this.options.container), ...wrapArray(this.options.slideContainer), ], - inlineSVG: this.options.inlineSVG, + inlineSVG: this.inlineSVGOptions, printable: this.options.printable, } } + /** + * @private + * @returns {Marpit~InlineSVGOptions} Options for inline SVG. + */ + get inlineSVGOptions() { + if (typeof this.options.inlineSVG === 'object') { + return { ...defaultInlineSVGOptions, ...this.options.inlineSVG } + } + + return { ...defaultInlineSVGOptions, enabled: !!this.options.inlineSVG } + } + /** * Load the specified markdown-it plugin with given parameters. * diff --git a/src/postcss/svg_backdrop.js b/src/postcss/svg_backdrop.js new file mode 100644 index 0000000..9f3f86e --- /dev/null +++ b/src/postcss/svg_backdrop.js @@ -0,0 +1,81 @@ +/** @module */ +import postcssPlugin from '../helpers/postcss_plugin' + +const backdropMatcher = /(?:\b|^)::backdrop$/ + +/** + * Marpit PostCSS SVG backdrop plugin. + * + * Retarget `::backdrop` and `section::backdrop` selector to + * `@media screen { :marpit-container > svg[data-marpit-svg] { .. } }`. It means + * `::backdrop` targets the SVG container in inline SVG mode. + * + * It's useful for setting style of the letterbox and pillarbox in the SVG + * scaled slide. + * + * ```css + * ::backdrop { + * background-color: #448; + * } + * ``` + * + * The original definition will remain to support an original usage of + * `::backdrop`. + * + * The important differences from an original `::backdrop` are following: + * + * - In original spec, `::backdrop` creates a separated layer from the target + * element, but Marpit's `::backdrop` does not. The slide elements still + * become the child of `::backdrop` so setting some properties that are + * inherited may make broken slide rendering. + * - Even if the browser is not fullscreen, `::backdrop` will match to SVG + * container whenever matched to `@media screen` media query. + * + * If concerned to conflict with the style provided by the app, consider to + * disable the selector support by `inlineSVG: { backdropSelector: false }`. + * + * @see https://developer.mozilla.org/docs/Web/CSS/::backdrop + * @alias module:postcss/svg_backdrop + */ +const plugin = postcssPlugin( + 'marpit-postcss-svg-backdrop', + () => (css, postcss) => { + css.walkRules((rule) => { + const injectSelectors = new Set() + + for (const selector of rule.selectors) { + // Detect pseudo-element (must appear after the simple selectors) + if (!selector.match(backdropMatcher)) continue + + // Detect whether the selector is targeted to section + const delimiterMatched = selector.match(/[.:#[]/) // must match + const target = selector.slice(0, delimiterMatched.index) + + if (target === 'section' || target === '') { + const delimiter = selector.slice(delimiterMatched.index, -10) + injectSelectors.add( + `:marpit-container > svg[data-marpit-svg]${delimiter}` + ) + } + } + + if (injectSelectors.size > 0 && rule.nodes.length > 0) { + rule.parent.insertAfter( + rule, + postcss.atRule({ + name: 'media', + params: 'screen', + nodes: [ + postcss.rule({ + selectors: [...injectSelectors.values()], + nodes: rule.nodes, + }), + ], + }) + ) + } + }) + } +) + +export default plugin diff --git a/src/theme_set.js b/src/theme_set.js index 49127cd..62df1f1 100644 --- a/src/theme_set.js +++ b/src/theme_set.js @@ -16,6 +16,7 @@ import postcssRootIncreasingSpecificity, { } from './postcss/root/increasing_specificity' import postcssRem from './postcss/root/rem' import postcssRootReplace from './postcss/root/replace' +import postcssSVGBackdrop from './postcss/svg_backdrop' import Theme from './theme' import scaffold from './theme/scaffold' @@ -236,16 +237,18 @@ class ThemeSet { * @param {Element[]} [opts.containers] Container elements wrapping whole * slide deck. * @param {boolean} [opts.printable] Make style printable to PDF. - * @param {boolean} [opts.inlineSVG] Apply a hierarchy of inline SVG to CSS - * selector by setting `true`. _(Experimental)_ + * @param {Marpit~InlineSVGOptions} [opts.inlineSVG] Apply a hierarchy of + * inline SVG to CSS selector by setting `true`. _(Experimental)_ * @return {string} The converted CSS string. */ pack(name, opts = {}) { const slideElements = [{ tag: 'section' }] const theme = this.get(name, true) + const inlineSVGOpts = opts.inlineSVG || {} - if (opts.inlineSVG) + if (inlineSVGOpts.enabled) { slideElements.unshift({ tag: 'svg' }, { tag: 'foreignObject' }) + } const additionalCSS = (css) => { if (!css) return undefined @@ -283,7 +286,10 @@ class ThemeSet { 'marpit-pack-scaffold', () => (css) => css.first.before(scaffold.css) ), - opts.inlineSVG && postcssAdvancedBackground, + inlineSVGOpts.enabled && postcssAdvancedBackground, + inlineSVGOpts.enabled && + inlineSVGOpts.backdropSelector && + postcssSVGBackdrop, postcssPagination, postcssRootReplace({ pseudoClass }), postcssRootFontSize, diff --git a/test/markdown/background_image.js b/test/markdown/background_image.js index 1737868..2d3d96a 100644 --- a/test/markdown/background_image.js +++ b/test/markdown/background_image.js @@ -15,7 +15,8 @@ describe('Marpit background image plugin', () => { customDirectives: { global: {}, local: {} }, lastGlobalDirectives: {}, themeSet: { getThemeProp: () => 100 }, - options: { inlineSVG: svg }, + options: {}, + inlineSVGOptions: { enabled: svg }, }) const md = (svg = false) => { diff --git a/test/markdown/collect.js b/test/markdown/collect.js index 5bdbe28..73553fd 100644 --- a/test/markdown/collect.js +++ b/test/markdown/collect.js @@ -15,7 +15,8 @@ describe('Marpit collect plugin', () => { themeSet, customDirectives: { global: {}, local: {} }, lastGlobalDirectives: {}, - options: { inlineSVG: svg }, + options: {}, + inlineSVGOptions: { enabled: svg }, }) const md = (marpitInstance) => { diff --git a/test/markdown/inline_svg.js b/test/markdown/inline_svg.js index 1cb43ca..f7475f1 100644 --- a/test/markdown/inline_svg.js +++ b/test/markdown/inline_svg.js @@ -10,7 +10,8 @@ describe('Marpit inline SVG plugin', () => { customDirectives: { global: {}, local: {} }, themeSet: new ThemeSet(), lastGlobalDirectives: {}, - options: { inlineSVG: true }, + options: {}, + inlineSVGOptions: { enabled: true }, ...props, }) @@ -52,7 +53,9 @@ describe('Marpit inline SVG plugin', () => { }) it('ignores when Marpit inlineSVG option is false', () => { - const marpitStubInstance = marpitStub({ options: { inlineSVG: false } }) + const marpitStubInstance = marpitStub({ + inlineSVGOptions: { enabled: false }, + }) const $ = render(md(marpitStubInstance), '# test\n\n---\n\n# test') expect($('svg')).toHaveLength(0) }) diff --git a/test/marpit.js b/test/marpit.js index 30246cd..3800295 100644 --- a/test/marpit.js +++ b/test/marpit.js @@ -175,7 +175,9 @@ describe('Marpit', () => { expect(opts.containers).toHaveLength(1) expect(opts.containers[0].tag).toBe('div') expect(opts.containers[0].class).toBe('marpit') - expect(opts.inlineSVG).toBe(false) + expect(opts.inlineSVG).toStrictEqual( + expect.objectContaining({ enabled: false }) + ) expect(opts.printable).toBe(true) return 'CSS' } @@ -233,6 +235,20 @@ describe('Marpit', () => { return declCount } + const backdropStyle = '' + const findBackdropRules = (css) => + postcssInstance.process(css, { from: undefined }).then((ret) => { + let rules = [] + + ret.root.walkDecls('--backdrop', (decl) => { + let node = decl + while (node.type !== 'rule' && node.parent) node = node.parent + if (node.type === 'rule') rules.push(node) + }) + + return rules + }) + it('has not svg when inlineSVG is false', () => { const rendered = instance(false).render('# Hi') const $ = cheerio.load(rendered.html, { lowerCaseTags: false }) @@ -240,6 +256,11 @@ describe('Marpit', () => { expect($('svg')).toHaveLength(0) }) + it('has not svg when inlineSVG option has enabled field as false', () => { + const rendered = instance({ enabled: false }).render('# Hi') + expect(rendered.html).not.toContain(' { const rendered = instance(true).render('# Hi') const $ = cheerio.load(rendered.html, { @@ -255,18 +276,51 @@ describe('Marpit', () => { }) }) + it('redirects ::backdrop selector to container SVG when inlineSVG is enabled', () => { + const { css } = instance(true).render(backdropStyle) + + return findBackdropRules(css).then((rules) => { + expect(rules).toHaveLength(2) + expect( + rules.find((r) => r.selector.endsWith('svg[data-marpit-svg]')) + ).toBeTruthy() + }) + }) + + it('wraps section with svg when inlineSVG is true', () => { + const rendered = instance({ enabled: true }).render('# Hi') + expect(rendered.html).toContain(' { it('outputs HTML including inline SVG as array', () => { - const { html } = instance(true).render('# Hi', { htmlAsArray: true }) - expect(html).toHaveLength(1) + for (const opt of [true, { enabled: true }]) { + const { html } = instance(opt).render('# Hi', { htmlAsArray: true }) + expect(html).toHaveLength(1) - const $ = cheerio.load(html[0], { - lowerCaseTags: false, - xmlMode: true, - }) - expect($('svg > foreignObject')).toHaveLength(1) + const $ = cheerio.load(html[0], { + lowerCaseTags: false, + xmlMode: true, + }) + expect($('svg > foreignObject')).toHaveLength(1) + } }) }) + + context( + 'when inlineSVG option has an object with backdropSelector field as false', + () => { + it('does not redirects ::backdrop selector to container SVG', () => { + const { css } = instance({ backdropSelector: false }).render( + backdropStyle + ) + + return findBackdropRules(css).then((rules) => { + expect(rules).toHaveLength(1) + }) + }) + } + ) }) describe('Background image', () => { diff --git a/test/postcss/section_size.js b/test/postcss/section_size.js index c110891..2114f88 100644 --- a/test/postcss/section_size.js +++ b/test/postcss/section_size.js @@ -5,12 +5,11 @@ describe('Marpit PostCSS section size plugin', () => { const run = (input) => postcss([sectionSize()]).process(input, { from: undefined }) - it('adds marpitSectionSize object to result', () => { + it('adds marpitSectionSize object to result', () => run('').then((result) => { expect(result.marpitSectionSize).toBeInstanceOf(Object) expect(result.marpitSectionSize).toStrictEqual({}) - }) - }) + })) it('parses width and height declaration on section selector', () => run('section { width: 123px; height: 456px; }').then((result) => diff --git a/test/postcss/svg_backdrop.js b/test/postcss/svg_backdrop.js new file mode 100644 index 0000000..fce5786 --- /dev/null +++ b/test/postcss/svg_backdrop.js @@ -0,0 +1,20 @@ +import postcss from 'postcss' +import svgBackdrop from '../../src/postcss/svg_backdrop' + +describe('Marpit PostCSS SVG backdrop plugin', () => { + const run = (input) => + postcss([svgBackdrop()]).process(input, { from: undefined }) + + it('appends redirected style for SVG in the container', () => + run('::backdrop { background: white; }').then(({ root }) => { + const [backdrop, redirected] = root.nodes + expect(backdrop.selector).toBe('::backdrop') + + expect(redirected.type).toBe('atrule') + expect(redirected.name).toBe('media') + expect(redirected.params).toBe('screen') + expect(redirected.nodes.toString()).toMatchInlineSnapshot( + `":marpit-container > svg[data-marpit-svg] { background: white; }"` + ) + })) +})