From d8b14f5ce86b548bb3b456f6a08be01d10c45650 Mon Sep 17 00:00:00 2001 From: njr <72367140+dribble-njr@users.noreply.github.com> Date: Fri, 22 Nov 2024 15:39:28 +0800 Subject: [PATCH] feat: support GFM alerts & render perf (#446) --- README.md | 1 + src/assets/example/theme-css.txt | 48 ++++++ src/assets/index.css | 4 + .../CodemirrorEditor/EditorHeader/index.vue | 11 ++ src/config/theme.ts | 101 ++++++++--- src/stores/index.ts | 34 ++-- src/types/index.ts | 47 +++++- src/utils/MDAlert.ts | 159 ++++++++++++++++++ src/utils/index.ts | 29 +++- src/utils/renderer.ts | 24 +-- 10 files changed, 407 insertions(+), 51 deletions(-) create mode 100644 src/utils/MDAlert.ts 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..35d0c83 100644 --- a/src/assets/example/theme-css.txt +++ b/src/assets/example/theme-css.txt @@ -33,6 +33,54 @@ blockquote { /* 引用段落样式 */ blockquote_p { } +/* GFM note 样式 */ +blockquote_note { +} +/* GFM tip 样式 */ +blockquote_tip { +} +/* GFM important 样式 */ +blockquote_important { +} +/* GFM warning 样式 */ +blockquote_warning { +} +/* GFM caution 样式 */ +blockquote_caution { +} +/* GFM 通用标题 */ +blockquote_title { +} +/* GFM note 标题 */ +blockquote_title_note { +} +/* GFM tip 标题 */ +blockquote_title_tip { +} +/* GFM important 标题 */ +blockquote_title_important { +} +/* GFM warning 标题 */ +blockquote_title_warning { +} +/* GFM caution 标题 */ +blockquote_title_caution { +} +/* GFM note 段落样式 */ +blockquote_p_note { +} +/* GFM tip 段落样式 */ +blockquote_p_tip { +} +/* GFM important 段落样式 */ +blockquote_p_important { +} +/* GFM warning 段落样式 */ +blockquote_p_warning { +} +/* GFM caution 段落样式 */ +blockquote_p_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..d4934ca 100644 --- a/src/config/theme.ts +++ b/src/config/theme.ts @@ -82,7 +82,7 @@ const defaultTheme: Theme = { 'padding': `1em`, 'border-radius': `8px`, 'color': `rgba(0,0,0,0.5)`, - 'background': `#f7f7f7`, + 'background': `var(--blockquote-background)`, 'margin': `2em 8px`, }, @@ -91,7 +91,65 @@ const defaultTheme: Theme = { 'display': `block`, 'font-size': `1em`, 'letter-spacing': `0.1em`, - 'color': `rgb(80, 80, 80)`, + 'color': `var(--el-text-color-regular)`, + }, + + blockquote_note: { + }, + + blockquote_tip: { + }, + + blockquote_important: { + }, + + blockquote_warning: { + }, + + blockquote_caution: { + }, + + // GFM 警告块标题 + blockquote_title: { + 'display': `flex`, + 'align-items': `center`, + 'gap': `0.5em`, + 'margin-bottom': `0.5em`, + }, + + blockquote_title_note: { + color: `#478be6`, + }, + + blockquote_title_tip: { + color: `#57ab5a`, + }, + + blockquote_title_important: { + color: `#986ee2`, + }, + + blockquote_title_warning: { + color: `#c69026`, + }, + + blockquote_title_caution: { + color: `#e5534b`, + }, + + blockquote_p_note: { + }, + + blockquote_p_tip: { + }, + + blockquote_p_important: { + }, + + blockquote_p_warning: { + }, + + blockquote_p_caution: { }, // 代码块 @@ -230,87 +288,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..cd05de4 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,6 +1,9 @@ import type { PropertiesHyphen } from 'csstype' -export type Block = `h1` | `h2` | `h3` | `h4` | `h5` | `h6` | `p` | `blockquote` | `blockquote_p` | `code_pre` | `code` | `image` | `ol` | `ul` | `footnotes` | `figure` | `hr` +import type { Token } from 'marked' + +type GFMBlock = `blockquote_note` | `blockquote_tip` | `blockquote_important` | `blockquote_warning` | `blockquote_caution` | `blockquote_title` | `blockquote_title_note` | `blockquote_title_tip` | `blockquote_title_important` | `blockquote_title_warning` | `blockquote_title_caution` | `blockquote_p` | `blockquote_p_note` | `blockquote_p_tip` | `blockquote_p_important` | `blockquote_p_warning` | `blockquote_p_caution` +export type Block = `h1` | `h2` | `h3` | `h4` | `h5` | `h6` | `p` | `blockquote` | `blockquote_p` | `code_pre` | `code` | `image` | `ol` | `ul` | `footnotes` | `figure` | `hr` | GFMBlock export type Inline = `listitem` | `codespan` | `link` | `wx_link` | `strong` | `table` | `thead` | `td` | `footnote` | `figcaption` | `em` interface CustomCSSProperties { @@ -12,8 +15,8 @@ export type ExtendedProperties = PropertiesHyphen & CustomCSSProperties export interface Theme { base: ExtendedProperties - block: Record - inline: Record + block: Record + inline: Record } export interface IOpts { @@ -22,7 +25,7 @@ export interface IOpts { size: string isUseIndent: boolean legend?: string - status?: boolean + citeStatus?: boolean } export type ThemeStyles = Record @@ -32,3 +35,39 @@ export interface IConfigOption { value: VT desc: string } + +/** + * Options for the `markedAlert` extension. + */ +export interface AlertOptions { + className?: string + variants?: AlertVariantItem[] + styles?: ThemeStyles +} + +/** + * 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..3aa5a54 --- /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`) + + const { styles } = options + + Object.assign(token, { + type: `alert`, + meta: { + className, + variant: variantType, + icon, + title, + titleClassName, + wrapperStyle: { + ...styles?.blockquote, + ...styles?.[`blockquote_${variantType}` as keyof typeof styles], + }, + titleStyle: { + ...styles?.blockquote_title, + ...styles?.[`blockquote_title_${variantType}` as keyof typeof styles], + }, + contentStyle: { + ...styles?.blockquote_p, + ...styles?.[`blockquote_p_${variantType}` as keyof typeof styles], + }, + }, + }) + + 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 text = this.parser.parse(tokens) + text = text.replace(/

/g, `

`) + let tmpl = `

\n` + tmpl += `

` + tmpl += meta.icon.replace( + `\n` + tmpl += text + 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..f4fb685 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' @@ -34,7 +34,7 @@ export function customizeTheme(theme: Theme, options: { export function customCssWithTemplate(jsonString: Partial>, color: string, theme: Theme) { const newTheme = customizeTheme(theme, { color }) - const mergeProperties = (target: Record, source: Partial>, keys: T[]) => { + const mergeProperties = (target: Record, source: Partial>, keys: T[]) => { keys.forEach((key) => { if (source[key]) { target[key] = Object.assign(target[key] || {}, source[key]) @@ -54,7 +54,23 @@ export function customCssWithTemplate(jsonString: Partial `${key}: ${value}`).join(`; `) +} + /** * 格式化内容 * @param {string} content - 要格式化的内容 diff --git a/src/utils/renderer.ts b/src/utils/renderer.ts index b1c18c1..4127c0d 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({ styles: styleMapping })) } 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 {