diff --git a/docs/en/guide/markdown.md b/docs/en/guide/markdown.md index e37631674641..8ca2c4f3b2de 100644 --- a/docs/en/guide/markdown.md +++ b/docs/en/guide/markdown.md @@ -597,6 +597,51 @@ const line3 = 'This is line 3' const line4 = 'This is line 4' ``` +## Code Modal + +You can enable codeModals for each code blocks via config: + +```js +export default { + markdown: { + codeModal: true + } +} +``` + +Please see [`markdown` options](../reference/site-config#markdown) for more details. + +You can add `:modal` / `:no-modal` mark in your fenced code blocks to override the value set in config. + + +**Input** + +````md:modal +```js:modal +export default { + data () { + return { + msg: 'Code with very long lines can often be hard to read in code blocks. Code modals can make seeing the whole content possible.', + lorem: 'ipsum', + } + } +} +``` +```` + +**Output** + +```js:modal +export default { + data () { + return { + msg: 'Code with very long lines can often be hard to read in code blocks. Code modals can make seeing the whole content possible.', + lorem: 'ipsum', + } + } +} +``` + ## Import Code Snippets You can import code snippets from existing files via following syntax: diff --git a/src/client/app/composables/codeModal.ts b/src/client/app/composables/codeModal.ts new file mode 100644 index 000000000000..9d6b88f25afb --- /dev/null +++ b/src/client/app/composables/codeModal.ts @@ -0,0 +1,51 @@ +import { inBrowser } from 'vitepress' + +export function useCodeModal() { + if (inBrowser) { + window.addEventListener('click', (e) => { + const el = e.target as HTMLElement + + if (el.matches('div[class*="language-"] > button.modal')) { + //remove focus from button + el.blur() + + const parent = el.parentElement + const sibling = el.nextElementSibling + if (!parent || !sibling) { + return + } + + sibling.classList.add('open') + } + + if ( + el.matches('div[class*="language-"] div.modal-container button.close') + ) { + const parent = el.parentElement?.parentElement + if (!parent) { + return + } + + parent.classList.remove('open') + } + + if (el.matches('div[class*="language-"] > div.modal-container')) { + el.classList.remove('open') + } + }) + + window.addEventListener('keydown', (ev) => { + if (ev.key == 'Escape') { + let modal = window.document.querySelector( + 'div[class*="language-"] > div.modal-container.open' + ) + + if (!modal) { + return + } + + modal.classList.remove('open') + } + }) + } +} diff --git a/src/client/app/index.ts b/src/client/app/index.ts index 72cc5063802f..b5501b001d98 100644 --- a/src/client/app/index.ts +++ b/src/client/app/index.ts @@ -17,6 +17,7 @@ import { usePrefetch } from './composables/preFetch' import { dataSymbol, initData, siteDataRef, useData } from './data' import { RouterSymbol, createRouter, scrollTo, type Router } from './router' import { inBrowser, pathToFile } from './utils' +import { useCodeModal } from './composables/codeModal' function resolveThemeExtends(theme: typeof RawTheme): typeof RawTheme { if (theme.extends) { @@ -57,6 +58,8 @@ const VitePressApp = defineComponent({ useCopyCode() // setup global code groups handler useCodeGroups() + // setup global code modal handler + useCodeModal() if (Theme.setup) Theme.setup() return () => h(Theme.Layout!) diff --git a/src/client/theme-default/styles/components/vp-code.css b/src/client/theme-default/styles/components/vp-code.css index 1ff9429b4ac1..381eba511d5a 100644 --- a/src/client/theme-default/styles/components/vp-code.css +++ b/src/client/theme-default/styles/components/vp-code.css @@ -5,3 +5,46 @@ html:not(.dark) .vp-code span { color: var(--shiki-light, inherit); } + +.vp-code ~ .modal-container:not(.open) { + display: none; +} + +.vp-code ~ .modal-container { + position: fixed; + + z-index: 100; + + top: 0; + left: 0; + width: 100vw; + height: 100vh; + + display: flex; + justify-content: center; + align-items: center; + + background-color: rgba(255, 255, 255, 0.02); + backdrop-filter: blur(2px); +} + +html:not(.dark) .vp-code ~ .modal-container { + background-color: rgba(0, 0, 0, 0.1); +} + +.vp-code ~ .modal-container [class*='language-'] { + position: relative; + margin: 20px; + + max-width: 90%; + width: min-content; + + border-radius: 8px; + + background-color: var(--vp-code-block-bg); + box-shadow: var(--vp-shadow-2); +} + +.vp-code ~ .modal-container [class*='language-']:hover span.lang { + opacity: 0; +} diff --git a/src/client/theme-default/styles/components/vp-doc.css b/src/client/theme-default/styles/components/vp-doc.css index 379a94ce4366..880cb3810d89 100644 --- a/src/client/theme-default/styles/components/vp-doc.css +++ b/src/client/theme-default/styles/components/vp-doc.css @@ -387,7 +387,10 @@ opacity 0.35s; } -.vp-doc [class*='language-']:hover .has-focused-lines .line:not(.has-focus) { +.vp-doc + [class*='language-']:hover:not(:has(.modal-container:hover)) + .has-focused-lines + .line:not(.has-focus) { filter: blur(0); opacity: 1; } @@ -437,7 +440,9 @@ color 0.5s; } -.vp-doc [class*='language-'] > button.copy { +.vp-doc [class*='language-'] > button.copy, +.vp-doc [class*='language-'] > button.modal, +.vp-doc [class*='language-'] > button.close { /*rtl:ignore*/ direction: ltr; position: absolute; @@ -462,13 +467,35 @@ opacity 0.25s; } -.vp-doc [class*='language-']:hover > button.copy, -.vp-doc [class*='language-'] > button.copy:focus { +.vp-doc [class*='language-'] > button.modal { + top: 64px; + background-image: var(--vp-icon-expand); +} + +.vp-doc [class*='language-'] > button.close { + top: 64px; + background-image: var(--vp-icon-close); +} + +.vp-doc + [class*='language-']:hover:not(:has(.modal-container:hover)) + > button.copy, +.vp-doc [class*='language-'] > button.copy:focus, +.vp-doc + [class*='language-']:hover:not(:has(.modal-container:hover)) + > button.modal, +.vp-doc [class*='language-'] > button.modal:focus, +.vp-doc + [class*='language-']:hover:not(:has(.modal-container:hover)) + > button.close, +.vp-doc [class*='language-'] > button.close:focus { opacity: 1; } .vp-doc [class*='language-'] > button.copy:hover, -.vp-doc [class*='language-'] > button.copy.copied { +.vp-doc [class*='language-'] > button.copy.copied, +.vp-doc [class*='language-'] > button.modal:hover, +.vp-doc [class*='language-'] > button.close:hover { border-color: var(--vp-code-copy-code-hover-border-color); background-color: var(--vp-code-copy-code-hover-bg); } @@ -506,7 +533,7 @@ content: var(--vp-code-copy-copied-text-content); } -.vp-doc [class*='language-'] > span.lang { +.vp-doc [class*='language-'] span.lang { position: absolute; top: 2px; /*rtl:ignore*/ @@ -520,7 +547,10 @@ opacity 0.4s; } -.vp-doc [class*='language-']:hover > button.copy + span.lang, +.vp-doc + [class*='language-']:hover:not(:has(.modal-container:hover)) + > button.copy + + span.lang, .vp-doc [class*='language-'] > button.copy:focus + span.lang { opacity: 0; } diff --git a/src/client/theme-default/styles/icons.css b/src/client/theme-default/styles/icons.css index b5612d1f8192..a1de8cfef932 100644 --- a/src/client/theme-default/styles/icons.css +++ b/src/client/theme-default/styles/icons.css @@ -87,6 +87,10 @@ --vp-icon-copy: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='rgba(128,128,128,1)' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' viewBox='0 0 24 24'%3E%3Crect width='8' height='4' x='8' y='2' rx='1' ry='1'/%3E%3Cpath d='M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2'/%3E%3C/svg%3E"); /* clipboard-copy */ --vp-icon-copied: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='rgba(128,128,128,1)' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' viewBox='0 0 24 24'%3E%3Crect width='8' height='4' x='8' y='2' rx='1' ry='1'/%3E%3Cpath d='M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2'/%3E%3Cpath d='m9 14 2 2 4-4'/%3E%3C/svg%3E"); + + --vp-icon-expand: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='rgba(128,128,128,1)' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' viewBox='0 0 24 24'%3E%3Cpath d='m21 21-6-6m6 6v-4.8m0 4.8h-4.8'/%3E%3Cpath d='M3 16.2V21m0 0h4.8M3 21l6-6'/%3E%3Cpath d='M21 7.8V3m0 0h-4.8M21 3l-6 6'/%3E%3Cpath d='M3 7.8V3m0 0h4.8M3 3l6 6'/%3E%3C/svg%3E"); + + --vp-icon-close: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='rgba(128,128,128,1)' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' viewBox='0 0 24 24'%3E%3Cpath d='M8 3v3a2 2 0 0 1-2 2H3'/%3E%3Cpath d='M21 8h-3a2 2 0 0 1-2-2V3'/%3E%3Cpath d='M3 16h3a2 2 0 0 1 2 2v3'/%3E%3Cpath d='M16 21v-3a2 2 0 0 1 2-2h3'/%3E%3C/svg%3E"); } /* social icons - used under CC0 1.0 from https://simpleicons.org/ */ diff --git a/src/node/markdown/markdown.ts b/src/node/markdown/markdown.ts index a423ee19cc03..b0d11358a652 100644 --- a/src/node/markdown/markdown.ts +++ b/src/node/markdown/markdown.ts @@ -37,6 +37,7 @@ import { linkPlugin } from './plugins/link' import { preWrapperPlugin } from './plugins/preWrapper' import { restoreEntities } from './plugins/restoreEntities' import { snippetPlugin } from './plugins/snippet' +import { codeModalPlugin } from './plugins/codeModal' export type { Header } from '../shared' @@ -116,6 +117,16 @@ export interface MarkdownOptions extends Options { * @default 'Copy Code' */ codeCopyButtonTitle?: string + /** + * Show an additional button to open a fullscreen modal in code blocks + * @default false + */ + codeModal?: boolean + /** + * The tooltip text for the modal button in code blocks + * @default 'Open Modal' + */ + codeModalButtonTitle?: string /* ==================== Markdown It Plugins ==================== */ @@ -201,6 +212,7 @@ export const createMarkdownRenderer = async ( ): Promise => { const theme = options.theme ?? { light: 'github-light', dark: 'github-dark' } const codeCopyButtonTitle = options.codeCopyButtonTitle || 'Copy Code' + const codeModalButtonTitle = options.codeModalButtonTitle || 'Open Modal' const hasSingleTheme = typeof theme === 'string' || 'name' in theme const md = MarkdownIt({ @@ -230,6 +242,7 @@ export const createMarkdownRenderer = async ( base ) .use(lineNumberPlugin, options.lineNumbers) + .use(codeModalPlugin, options.codeModal, { codeModalButtonTitle }) md.renderer.rules.table_open = function (tokens, idx, options, env, self) { return '\n' diff --git a/src/node/markdown/plugins/codeModal.ts b/src/node/markdown/plugins/codeModal.ts new file mode 100644 index 000000000000..134ad4e0c240 --- /dev/null +++ b/src/node/markdown/plugins/codeModal.ts @@ -0,0 +1,41 @@ +// markdown-it plugin for generating line numbers. +// It depends on preWrapper plugin. + +import type MarkdownIt from 'markdown-it' +import type { MarkdownOptions } from '../markdown' + +export const codeModalPlugin = ( + md: MarkdownIt, + enable = false, + options: MarkdownOptions = {} +) => { + const fence = md.renderer.rules.fence! + md.renderer.rules.fence = (...args) => { + const rawCode = fence(...args) + + const [tokens, idx] = args + const info = tokens[idx].info + + if ( + (!enable && !/:modal($| |=)/.test(info)) || + (enable && /:no-modal($| )/.test(info)) + ) { + return rawCode + } + + let end = rawCode.lastIndexOf('') + + let innerCode = + rawCode.substring(0, end) + + `` + + '' + + const modal = + `` + + '' + + return rawCode.substring(0, end) + modal + '' + } +} diff --git a/src/node/markdown/plugins/highlight.ts b/src/node/markdown/plugins/highlight.ts index fbd03e56050b..943044a4cb9d 100644 --- a/src/node/markdown/plugins/highlight.ts +++ b/src/node/markdown/plugins/highlight.ts @@ -93,6 +93,7 @@ export async function highlight( const vueRE = /-vue$/ const lineNoStartRE = /=(\d*)/ const lineNoRE = /:(no-)?line-numbers(=\d*)?$/ + const modalNoRE = /:(no-)?modal(=\d*)?$/ const mustacheRE = /\{\{.*?\}\}/g return (str: string, lang: string, attrs: string) => { @@ -101,6 +102,7 @@ export async function highlight( lang .replace(lineNoStartRE, '') .replace(lineNoRE, '') + .replace(modalNoRE, '') .replace(vueRE, '') .toLowerCase() || defaultLang diff --git a/src/node/markdown/plugins/preWrapper.ts b/src/node/markdown/plugins/preWrapper.ts index 906cac2afd1e..3d77bcc588d1 100644 --- a/src/node/markdown/plugins/preWrapper.ts +++ b/src/node/markdown/plugins/preWrapper.ts @@ -47,6 +47,7 @@ function extractLang(info: string) { .trim() .replace(/=(\d*)/, '') .replace(/:(no-)?line-numbers({| |$|=\d*).*/, '') + .replace(/:(no-)?modal({| |$|=\d*).*/, '') .replace(/(-vue|{| ).*$/, '') .replace(/^vue-html$/, 'template') .replace(/^ansi$/, '')