diff --git a/src/components/Preview.js b/src/components/Preview.js index d1ca09d..301f6a8 100644 --- a/src/components/Preview.js +++ b/src/components/Preview.js @@ -348,6 +348,11 @@ const Preview = styled.div` margin-right: 8px; transform: translateY(2px); } + + figcaption { + text-align: center; + margin-top: 12px; + } `; Preview.defaultProps = { className: "myst-preview" }; diff --git a/src/hooks/markdownFigureMd.js b/src/hooks/markdownFigureMd.js new file mode 100644 index 0000000..eef730a --- /dev/null +++ b/src/hooks/markdownFigureMd.js @@ -0,0 +1,138 @@ +// https://github.com/executablebooks/markdown-it-docutils/blob/main/src/directives/images.ts +// figure-md seems to be a myst-parser (MyST+Sphinx) thing but the MyST project seems to be +// evolving away from Sphinx towards mystmd, so slim chance of mainlining this +import { directiveOptions, directivesDefault } from "markdown-it-docutils"; + +const shared_option_spec = { + alt: directiveOptions.unchanged, + height: directiveOptions.length_or_unitless, + width: directiveOptions.length_or_percentage_or_unitless, + scale: directiveOptions.percentage, + target: directiveOptions.unchanged_required, + class: directiveOptions.class_option, + name: directiveOptions.unchanged, +}; + +export class FigureMd extends directivesDefault.image { + option_spec = { + ...shared_option_spec, + align: directiveOptions.create_choice(["left", "center", "right"]), + figwidth: directiveOptions.length_or_percentage_or_unitless_figure, + figclass: directiveOptions.class_option, + }; + has_content = true; + required_arguments = 0; + optional_arguments = 1; + run(data) { + const openToken = this.createToken("figure_open", "figure", 1, { + map: data.map, + block: true, + }); + if (data.options.figclass) { + openToken.attrJoin("class", data.options.figclass.join(" ")); + } + if (data.options.align) { + openToken.attrJoin("class", `align-${data.options.align}`); + } + if (data.options.figwidth && data.options.figwidth !== "image") { + openToken.attrSet("width", data.options.figwidth); + } + let target; + if (data.args.length > 0) { + target = newTarget(this.state, openToken, "fig", data.args[0], data.body.trim()); + openToken.attrJoin("class", "numbered"); + } + + let captionTokens = []; + let legendTokens = []; + let imageToken = null; + if (data.body) { + imageToken = this.state.md.parseInline(data.body.split("\n")[0], this.state.env)[0].children[0]; + imageToken.map = data.map; + if (data.options.height) { + imageToken.attrSet("height", data.options.height); + } + if (data.options.width) { + imageToken.attrSet("width", data.options.width); + } + if (data.options.align) { + imageToken.attrJoin("class", `align-${data.options.align}`); + } + if (data.options.class) { + imageToken.attrJoin("class", data.options.class.join(" ")); + } + + const [caption, ...legendParts] = data.body.split("\n\n").slice(1); + const legend = legendParts.join("\n\n"); + const captionMap = data.bodyMap[0] + 2; + const openCaption = this.createToken("figure_caption_open", "figcaption", 1, { + block: true, + }); + if (target) { + openCaption.attrSet("number", `${target.number}`); + } + const captionBody = this.nestedParse(caption, captionMap); + const closeCaption = this.createToken("figure_caption_close", "figcaption", -1, { + block: true, + }); + captionTokens = [openCaption, ...captionBody, closeCaption]; + if (legend) { + const legendMap = captionMap + caption.split("\n").length + 1; + const openLegend = this.createToken("figure_legend_open", "", 1, { + block: true, + }); + const legendBody = this.nestedParse(legend, legendMap); + const closeLegend = this.createToken("figure_legend_close", "", -1, { + block: true, + }); + legendTokens = [openLegend, ...legendBody, closeLegend]; + } + } + const closeToken = this.createToken("figure_close", "figure", -1, { block: true }); + return [openToken, imageToken, ...captionTokens, ...legendTokens, closeToken]; + } +} + +function newTarget(state, token, kind, label, title, silent = false) { + const env = getDocState(state); + const number = nextNumber(state, kind); + const target = { + label, + kind, + number, + title, + }; + if (!silent) { + const meta = getNamespacedMeta(token); + meta.target = target; + token.attrSet("id", label); + env.targets[label] = target; + } + return target; +} + +function getDocState(state) { + const env = state.env?.docutils ?? {}; + if (!env.targets) env.targets = {}; + if (!env.references) env.references = []; + if (!env.numbering) env.numbering = {}; + if (!state.env.docutils) state.env.docutils = env; + return env; +} + +function nextNumber(state, kind) { + const env = getDocState(state); + if (env.numbering[kind] == null) { + env.numbering[kind] = 1; + } else { + env.numbering[kind] += 1; + } + return env.numbering[kind]; +} + +function getNamespacedMeta(token) { + const meta = token.meta?.docutils ?? {}; + if (!token.meta) token.meta = {}; + if (!token.meta.docutils) token.meta.docutils = meta; + return meta; +} diff --git a/src/hooks/useText.js b/src/hooks/useText.js index 21c4d7c..8c7d1f6 100644 --- a/src/hooks/useText.js +++ b/src/hooks/useText.js @@ -1,4 +1,4 @@ -import markdownitDocutils from "markdown-it-docutils"; +import markdownitDocutils, { directivesDefault } from "markdown-it-docutils"; import purify from "dompurify"; import markdownIt from "markdown-it"; import { markdownReplacer, useCustomRoles } from "./markdownReplacer"; @@ -15,6 +15,7 @@ import { useComputed } from "@preact/signals"; import markdownCheckboxes from "markdown-it-checkbox"; import { colonFencedBlocks } from "./markdownFence"; import { markdownItMapUrls } from "./markdownUrlMapping"; +import { FigureMd } from "./markdownFigureMd"; const countOccurences = (str, pattern) => (str?.match(pattern) || []).length; @@ -106,7 +107,7 @@ export const useText = ({ preview }) => { const markdown = useComputed(() => { const md = markdownIt({ breaks: true, linkify: true }) - .use(markdownitDocutils) + .use(markdownitDocutils, { directives: { ...directivesDefault, "figure-md": FigureMd } }) .use(markdownReplacer(options.transforms.value, options.parent, cache.transform)) .use(useCustomRoles(options.customRoles.value, options.parent, cache.transform)) .use(markdownMermaid, { lineMap, parent: options.parent })