diff --git a/README.md b/README.md index d7c53b9..12a678e 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ Markdown 文档自动即时渲染为微信图文,让你不再为微信文章 - [x] 支持自定义 CSS 样式 - [x] 支持 Markdown 所有基础语法、代码块、LaTeX 公式 +- [x] 支持 [GFM 警告块](https://github.com/orgs/community/discussions/16925) - [x] 支持浅色、深色两种编辑器外观 - [x] 支持 Alt + Shift + F 快速格式化文档 - [x] 支持色盘取色,快速替换文章整体色调 diff --git a/src/assets/example/theme-css.txt b/src/assets/example/theme-css.txt index 53f9a92..9c04218 100644 --- a/src/assets/example/theme-css.txt +++ b/src/assets/example/theme-css.txt @@ -33,6 +33,30 @@ blockquote { /* 引用段落样式 */ blockquote_p { } +/* GFM 警告块 */ +markdown-alert { +} +/* GFM 警告块标题 */ +markdown-alert-title { +} +/* GFM 警告块内容,抵消 p 默认的 margin */ +markdown-alert-content-wrapper { +} +/* GFM note */ +markdown-alert-title-note { +} +/* GFM tip */ +markdown-alert-title-tip { +} +/* GFM important */ +markdown-alert-title-important { +} +/* GFM warning */ +markdown-alert-title-warning { +} +/* GFM caution */ +markdown-alert-title-caution { +} /* 段落样式 */ p { } diff --git a/src/assets/index.css b/src/assets/index.css index c041c3c..cd105a8 100644 --- a/src/assets/index.css +++ b/src/assets/index.css @@ -32,6 +32,8 @@ --input:0 0% 89.8%; --ring:0 0% 3.9%; --radius: 0.5rem; + + --blockquote-background: #f7f7f7; } .dark { @@ -62,6 +64,8 @@ --border:0 0% 14.9%; --input:0 0% 14.9%; --ring:0 0% 83.1%; + + --blockquote-background: #212121; } } diff --git a/src/components/CodemirrorEditor/EditorHeader/index.vue b/src/components/CodemirrorEditor/EditorHeader/index.vue index 28d106f..3f65709 100644 --- a/src/components/CodemirrorEditor/EditorHeader/index.vue +++ b/src/components/CodemirrorEditor/EditorHeader/index.vue @@ -123,9 +123,20 @@ function copy() { .replace(/top:(.*?)em/g, `transform: translateY($1em)`) // 适配主题中的颜色变量 .replaceAll(`var(--el-text-color-regular)`, `#3f3f3f`) + .replaceAll(`var(--blockquote-background)`, `#f7f7f7`) .replaceAll(`var(--md-primary-color)`, primaryColor.value) .replaceAll(/--md-primary-color:.+?;/g, ``) + clipboardDiv.focus() + + // edge case: 由于 svg 无法复制, 在前面插入一个空节点 + const p = document.createElement(`p`) + p.style.fontSize = `0` // 设置字体大小为 0 + p.style.lineHeight = `0` // 行高也为 0 + p.style.margin = `0` // 避免外边距干扰 + p.innerHTML = ` ` + clipboardDiv.insertBefore(p, clipboardDiv.firstChild) + window.getSelection()!.removeAllRanges() const range = document.createRange() diff --git a/src/config/theme.ts b/src/config/theme.ts index 483d683..842fafe 100644 --- a/src/config/theme.ts +++ b/src/config/theme.ts @@ -10,7 +10,7 @@ const defaultTheme: Theme = { }, block: { // 一级标题 - h1: { + 'h1': { 'display': `table`, 'padding': `0 1em`, 'border-bottom': `2px solid var(--md-primary-color)`, @@ -22,7 +22,7 @@ const defaultTheme: Theme = { }, // 二级标题 - h2: { + 'h2': { 'display': `table`, 'padding': `0 0.2em`, 'margin': `4em auto 2em`, @@ -34,7 +34,7 @@ const defaultTheme: Theme = { }, // 三级标题 - h3: { + 'h3': { 'padding-left': `8px`, 'border-left': `3px solid var(--md-primary-color)`, 'margin': `2em 8px 0.75em 0`, @@ -45,7 +45,7 @@ const defaultTheme: Theme = { }, // 四级标题 - h4: { + 'h4': { 'margin': `2em 8px 0.5em`, 'color': `var(--md-primary-color)`, 'font-size': `1em`, @@ -53,7 +53,7 @@ const defaultTheme: Theme = { }, // 五级标题 - h5: { + 'h5': { 'margin': `1.5em 8px 0.5em`, 'color': `var(--md-primary-color)`, 'font-size': `1em`, @@ -61,14 +61,14 @@ const defaultTheme: Theme = { }, // 六级标题 - h6: { + 'h6': { 'margin': `1.5em 8px 0.5em`, 'font-size': `1em`, 'color': `var(--md-primary-color)`, }, // 段落 - p: { + 'p': { 'margin': `1.5em 8px`, 'letter-spacing': `0.1em`, 'color': `var(--el-text-color-regular)`, @@ -76,26 +76,68 @@ const defaultTheme: Theme = { }, // 引用 - blockquote: { + 'blockquote': { 'font-style': `normal`, 'border-left': `none`, 'padding': `1em`, 'border-radius': `8px`, 'color': `rgba(0,0,0,0.5)`, - 'background': `#f7f7f7`, + 'background': `var(--blockquote-background)`, 'margin': `2em 8px`, }, // 引用内容 - blockquote_p: { + 'blockquote_p': { 'display': `block`, 'font-size': `1em`, 'letter-spacing': `0.1em`, - 'color': `rgb(80, 80, 80)`, + 'color': `var(--el-text-color-regular)`, + }, + + // GFM 警告块 + 'markdown-alert': { + 'font-style': `normal`, + 'border-left': `none`, + 'padding': `1em`, + 'border-radius': `8px`, + 'background': `var(--blockquote-background)`, + 'margin': `2em 8px`, + }, + + // GFM 警告块标题 + 'markdown-alert-title': { + 'display': `flex`, + 'align-items': `center`, + 'gap': `0.5em`, + }, + + // GFM 警告块内容,抵消 p 默认的 margin + 'markdown-alert-content-wrapper': { + margin: `-1em -8px -1.5em;`, + }, + + 'markdown-alert-title-note': { + color: `#478be6`, + }, + + 'markdown-alert-title-tip': { + color: `#57ab5a`, + }, + + 'markdown-alert-title-important': { + color: `#986ee2`, + }, + + 'markdown-alert-title-warning': { + color: `#c69026`, + }, + + 'markdown-alert-title-caution': { + color: `#e5534b`, }, // 代码块 - code_pre: { + 'code_pre': { 'font-size': `14px`, 'overflow-x': `auto`, 'border-radius': `8px`, @@ -105,14 +147,14 @@ const defaultTheme: Theme = { }, // 行内代码 - code: { + 'code': { 'margin': 0, 'white-space': `nowrap`, 'font-family': `Menlo, Operator Mono, Consolas, Monaco, monospace`, }, // 图片 - image: { + 'image': { 'display': `block`, 'width': `100% !important`, 'margin': `0.1em auto 0.5em`, @@ -120,32 +162,32 @@ const defaultTheme: Theme = { }, // 有序列表 - ol: { + 'ol': { 'padding-left': `1em`, 'margin-left': `0`, 'color': `var(--el-text-color-regular)`, }, // 无序列表 - ul: { + 'ul': { 'list-style': `circle`, 'padding-left': `1em`, 'margin-left': `0`, 'color': `var(--el-text-color-regular)`, }, - footnotes: { + 'footnotes': { 'margin': `0.5em 8px`, 'font-size': `80%`, 'color': `var(--el-text-color-regular)`, }, - figure: { + 'figure': { margin: `1.5em 8px`, color: `var(--el-text-color-regular)`, }, - hr: { + 'hr': { 'border-style': `solid`, 'border-width': `1px 0 0`, 'border-color': `rgba(0,0,0,0.1)`, @@ -230,87 +272,90 @@ const graceTheme = toMerged(defaultTheme, { base: { }, block: { - h1: { + 'h1': { 'padding': `0.5em 1em`, 'border-bottom': `2px solid var(--md-primary-color)`, 'font-size': `1.4em`, 'text-shadow': `2px 2px 4px rgba(0,0,0,0.1)`, }, - h2: { + 'h2': { 'padding': `0.3em 1em`, 'border-radius': `8px`, 'font-size': `1.3em`, 'box-shadow': `0 4px 6px rgba(0,0,0,0.1)`, }, - h3: { + 'h3': { 'padding-left': `12px`, 'font-size': `1.2em`, 'border-left': `4px solid var(--md-primary-color)`, 'border-bottom': `1px dashed var(--md-primary-color)`, }, - h4: { + 'h4': { 'font-size': `1.1em`, }, - h5: { + 'h5': { 'font-size': `1em`, }, - h6: { + 'h6': { 'font-size': `1em`, }, - p: { + 'p': { }, - blockquote: { + 'blockquote': { 'font-style': `italic`, 'padding': `1em 1em 1em 2em`, 'border-left': `4px solid var(--md-primary-color)`, 'border-radius': `6px`, 'color': `rgba(0,0,0,0.6)`, - 'background': `linear-gradient(to right, #f7f7f7, #ffffff)`, 'box-shadow': `0 4px 6px rgba(0,0,0,0.05)`, }, - blockquote_p: { + 'blockquote_p': { }, - code_pre: { + 'markdown-alert': { + 'font-style': `italic`, + }, + + 'code_pre': { 'box-shadow': `inset 0 0 10px rgba(0,0,0,0.05)`, }, - code: { + 'code': { 'white-space': `pre-wrap`, 'font-family': `'Fira Code', Menlo, Operator Mono, Consolas, Monaco, monospace`, }, - image: { + 'image': { 'border-radius': `8px`, 'box-shadow': `0 4px 8px rgba(0,0,0,0.1)`, }, - ol: { + 'ol': { 'padding-left': `1.5em`, }, - ul: { + 'ul': { 'list-style': `none`, 'padding-left': `1.5em`, }, - footnotes: { + 'footnotes': { }, - figure: { + 'figure': { }, - hr: { + 'hr': { height: `1px`, border: `none`, margin: `2em 0`, diff --git a/src/stores/index.ts b/src/stores/index.ts index f96732c..7a0921e 100644 --- a/src/stores/index.ts +++ b/src/stores/index.ts @@ -143,7 +143,8 @@ export const useStore = defineStore(`store`, () => { // 更新编辑器 const editorRefresh = () => { codeThemeChange() - renderer.reset({ status: isCiteStatus.value, legend: legend.value, isUseIndent: isUseIndent.value }) + renderer.reset({ citeStatus: isCiteStatus.value, legend: legend.value, isUseIndent: isUseIndent.value }) + let outputTemp = marked.parse(editor.value!.getValue()) as string // 去除第一行的 margin-top @@ -157,23 +158,27 @@ export const useStore = defineStore(`store`, () => { outputTemp += ` ` } + outputTemp += ` + + ` + output.value = outputTemp } @@ -184,6 +189,7 @@ export const useStore = defineStore(`store`, () => { renderer.setOptions({ theme: newTheme, }) + editorRefresh() } // 初始化 CSS 编辑器 @@ -354,7 +360,7 @@ export const useStore = defineStore(`store`, () => { const reader = new FileReader() reader.readAsText(file) reader.onload = (event) => { - (editor.value!).setValue((event.target !).result as string) + (editor.value!).setValue((event.target!).result as string) ElMessage.success(`文档导入成功`) } } diff --git a/src/types/index.ts b/src/types/index.ts index ffc5ca2..28742e4 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,5 +1,7 @@ import type { PropertiesHyphen } from 'csstype' +import type { Token } from 'marked' + export type Block = `h1` | `h2` | `h3` | `h4` | `h5` | `h6` | `p` | `blockquote` | `blockquote_p` | `code_pre` | `code` | `image` | `ol` | `ul` | `footnotes` | `figure` | `hr` export type Inline = `listitem` | `codespan` | `link` | `wx_link` | `strong` | `table` | `thead` | `td` | `footnote` | `figcaption` | `em` @@ -12,8 +14,8 @@ export type ExtendedProperties = PropertiesHyphen & CustomCSSProperties export interface Theme { base: ExtendedProperties - block: Record - inline: Record + block: Record + inline: Record } export interface IOpts { @@ -22,7 +24,7 @@ export interface IOpts { size: string isUseIndent: boolean legend?: string - status?: boolean + citeStatus?: boolean } export type ThemeStyles = Record @@ -32,3 +34,39 @@ export interface IConfigOption { value: VT desc: string } + +/** + * Options for the `markedAlert` extension. + */ +export interface AlertOptions { + className?: string + variants?: AlertVariantItem[] + theme?: Theme +} + +/** + * Configuration for an alert type. + */ +export interface AlertVariantItem { + type: string + icon: string + title?: string + titleClassName?: string +} + +/** + * Represents an alert token. + */ +export interface Alert { + type: `alert` + meta: { + className: string + variant: string + icon: string + title: string + titleClassName: string + } + raw: string + text: string + tokens: Token[] +} diff --git a/src/utils/MDAlert.ts b/src/utils/MDAlert.ts new file mode 100644 index 0000000..58118c0 --- /dev/null +++ b/src/utils/MDAlert.ts @@ -0,0 +1,159 @@ +import type { AlertOptions, AlertVariantItem } from '@/types' +import type { MarkedExtension, Tokens } from 'marked' +import { getStyleString } from '.' + +/** + * https://github.com/bent10/marked-extensions/tree/main/packages/alert + * To support theme, we need to modify the source code. + * A [marked](https://marked.js.org/) extension to support [GFM alerts](https://github.com/orgs/community/discussions/16925). + */ +export default function markedAlert(options: AlertOptions = {}): MarkedExtension { + const { className = `markdown-alert`, variants = [] } = options + const resolvedVariants = resolveVariants(variants) + + return { + walkTokens(token) { + if (token.type !== `blockquote`) + return + + const matchedVariant = resolvedVariants.find(({ type }) => + new RegExp(createSyntaxPattern(type), `i`).test(token.text), + ) + + if (matchedVariant) { + const { + type: variantType, + icon, + title = ucfirst(variantType), + titleClassName = `${className}-title`, + } = matchedVariant + const typeRegexp = new RegExp(createSyntaxPattern(variantType), `i`) + + Object.assign(token, { + type: `alert`, + meta: { + className, + variant: variantType, + icon, + title, + titleClassName, + style: { + ...options.theme?.block[className], + ...options.theme?.block[`${className}-${variantType}`], + }, + titleStyle: { + ...options.theme?.block[titleClassName], + ...options.theme?.block[`${titleClassName}-${variantType}`], + }, + contentWrapperStyle: { + margin: options.theme?.block[`${className}-content-wrapper`]?.margin, + }, + }, + }) + + console.log({ + ...options.theme?.block[className], + ...options.theme?.block[`${className}-${variantType}`], + }, `style`) + + const firstLine = token.tokens?.[0] as Tokens.Paragraph + const firstLineText = firstLine.raw?.replace(typeRegexp, ``).trim() + + if (firstLineText) { + const patternToken = firstLine.tokens[0] as Tokens.Text + + Object.assign(patternToken, { + raw: patternToken.raw.replace(typeRegexp, ``), + text: patternToken.text.replace(typeRegexp, ``), + }) + + if (firstLine.tokens[1]?.type === `br`) { + firstLine.tokens.splice(1, 1) + } + } + else { + token.tokens?.shift() + } + } + }, + extensions: [ + { + name: `alert`, + level: `block`, + renderer({ meta, tokens = [] }) { + let tmpl = `
\n` + tmpl += `

` + tmpl += meta.icon.replace( + `\n` + tmpl += `${this.parser.parse(tokens)}` + tmpl += `

\n` + + return tmpl + }, + }, + ], + } +} + +/** + * The default configuration for alert variants. + */ +const defaultAlertVariant: AlertVariantItem[] = [ + { + type: `note`, + icon: ``, + }, + { + type: `tip`, + icon: ``, + }, + { + type: `important`, + icon: ``, + }, + { + type: `warning`, + icon: ``, + }, + { + type: `caution`, + icon: ``, + }, +] + +/** + * Resolves the variants configuration, combining the provided variants with + * the default variants. + */ +export function resolveVariants(variants: AlertVariantItem[]) { + if (!variants.length) + return defaultAlertVariant + + return Object.values( + [...defaultAlertVariant, ...variants].reduce( + (map, item) => { + map[item.type] = item + return map + }, + {} as { [key: string]: AlertVariantItem }, + ), + ) +} + +/** + * Returns regex pattern to match alert syntax. + */ +export function createSyntaxPattern(type: string) { + return `^(?:\\[!${type}])\\s*?\n*` +} + +/** + * Capitalizes the first letter of a string. + */ +export function ucfirst(str: string) { + return str.slice(0, 1).toUpperCase() + str.slice(1).toLowerCase() +} diff --git a/src/utils/index.ts b/src/utils/index.ts index c787cfa..a18c86c 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,4 +1,4 @@ -import type { Block, Inline, Theme } from '@/types' +import type { Block, ExtendedProperties, Inline, Theme } from '@/types' import type { PropertiesHyphen } from 'csstype' import { prefix } from '@/config' @@ -116,6 +116,15 @@ export function css2json(css: string): Partial `${key}: ${value}`).join(`; `) +} + /** * 格式化内容 * @param {string} content - 要格式化的内容 diff --git a/src/utils/renderer.ts b/src/utils/renderer.ts index b1c18c1..5f6729a 100644 --- a/src/utils/renderer.ts +++ b/src/utils/renderer.ts @@ -6,6 +6,8 @@ import hljs from 'highlight.js' import { marked } from 'marked' import mermaid from 'mermaid' +import { getStyleString } from '.' +import markedAlert from './MDAlert' import { MDKatex } from './MDKatex' marked.use(MDKatex({ nonStandard: true })) @@ -58,9 +60,7 @@ function getStyles(styleMapping: ThemeStyles, tokenName: string, addition: strin if (!dict) { return `` } - const styles = Object.entries(dict) - .map(([key, value]) => `${key}:${value}`) - .join(`;`) + const styles = getStyleString(dict) return `style="${styles}${addition}"` } @@ -89,9 +89,9 @@ function transform(legend: string, text: string | null, title: string | null): s const macCodeSvg = ` - + - + `.trim() @@ -126,6 +126,7 @@ export function initRenderer(opts: IOpts) { function setOptions(newOpts: Partial): void { opts = { ...opts, ...newOpts } styleMapping = buildTheme(opts) + marked.use(markedAlert({ theme: opts.theme })) } const buildFootnotes = () => { @@ -211,18 +212,19 @@ export function initRenderer(opts: IOpts) { return `
${text}${subText}
` }, - link({ href, title, text }: Tokens.Link): string { + link({ href, title, text, tokens }: Tokens.Link): string { + const parsedText = this.parser.parseInline(tokens) if (href.startsWith(`https://mp.weixin.qq.com`)) { - return `${text}` + return `${parsedText}` } if (href === text) { - return text + return parsedText } - if (opts.status) { + if (opts.citeStatus) { const ref = addFootnote(title || text, href) - return `${text}[${ref}]` + return `${parsedText}[${ref}]` } - return styledContent(`link`, text, `span`) + return styledContent(`link`, parsedText, `span`) }, strong({ tokens }: Tokens.Strong): string {