| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411 |
- <script lang="ts" setup>
- import type { Options as MarkdownOptions } from 'markdown-it'
- import Markdown from 'markdown-it'
- import { full as emoji } from 'markdown-it-emoji'
- import markdownLink from 'markdown-it-link-attributes'
- import markdownTaskLists from 'markdown-it-task-lists'
- import { computed, ref } from 'vue'
- import xss, { whiteList } from 'xss'
- import { toggleCheckbox, serializeAttr } from './utils'
- interface IImage {
- id: string | number
- url: string
- }
- interface Options {
- markdown: MarkdownOptions
- linkAttributes: markdownLink.Config
- tasklists: markdownTaskLists.Config
- }
- interface MarkdownProps {
- content?: string | null
- withMultiBreaks?: boolean
- images?: IImage[]
- loading?: boolean
- loadingBlocks?: number
- loadingRows?: number
- theme?: string
- options?: Options
- }
- const props = withDefaults(defineProps<MarkdownProps>(), {
- content: '',
- withMultiBreaks: false,
- images: () => [],
- loading: false,
- loadingBlocks: 2,
- loadingRows: 3,
- theme: 'markdown',
- options: () => ({
- markdown: {
- html: false,
- linkify: true,
- typographer: true,
- breaks: true
- },
- linkAttributes: {
- attrs: {
- target: '_blank',
- rel: 'noopener'
- }
- },
- tasklists: {
- enabled: true,
- label: true,
- labelAfter: false
- }
- })
- })
- const editor = ref<HTMLDivElement | undefined>(undefined)
- const { options } = props
- const md = new Markdown(options.markdown)
- .use(markdownLink, options.linkAttributes)
- .use(emoji)
- .use(markdownTaskLists, options.tasklists)
- const xssWhiteList = {
- ...whiteList,
- label: ['class', 'for'],
- iframe: ['width', 'height', 'src', 'title', 'frameborder', 'allow', 'referrerpolicy']
- }
- const htmlContent = computed(() => {
- if (!props.content) {
- return ''
- }
- const imageUrls: { [key: string]: string } = {}
- if (props.images) {
- props.images.forEach((image: IImage) => {
- if (!image) {
- // Happens if an image got deleted but the workflow
- // still has a reference to it
- return
- }
- imageUrls[image.id] = image.url
- })
- }
- const fileIdRegex = new RegExp('fileId:([0-9]+)')
- let contentToRender = props.content
- if (props.withMultiBreaks) {
- contentToRender = contentToRender.replaceAll('\n\n', '\n \n')
- }
- const html = md.render(contentToRender)
- const safeHtml = xss(html, {
- onTagAttr(tag, name, value) {
- if (tag === 'img' && name === 'src') {
- if (value.match(fileIdRegex)) {
- const id = value.split('fileId:')[1]
- const imageUrl = imageUrls[id]
- if (!imageUrl) {
- return ''
- }
- return serializeAttr(tag, name, imageUrl)
- }
- // Only allow http requests to supported image files from the `static` directory
- const isImageFile = value.split('#')[0].match(/\.(jpeg|jpg|gif|png|webp)$/) !== null
- const isStaticImageFile = isImageFile && value.startsWith('/static/')
- if (!value.startsWith('https://') && !isStaticImageFile) {
- return ''
- }
- }
- // Return nothing, means keep the default handling measure
- return
- },
- onTag(tag, code) {
- if (tag === 'img' && code.includes('alt="workflow-screenshot"')) {
- return ''
- }
- // return nothing, keep tag
- return
- },
- onIgnoreTag(tag, tagHTML) {
- // Allow checkboxes
- if (tag === 'input' && tagHTML.includes('type="checkbox"')) {
- return tagHTML
- }
- return
- },
- whiteList: xssWhiteList
- })
- return safeHtml
- })
- const emit = defineEmits<{
- 'markdown-click': [link: HTMLAnchorElement, e: MouseEvent]
- 'update-content': [content: string]
- }>()
- const onClick = (event: MouseEvent) => {
- let clickedLink: HTMLAnchorElement | null = null
- if (event.target instanceof HTMLAnchorElement) {
- clickedLink = event.target
- }
- if (event.target instanceof HTMLElement && event.target.matches('a *')) {
- const parentLink = event.target.closest('a')
- if (parentLink) {
- clickedLink = parentLink
- }
- }
- if (clickedLink) {
- emit('markdown-click', clickedLink, event)
- }
- }
- // Handle checkbox changes
- const onChange = async (event: Event) => {
- if (event.target instanceof HTMLInputElement && event.target.type === 'checkbox') {
- const checkboxes = editor.value?.querySelectorAll('input[type="checkbox"]')
- if (checkboxes) {
- // Get the index of the checkbox that was clicked
- const index = Array.from(checkboxes).indexOf(event.target)
- if (index !== -1) {
- onCheckboxChange(index)
- }
- }
- }
- }
- const onMouseDown = (event: MouseEvent) => {
- // Mouse down on input fields is caught by node view handlers
- // which prevents checking them, this will prevent that
- if (event.target instanceof HTMLInputElement) {
- event.stopPropagation()
- }
- }
- // Update markdown when checkbox state changes
- const onCheckboxChange = (index: number) => {
- const currentContent = props.content
- if (!currentContent) {
- return
- }
- // We are using index to connect the checkbox with the corresponding line in the markdown
- const newContent = toggleCheckbox(currentContent, index)
- emit('update-content', newContent)
- }
- </script>
- <template>
- <div class="markdown">
- <!-- eslint-disable vue/no-v-html -->
- <div
- v-if="!loading"
- ref="editor"
- :class="$style[theme]"
- @click="onClick"
- @mousedown="onMouseDown"
- @change="onChange"
- v-html="htmlContent"
- />
- <!-- eslint-enable vue/no-v-html -->
- <div v-else :class="$style.markdown">
- <div v-for="(_, index) in loadingBlocks" :key="index" v-loading="loading">
- <div :class="$style.spacer" />
- </div>
- </div>
- </div>
- </template>
- <style lang="less" module>
- .markdown {
- color: var(--color--text);
- * {
- font-size: var(--font-size--md);
- line-height: var(--line-height--xl);
- }
- h1,
- h2,
- h3,
- h4 {
- margin-bottom: var(--spacing--sm);
- font-size: var(--font-size--md);
- font-weight: var(--font-weight--bold);
- }
- h3,
- h4 {
- font-weight: var(--font-weight--bold);
- }
- p,
- span {
- margin-bottom: var(--spacing--sm);
- }
- ul,
- ol {
- margin-bottom: var(--spacing--sm);
- padding-left: var(--spacing--md);
- li {
- margin-top: 0.25em;
- }
- }
- pre > code {
- background-color: var(--color--background);
- color: var(--color--text--shade-1);
- }
- li > code,
- p > code {
- padding: 0 var(--spacing--4xs);
- color: var(--color--text--shade-1);
- background-color: var(--color--background);
- }
- .label {
- color: var(--color--text);
- }
- img {
- max-width: 100%;
- border-radius: var(--radius--lg);
- }
- blockquote {
- padding-left: 10px;
- font-style: italic;
- border-left: var(--border-color) 2px solid;
- }
- }
- input[type='checkbox'] {
- accent-color: var(--color--primary);
- }
- input[type='checkbox'] + label {
- cursor: pointer;
- }
- .sticky {
- color: var(--sticky--color--text);
- overflow-wrap: break-word;
- h1,
- h2,
- h3,
- h4,
- h5,
- h6 {
- color: var(--sticky--color--text);
- }
- h1,
- h2,
- h3,
- h4 {
- margin-bottom: var(--spacing--2xs);
- font-weight: var(--font-weight--bold);
- line-height: var(--line-height--lg);
- }
- h1 {
- font-size: 36px;
- }
- h2 {
- font-size: 24px;
- }
- h3,
- h4,
- h5,
- h6 {
- font-size: var(--font-size--md);
- }
- p {
- margin-bottom: var(--spacing--2xs);
- font-size: var(--font-size--sm);
- font-weight: var(--font-weight--regular);
- line-height: var(--line-height--lg);
- }
- ul,
- ol {
- margin-bottom: var(--spacing--2xs);
- padding-left: var(--spacing--md);
- li {
- margin-top: 0.25em;
- font-size: var(--font-size--sm);
- font-weight: var(--font-weight--regular);
- line-height: var(--line-height--md);
- }
- &:has(input[type='checkbox']) {
- list-style-type: none;
- padding-left: var(--spacing--5xs);
- }
- }
- pre > code {
- background-color: var(--sticky--code--color--background);
- color: var(--sticky--code--color--text);
- }
- pre > code,
- li > code,
- p > code {
- color: var(--sticky--code--color--text);
- }
- a {
- &:hover {
- text-decoration: underline;
- }
- }
- img {
- object-fit: contain;
- margin-top: var(--spacing--xs);
- margin-bottom: var(--spacing--2xs);
- &[src*='#full-width'] {
- width: 100%;
- }
- }
- }
- .sticky,
- .markdown {
- pre {
- margin-bottom: var(--spacing--sm);
- display: grid;
- }
- pre > code {
- display: block;
- padding: var(--spacing--sm);
- overflow-x: auto;
- }
- iframe {
- aspect-ratio: 16/9 auto;
- }
- summary {
- cursor: pointer;
- }
- }
- .spacer {
- margin: var(--spacing--2xl);
- }
- </style>
|